First commit

This commit is contained in:
2026-03-16 23:54:36 +01:00 Verified
commit be129af1d9
53 changed files with 49704 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
# Dependency directories
node_modules
npm-debug.log
yarn-error.log
yarn-debug.log
.pnp.*
# Next.js build output
.next
out
build
# Git
.git
.gitignore
# Environment variables
# We usually don't want local env files in the image. Pass them via Docker run/compose instead.
.env*.local
.env
# Local Database Files (CRITICAL if using SQLite!)
*.db
*.db-journal
*.sqlite
*.sqlite3
data/
# Mac/Windows system files
.DS_Store
Thumbs.db
# Typescript cache
*.tsbuildinfo
+56
View File
@@ -0,0 +1,56 @@
generated/
dev.db
# PWA / Serwist generated files
public/sw.js
public/sw.js.map
public/swe-worker-*.js
public/swe-worker-*.js.map
public/workbox-*.js
public/workbox-*.js.map
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
/app/generated/prisma
+18
View File
@@ -0,0 +1,18 @@
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 -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
+36
View File
@@ -0,0 +1,36 @@
# 1. Install dependencies
FROM node:alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package*.json ./
RUN npm ci
# 2. Build the app
FROM node:alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# 3. Production image (Slimmad!)
FROM node:alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN mkdir -p data
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
+3
View File
@@ -0,0 +1,3 @@
# Naturvärdarna PWA
Källkod för [app.naturvardarna.com](https://app.naturvardarna.com/)
+154
View File
@@ -0,0 +1,154 @@
// app/actions/admin.ts
"use server";
import prisma from "../../lib/prisma";
import { unstable_noStore as noStore } from "next/cache";
// ==========================================
// 1. FETCH DATA
// ==========================================
export async function getAdminData() {
noStore(); // CRITICAL: Tells Next.js to NEVER cache this response
return await prisma.period.findMany({
include: {
youths: {
include: {
attendance: true
}
}
}
});
}
// ==========================================
// 2. PERIOD ACTIONS
// ==========================================
export async function createPeriodDb(name: string, startDate: string, endDate: string) {
const newPeriod = await prisma.period.create({
data: { name, startDate, endDate }
});
return newPeriod;
}
export async function deletePeriodDb(periodId: string) {
const youths = await prisma.youth.findMany({ where: { periodId } });
const youthIds = youths.map(y => y.id);
if (youthIds.length > 0) {
await prisma.attendance.deleteMany({
where: { youthId: { in: youthIds } }
});
}
await prisma.youth.deleteMany({
where: { periodId }
});
await prisma.period.delete({
where: { id: periodId }
});
return { success: true }; // Returning JSON prevents "Unexpected end of JSON input"
}
// ==========================================
// 3. YOUTH ACTIONS
// ==========================================
export async function bulkAddYouthDb(periodId: string, youthData: { name: string, team: string }[]) {
await prisma.youth.createMany({
data: youthData.map(y => ({
name: y.name,
team: y.team,
periodId: periodId
}))
});
return { success: true };
}
export async function removeYouthDb(youthId: string) {
await prisma.attendance.deleteMany({
where: { youthId }
});
await prisma.youth.delete({
where: { id: youthId }
});
return { success: true };
}
// ==========================================
// 4. ATTENDANCE ACTIONS
// ==========================================
export async function setAttendanceDb(date: string, youthId: string, shiftId: string, hoursWorked: number, weightedHours: number, status: string, note: string) {
await prisma.attendance.upsert({
where: { date_youthId_shiftId: { date, youthId, shiftId } },
update: { hoursWorked, weightedHours, status, note },
create: { date, youthId, shiftId, hoursWorked, weightedHours, status, note }
});
return { success: true };
}
export async function removeAttendanceDb(date: string, youthId: string, shiftId: string) {
await prisma.attendance.delete({
where: { date_youthId_shiftId: { date, youthId, shiftId } }
}).catch(() => { /* Ignore if it doesn't exist */ });
return { success: true };
}
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({
where: { date_youthId_shiftId: { date: record.date, youthId: record.youthId, shiftId: record.shiftId } },
update: { hoursWorked: record.hoursWorked, weightedHours: record.weightedHours, status: record.status, note: record.note },
create: { date: record.date, youthId: record.youthId, shiftId: record.shiftId, hoursWorked: record.hoursWorked, weightedHours: record.weightedHours, status: record.status, note: record.note }
})
)
);
return { success: true };
}
// ==========================================
// 5. AUTHENTICATION ACTIONS
// ==========================================
export async function getLoginUsers() {
noStore();
return await prisma.user.findMany({
select: { id: true, name: true, role: true }
});
}
export async function verifyLogin(username: string, pin: string) {
noStore();
const users = await prisma.user.findMany();
const user = users.find(u => u.name.toLowerCase() === username.toLowerCase().trim());
if (user && user.pin === pin) {
return { success: true, user: { id: user.id, name: user.name, role: user.role } };
}
return { success: false, user: null };
}
// ==========================================
// 6. OFFLINE SYNC ACTIONS
// ==========================================
export async function syncOfflineQueueDb(queue: any[]) {
for (const action of queue) {
if (action.type === 'SET_ATTENDANCE') {
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);
}
}
return { success: true };
}
+375
View File
@@ -0,0 +1,375 @@
// app/admin/AttendanceTab.tsx
"use client";
import { CheckCircle, ChevronLeft, ChevronRight, ClipboardCheck, Edit3, FileText, Undo, Zap } from 'lucide-react';
import Image from 'next/image';
import React, { useEffect, useState } from 'react';
import falconIcon from '../assets/falcon.svg';
import porpoiseIcon from '../assets/porpoise.svg';
import scheduleData from '../data/schedule.json';
import { type AttendanceDataMap, calculateShiftDuration, formatTimeHHMM, getAttendanceKey, isWeekend, parseTimeInput, type Period, toIsoDate } from './adminTypes';
interface Props {
periods: Period[];
attendance: AttendanceDataMap;
setManualAttendance: (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON', status: 'Absent' | 'Late' | 'Present', hours: number, note?: string) => void;
bulkSetManualAttendance: (records: any[]) => void;
addPendingAttendance: (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => void;
removeAttendanceEntry: (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => void;
activePeriodId: string;
setActivePeriodId: (id: string) => void;
}
// NEW: Dynamic Shift Helper
export const getTeamShiftInfo = (daySchedule: any, team: 'PF' | 'TU') => {
if (!daySchedule) return { time: 'Ledig', shiftId: 'MORNING' as 'MORNING' | 'AFTERNOON' };
const pfTime = daySchedule.pilgrimsfalkarna?.time || 'Ledig';
const tuTime = daySchedule.tumlarna?.time || 'Ledig';
const pfStart = pfTime !== 'Ledig' ? parseInt(pfTime.match(/(\d+):/)?.[1] || '99') : 99;
const tuStart = tuTime !== 'Ledig' ? parseInt(tuTime.match(/(\d+):/)?.[1] || '99') : 99;
if (team === 'PF') {
return { time: pfTime, shiftId: (pfStart > tuStart) ? 'AFTERNOON' : 'MORNING' as 'MORNING' | 'AFTERNOON' };
} else {
return { time: tuTime, shiftId: (tuStart > pfStart) ? 'AFTERNOON' : (pfStart === tuStart ? 'AFTERNOON' : 'MORNING') as 'MORNING' | 'AFTERNOON' };
}
};
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;
};
const getDailyCompletionStats = (date: string, period: Period, attendance: AttendanceDataMap) => {
const dayNameStr = new Date(date + 'T12:00:00').toLocaleDateString('sv-SE', { weekday: 'long' }).toLowerCase();
const daySchedule = scheduleData.find(d => d.day.toLowerCase() === dayNameStr);
if (!daySchedule) return { expected: 0, completed: 0, isComplete: true, hasWork: false };
let expected = 0;
let completed = 0;
let hasWork = false;
// Use dynamic shift checking
if (daySchedule.pilgrimsfalkarna && daySchedule.pilgrimsfalkarna.time !== 'Ledig') {
hasWork = true;
const { shiftId } = getTeamShiftInfo(daySchedule, 'PF');
const pfYouth = period.youthList.filter(y => y.team === 'PF');
expected += pfYouth.length;
pfYouth.forEach(y => {
const entry = attendance[getAttendanceKey(date, y.id, shiftId)];
if (entry && entry.status !== 'Pending') completed++;
});
}
if (daySchedule.tumlarna && daySchedule.tumlarna.time !== 'Ledig') {
hasWork = true;
const { shiftId } = getTeamShiftInfo(daySchedule, 'TU');
const tuYouth = period.youthList.filter(y => y.team === 'TU');
expected += tuYouth.length;
tuYouth.forEach(y => {
const entry = attendance[getAttendanceKey(date, y.id, shiftId)];
if (entry && entry.status !== 'Pending') completed++;
});
}
return { expected, completed, isComplete: expected > 0 && completed >= expected, hasWork };
};
export const AttendanceTab: React.FC<Props> = ({ periods, attendance, setManualAttendance, bulkSetManualAttendance, addPendingAttendance, removeAttendanceEntry, activePeriodId, setActivePeriodId }) => {
const activePeriod = periods.find(p => p.id === activePeriodId);
const [currentDate, setCurrentDate] = useState<string>(activePeriod ? activePeriod.startDate : toIsoDate(new Date()));
useEffect(() => {
if (activePeriod) {
const today = toIsoDate(new Date());
if (today >= activePeriod.startDate && today <= activePeriod.endDate) {
setCurrentDate(today);
} else {
setCurrentDate(activePeriod.startDate);
}
}
}, [activePeriodId, activePeriod]);
if (!activePeriod) return <p className="text-center font-bold text-slate-teal mt-10">Ingen period aktiv.</p>;
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 daySchedule = scheduleData.find(d => d.day.toLowerCase() === dayNameStr.toLowerCase());
const timelineDays = getDaysInPeriod(activePeriod.startDate, activePeriod.endDate);
const todayIso = toIsoDate(new Date());
const currentDayStats = getDailyCompletionStats(currentDate, activePeriod, attendance);
const markStandardAttendance = (youthId: string, team: 'PF' | 'TU', shiftId: 'MORNING' | 'AFTERNOON') => {
const { time: shiftTime } = getTeamShiftInfo(daySchedule, team);
const rawHours = calculateShiftDuration(shiftTime);
const actualHours = isWeekend(currentDate) ? Math.max(0, rawHours - 0.5) : rawHours;
if (actualHours > 0) setManualAttendance(currentDate, youthId, shiftId, 'Present', actualHours);
};
// Sort teams chronologically
const teamsToRender = ['PF', 'TU'].sort((a, b) => {
const timeA = getTeamShiftInfo(daySchedule, a as 'PF' | 'TU').time;
const timeB = getTeamShiftInfo(daySchedule, b as 'PF' | 'TU').time;
const startA = timeA !== 'Ledig' ? parseInt(timeA.match(/(\d+):/)?.[1] || '99') : 99;
const startB = timeB !== 'Ledig' ? parseInt(timeB.match(/(\d+):/)?.[1] || '99') : 99;
return startA - startB;
});
return (
<div className="space-y-6 animate-fade-in">
{/* Timeline Overview */}
<div className="bg-eggshell border-2 border-slate-teal/20 p-4 md:p-6 rounded-2xl shadow-sm">
<div className="flex justify-between items-center mb-1">
<h3 className="text-xs font-black text-slate-teal uppercase tracking-widest ml-1">Periodöversikt</h3>
<select
value={activePeriodId}
onChange={(e) => setActivePeriodId(e.target.value)}
className="bg-white border border-slate-teal/20 p-1.5 rounded-lg font-bold text-slate-teal text-sm cursor-pointer shadow-sm focus:outline-none"
>
{periods.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
<div className="flex overflow-x-auto gap-3 pb-6 pt-4 px-2 scrollbar-hide">
{timelineDays.map(day => {
const stats = getDailyCompletionStats(day, activePeriod, attendance);
const isPast = day < todayIso;
const isToday = day === todayIso;
const isSelected = day === currentDate;
let bgClass = "bg-white text-ebony border-slate-teal/20";
if (!stats.hasWork) bgClass = "bg-slate-teal/5 text-ebony/40 border-transparent";
else if (stats.isComplete) bgClass = "bg-moss text-white border-moss";
else if (isPast || isToday) bgClass = "bg-goldenrod text-white border-goldenrod";
return (
<button
key={day}
onClick={() => setCurrentDate(day)}
className={`flex flex-col items-center justify-center min-w-12.5 p-2 rounded-xl border-2 transition-all ${bgClass} ${isSelected ? 'ring-2 ring-slate-teal ring-offset-2 ring-offset-eggshell scale-110 shadow-md' : 'hover:brightness-95'}`}
>
<span className="text-[10px] font-bold uppercase">{new Date(day + 'T12:00:00').toLocaleDateString('sv-SE', { weekday: 'short' })}</span>
<span className="text-xs font-black">{new Date(day + 'T12:00:00').getDate()}/{new Date(day + 'T12:00:00').getMonth() + 1}</span>
</button>
);
})}
</div>
</div>
{/* Date Navigation */}
<div className="flex flex-col md:flex-row justify-between items-center bg-eggshell border-2 border-slate-teal/20 p-4 md:p-6 rounded-2xl shadow-sm gap-4">
<div className="flex items-center gap-4 w-full md:w-auto justify-between md:justify-start">
<button onClick={() => changeDate(-1)} className="p-2 text-slate-teal hover:bg-slate-teal/10 rounded-full transition-colors"><ChevronLeft size={24} /></button>
<div className="text-center w-40">
<h2 className="text-lg font-black text-ebony capitalize">{dayNameStr}</h2>
<p className="text-xs font-bold text-moss">{currentDate}</p>
</div>
<button onClick={() => changeDate(1)} className="p-2 text-slate-teal hover:bg-slate-teal/10 rounded-full transition-colors"><ChevronRight size={24} /></button>
</div>
{currentDayStats.hasWork && (
<div className={`w-full md:w-auto px-5 py-2.5 rounded-lg font-bold text-sm flex items-center justify-center border ${currentDayStats.isComplete ? 'bg-moss/20 text-moss border-moss/30' : 'bg-goldenrod/10 text-goldenrod border-goldenrod/30'}`}>
{currentDayStats.isComplete ? <><CheckCircle size={18} className="mr-2" /> Dagen är komplett!</> : `Ifyllt: ${currentDayStats.completed} av ${currentDayStats.expected} pers`}
</div>
)}
</div>
{/* Attendance Lists */}
{!daySchedule || !currentDayStats.hasWork ? (
<div className="bg-eggshell border-2 border-slate-teal/20 p-6 rounded-2xl text-center"><p className="font-bold text-ebony">Inga schemalagda pass denna dag.</p></div>
) : (
teamsToRender.map(teamStr => {
const team = teamStr as 'PF' | 'TU';
const { time: standardTime, shiftId } = getTeamShiftInfo(daySchedule, team);
if (standardTime === 'Ledig') return null;
const rawDuration = calculateShiftDuration(standardTime);
const isWknd = isWeekend(currentDate);
const actualDuration = isWknd ? Math.max(0, rawDuration - 0.5) : rawDuration;
const weight = isWknd ? 1.5 : 1.0;
const weightedDuration = actualDuration * weight;
const scheduledYouth = activePeriod.youthList.filter(y => y.team === team);
const extraYouth = activePeriod.youthList.filter(y => y.team !== team && attendance[getAttendanceKey(currentDate, y.id, shiftId)]);
// Display youth sorted by name alphabetically
const displayYouth = [...scheduledYouth, ...extraYouth].sort((a, b) => a.name.localeCompare(b.name));
const availableExtras = activePeriod.youthList.filter(y => !displayYouth.some(dy => dy.id === y.id)).sort((a, b) => a.name.localeCompare(b.name));
const handleQuickLogAll = () => {
const recordsToUpdate: any[] = [];
displayYouth.forEach(y => {
const entry = attendance[getAttendanceKey(currentDate, y.id, shiftId)];
if (!entry || entry.status === 'Pending') {
recordsToUpdate.push({
date: currentDate,
youthId: y.id,
shiftId: shiftId,
status: 'Present',
hoursWorked: actualDuration,
weightedHours: weightedDuration,
note: ''
});
}
});
if (recordsToUpdate.length > 0) {
bulkSetManualAttendance(recordsToUpdate);
}
};
return (
<div key={team} className="bg-eggshell border-2 border-slate-teal/20 p-4 md:p-6 rounded-2xl shadow-sm">
<div className="flex flex-col md:flex-row md:justify-between md:items-center mb-6 gap-3">
<h2 className="text-xl font-black text-ebony uppercase tracking-widest flex items-center">
<div className={`flex items-center justify-center w-8 h-8 rounded-full ${team === 'PF' ? 'bg-gold' : 'bg-seafoam'} mr-3 shrink-0 shadow-sm`}>
<Image
src={team === 'PF' ? falconIcon : porpoiseIcon}
alt={team === 'PF' ? 'Pilgrimsfalk' : 'Tumlare'}
width={18}
height={18}
/>
</div>
{team === 'PF' ? 'Pilgrimsfalkarna' : 'Tumlarna'}
</h2>
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-3 bg-slate-teal/5 px-4 py-2 rounded-lg border border-slate-teal/10 text-sm">
<FileText size={16} className="text-slate-teal" />
<span className="font-bold text-ebony">{standardTime} ({formatTimeHHMM(rawDuration)})</span>
{isWknd && <span className="text-xs font-bold text-goldenrod bg-goldenrod/10 px-2 py-0.5 rounded border border-goldenrod/20">Helg (-30m)</span>}
</div>
<button
onClick={handleQuickLogAll}
className="flex items-center gap-2 bg-moss/10 text-moss hover:bg-moss/20 border border-moss/20 px-4 py-2 rounded-lg font-bold text-sm transition-colors"
title="Sätt alla som inte har en ifylld tid till 'Hela passet'"
>
<Zap size={16} /> Snabblogga alla
</button>
</div>
</div>
<div className="space-y-4">
{displayYouth.map(youth => {
const entry = attendance[getAttendanceKey(currentDate, youth.id, shiftId)];
const isExtra = youth.team !== team;
const isPending = entry?.status === 'Pending';
const isCompleted = entry && !isPending;
return (
<div key={youth.id} className={`flex flex-col xl:flex-row xl:justify-between xl:items-center p-3 rounded-xl border-2 transition-colors ${isCompleted ? 'bg-moss/10 border-moss/40' : (isPending ? 'bg-goldenrod/5 border-goldenrod/40' : 'bg-white border-slate-teal/10 shadow-sm')}`}>
<div className="flex items-center gap-3 mb-3 xl:mb-0">
{isCompleted ? <CheckCircle size={20} className="text-moss shrink-0" /> : <div className="w-5 h-5 rounded-full border-2 border-slate-teal/20 shrink-0"></div>}
<span className={`font-bold text-lg ${isCompleted ? 'text-moss' : 'text-ebony'}`}>
{youth.name}
{isExtra && <span className="ml-2 text-[10px] text-slate-teal bg-slate-teal/10 px-1.5 py-0.5 rounded uppercase tracking-wider">Extra pass</span>}
</span>
</div>
<div className="flex items-center justify-end gap-3 w-full xl:w-auto flex-wrap">
{isPending && (
<span className="text-xs font-bold bg-goldenrod/10 text-goldenrod px-3 py-1.5 rounded-lg border border-goldenrod/20 shadow-sm animate-pulse">
Väntar tid...
</span>
)}
{isCompleted && (
<span className="text-xs font-bold bg-white text-moss px-3 py-1.5 rounded-lg border border-moss/20 shadow-sm">
{entry.status === 'Absent' ? entry.note : `${formatTimeHHMM(entry.hoursWorked)} arbetat (+${entry.weightedHours.toFixed(1)}t pott)`}
</span>
)}
<div className="flex flex-wrap items-center gap-2">
{(!entry || isPending) && (
<>
<button onClick={() => markStandardAttendance(youth.id, team as 'PF' | 'TU', shiftId)} className="bg-slate-teal/10 text-slate-teal hover:bg-slate-teal/20 px-3 py-1.5 rounded-lg font-bold text-sm flex items-center gap-1.5 transition-colors">
<ClipboardCheck size={16} /> Hela passet
</button>
<button onClick={() => {
const input = prompt(`Timmar arbetade (t.ex. 2:30 eller 2.5):`, formatTimeHHMM(actualDuration));
const hrs = input ? parseTimeInput(input) : 0;
if (hrs > 0) setManualAttendance(currentDate, youth.id, shiftId, 'Present', hrs);
}} className="bg-goldenrod/10 text-goldenrod hover:bg-goldenrod/20 px-3 py-1.5 rounded-lg font-bold text-sm transition-colors">
<Edit3 size={16} />
</button>
<select
value=""
onChange={(e) => {
if (!e.target.value) return;
let reason = e.target.value;
if (reason === 'Custom') reason = prompt('Ange anledning:') || 'Frånvarande';
setManualAttendance(currentDate, youth.id, shiftId, 'Absent', 0, reason);
}}
className="bg-goldenrod/10 text-goldenrod border border-goldenrod/20 hover:bg-goldenrod/20 px-2 py-1.5 rounded-lg font-bold text-sm transition-colors cursor-pointer appearance-none text-center outline-none"
>
<option value="">+ Frånvaro...</option>
<option value="Sjuk">Sjuk</option>
<option value="Uteblev">Uteblev</option>
<option value="Ledig">Ledig</option>
<option value="Custom">Annan...</option>
</select>
</>
)}
{entry && (
<button
onClick={() => removeAttendanceEntry(currentDate, youth.id, shiftId)}
className="text-moss hover:text-goldenrod p-2 bg-white rounded-lg border border-moss/20 shadow-sm transition-colors"
title="Ångra och ta bort närvaro"
>
<Undo size={16} />
</button>
)}
</div>
</div>
</div>
);
})}
{availableExtras.length > 0 && (
<div className="pt-2 border-t border-slate-teal/10 mt-2">
<select
value=""
onChange={(e) => {
if (e.target.value) {
addPendingAttendance(currentDate, e.target.value, shiftId);
}
}}
className="bg-white border border-slate-teal/20 p-2 rounded-lg font-bold text-slate-teal text-sm w-full md:w-auto cursor-pointer"
>
<option value="">+ Lägg till extra person detta pass...</option>
{availableExtras.map(y => (
<option key={y.id} value={y.id}>{y.name}</option>
))}
</select>
</div>
)}
</div>
</div>
);
})
)}
</div>
);
};
+254
View File
@@ -0,0 +1,254 @@
// app/admin/ReportTab.tsx
"use client";
import { AlertTriangle, ChevronDown, ChevronUp, Clock, Download, Users } from 'lucide-react';
import Image from 'next/image';
import React, { useState } from 'react';
import scheduleData from '../data/schedule.json';
import falconIcon from '../assets/falcon.svg';
import porpoiseIcon from '../assets/porpoise.svg';
import { type AttendanceDataMap, formatTimeHHMM, isWeekend, type Period, type Role } from './adminTypes';
import { getTeamShiftInfo } from './AttendanceTab';
interface Props {
periods: Period[];
attendance: AttendanceDataMap;
activePeriodId: string;
setActivePeriodId: (id: string) => void;
currentUserRole: Role;
}
const POT_HOUR_LIMIT = 90;
const formatHours = (h: number) => Number(h.toFixed(1)).toString();
export const ReportTab: React.FC<Props> = ({ periods, attendance, activePeriodId, setActivePeriodId, currentUserRole }) => {
const activePeriod = periods.find(p => p.id === activePeriodId);
const [expandedYouthId, setExpandedYouthId] = useState<string | null>(null);
if (!activePeriod) return <p className="text-center font-bold text-slate-teal mt-10">Ingen period tillgänglig.</p>;
const getPeriodHoursTotal = (youthId: string): number => {
let total = 0;
for (const key in attendance) {
const entry = attendance[key];
if (entry.youthId === youthId && entry.date >= activePeriod.startDate && entry.date <= activePeriod.endDate) {
total += entry.weightedHours;
}
}
return total;
};
const getTimelineForYouth = (youthId: string) => {
return Object.values(attendance)
.filter(a => a.youthId === youthId && a.date >= activePeriod.startDate && a.date <= activePeriod.endDate)
.sort((a, b) => {
if (a.date !== b.date) return a.date.localeCompare(b.date);
return a.shiftId === 'MORNING' ? -1 : 1;
});
};
const exportToCSV = () => {
if (!activePeriod) return;
let csvContent = "Datum;Pass;Namn;Lag;Status;Arbetad Tid (HH:MM);Viktad Pott;Anteckning\n";
const entries = Object.values(attendance).filter(a => a.date >= activePeriod.startDate && a.date <= activePeriod.endDate);
entries.sort((a, b) => {
if (a.date !== b.date) return a.date.localeCompare(b.date);
if (a.shiftId !== b.shiftId) return a.shiftId.localeCompare(b.shiftId);
const nameA = activePeriod.youthList.find(y => y.id === a.youthId)?.name || '';
const nameB = activePeriod.youthList.find(y => y.id === b.youthId)?.name || '';
return nameA.localeCompare(nameB);
});
entries.forEach(entry => {
const youth = activePeriod.youthList.find(y => y.id === entry.youthId);
if (!youth) return;
const isWknd = isWeekend(entry.date);
const shift = isWknd ? 'Hela dagen' : (entry.shiftId === 'MORNING' ? 'Morgon' : 'Eftermiddag');
const teamName = youth.team === 'PF' ? 'Pilgrimsfalk' : 'Tumlare';
const worked = formatTimeHHMM(entry.hoursWorked);
const weighted = entry.weightedHours.toFixed(2).replace('.', ',');
const note = entry.note || '';
csvContent += `${entry.date};${shift};${youth.name};${teamName};${entry.status};${worked};${weighted};${note}\n`;
});
csvContent += "\nSummering (Timpott)\nNamn;Lag;Total Viktad Pott\n";
const sortedYouth = [...activePeriod.youthList].sort((a, b) => a.name.localeCompare(b.name));
sortedYouth.forEach(youth => {
const total = getPeriodHoursTotal(youth.id).toFixed(2).replace('.', ',');
const teamName = youth.team === 'PF' ? 'Pilgrimsfalk' : 'Tumlare';
csvContent += `${youth.name};${teamName};${total}\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", `Narvaro_${activePeriod.name.replace(/ /g, '_')}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const sortedActiveYouth = [...activePeriod.youthList].sort((a, b) => a.name.localeCompare(b.name));
return (
<div className="space-y-6 animate-fade-in">
{/* Header / Period Selector */}
<div className="bg-eggshell border-2 border-slate-teal/20 p-4 md:p-6 rounded-2xl shadow-sm text-center flex flex-col md:flex-row justify-between items-center gap-4">
<select
value={activePeriodId}
onChange={(e) => { setActivePeriodId(e.target.value); setExpandedYouthId(null); }}
className="bg-white border border-slate-teal/20 p-2.5 rounded-lg font-bold text-slate-teal w-full md:w-auto focus:outline-none"
>
{periods.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
<div>
<h2 className="text-xl font-black text-ebony uppercase tracking-widest">{activePeriod.name}</h2>
<p className="font-bold text-moss">{activePeriod.startDate} {activePeriod.endDate}</p>
</div>
</div>
{/* PERIOD HOUR POT REPORT */}
<div className="bg-eggshell border-2 border-slate-teal/20 p-4 md:p-6 rounded-2xl shadow-sm">
<div className="flex flex-col md:flex-row justify-between md:items-center mb-8 gap-4 px-1 md:px-2">
<h2 className="text-xl font-black text-ebony uppercase tracking-widest flex items-center">
<Users className="mr-3 text-slate-teal" size={24} />
Timrapport
</h2>
{currentUserRole !== 'Viewer' && (
<button
onClick={exportToCSV}
className="flex items-center justify-center gap-2 bg-seafoam/20 hover:bg-seafoam/40 text-slate-teal font-bold px-5 py-2.5 rounded-lg transition-colors border border-seafoam/30 w-full md:w-auto"
>
<Download size={18} />
Exportera till Excel
</button>
)}
</div>
<div className="space-y-4">
{sortedActiveYouth.map(youth => {
const total = getPeriodHoursTotal(youth.id);
const warningStatus: 'none' | 'yellow' | 'red' = total > POT_HOUR_LIMIT ? 'red' : (total >= POT_HOUR_LIMIT - 10 ? 'yellow' : 'none');
const isExpanded = expandedYouthId === youth.id;
const timeline = getTimelineForYouth(youth.id);
return (
<div key={youth.id} className="bg-white border border-slate-teal/10 rounded-xl shadow-inner overflow-hidden">
<div className="flex flex-col md:flex-row md:justify-between md:items-center p-6 md:px-8 gap-6">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3">
<div className={`flex items-center justify-center w-8 h-8 rounded-full ${youth.team === 'PF' ? 'bg-gold' : 'bg-seafoam'} shrink-0 shadow-sm`}>
<Image
src={youth.team === 'PF' ? falconIcon : porpoiseIcon}
alt={youth.team === 'PF' ? 'Pilgrimsfalk' : 'Tumlare'}
width={18}
height={18}
/>
</div>
<span className="font-bold text-ebony text-xl">{youth.name}</span>
</div>
<button
onClick={() => setExpandedYouthId(isExpanded ? null : youth.id)}
className="flex items-center text-xs font-bold text-slate-teal hover:text-ebony transition-colors w-fit bg-slate-teal/5 px-2.5 py-1.5 rounded mt-1"
>
{isExpanded ? <ChevronUp size={14} className="mr-1" /> : <ChevronDown size={14} className="mr-1" />}
{isExpanded ? 'Dölj detaljer' : 'Visa detaljer'}
</button>
</div>
<div className="flex items-center justify-end gap-5 w-full md:w-auto">
{warningStatus === 'red' && <AlertTriangle className="text-goldenrod shrink-0" size={32} />}
<div className="text-right min-w-30 md:min-w-37.5">
<div className={`text-3xl md:text-4xl font-black tracking-tight ${warningStatus === 'red' ? 'text-goldenrod' : (warningStatus === 'yellow' ? 'text-goldenrod/80' : 'text-slate-teal')}`}>
{formatHours(total)}<span className="text-lg font-bold text-ebony/60"> / {POT_HOUR_LIMIT}t</span>
</div>
<p className="text-[11px] font-bold text-ebony/60 uppercase tracking-widest mt-1 pr-1">Viktade timmar</p>
</div>
</div>
</div>
{isExpanded && (
<div className="bg-slate-teal/5 border-t border-slate-teal/10 p-5 md:p-6">
<h4 className="text-sm font-black text-ebony uppercase tracking-widest mb-4 flex items-center">
<Clock size={16} className="mr-2 text-slate-teal" /> Arbetspass & Frånvaro
</h4>
{timeline.length === 0 ? (
<p className="text-sm font-bold text-slate-teal/60 italic">Ingen närvaro loggad ännu.</p>
) : (
<div className="space-y-2">
{timeline.map((entry, idx) => {
const isWknd = isWeekend(entry.date);
// Grab schedule for this exact day to check for 'Extra' shifts accurately!
const dayNameStr = new Date(entry.date + 'T12:00:00').toLocaleDateString('sv-SE', { weekday: 'long' }).toLowerCase();
const daySchedule = scheduleData.find(d => d.day.toLowerCase() === dayNameStr);
let isExtra = false;
if (!isWknd && daySchedule) {
const expectedShiftId = getTeamShiftInfo(daySchedule, youth.team).shiftId;
isExtra = entry.shiftId !== expectedShiftId;
}
// Correct display logic: "Hela dagen" for weekends
const shiftLabel = isWknd ? 'Hela dagen' : (entry.shiftId === 'MORNING' ? 'Morgon' : 'Eftermiddag');
return (
<div key={idx} className="flex flex-col sm:flex-row sm:justify-between sm:items-center bg-white border border-slate-teal/10 p-3 rounded-lg text-sm">
<div className="flex items-center flex-wrap gap-2 mb-2 sm:mb-0">
<span className="font-bold text-ebony min-w-25">{entry.date}</span>
<span className="text-xs font-bold text-slate-teal bg-slate-teal/10 px-2 py-0.5 rounded">
{shiftLabel}
</span>
{isExtra && (
<span className="text-[10px] font-bold text-slate-teal bg-slate-teal/10 px-1.5 py-0.5 rounded uppercase tracking-wider">
Extra pass
</span>
)}
{isWknd && <span className="text-xs font-bold text-goldenrod bg-goldenrod/10 px-2 py-0.5 rounded border border-goldenrod/20">Helg</span>}
</div>
<div className="flex items-center gap-4">
{entry.status === 'Pending' ? (
<span className="font-bold text-goldenrod bg-goldenrod/10 px-2 py-0.5 rounded">Väntar registrering</span>
) : entry.status === 'Absent' ? (
<span className="font-bold text-goldenrod bg-goldenrod/10 px-2 py-0.5 rounded">{entry.note || 'Frånvarande'}</span>
) : (
<span className="font-bold text-moss bg-moss/10 px-2 py-0.5 rounded">{entry.status === 'Late' ? 'Manuell tid' : 'Närvarande'}</span>
)}
<div className="text-right min-w-37.5">
<span className="font-bold text-ebony">{formatTimeHHMM(entry.hoursWorked)} arbetat</span>
<span className="text-slate-teal font-black ml-2"> +{entry.weightedHours.toFixed(1)} pott</span>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
)}
</div>
);
})}
{activePeriod.youthList.length === 0 && (
<p className="text-sm font-bold text-slate-teal/60 italic text-center">Inga ungdomar i denna period.</p>
)}
</div>
</div>
</div>
);
};
+184
View File
@@ -0,0 +1,184 @@
// app/admin/SetupTab.tsx
"use client";
import { CalendarRange, Trash2, UserPlus } from 'lucide-react';
import Image from 'next/image';
import React, { useState } from 'react';
import falconIcon from '../assets/falcon.svg';
import porpoiseIcon from '../assets/porpoise.svg';
import { type Period, type Youth } from './adminTypes';
interface Props {
periods: Period[];
createPeriod: (name: string, start: string) => void;
deletePeriod: (id: string) => void;
bulkAddYouth: (periodId: string, text: string, team: 'PF' | 'TU') => void;
removeYouth: (periodId: string, youthId: string) => void;
isOffline: boolean;
}
export const SetupTab: React.FC<Props> = ({ periods, createPeriod, deletePeriod, bulkAddYouth, removeYouth, isOffline }) => {
const [bulkText, setBulkText] = useState('');
const [bulkTeam, setBulkTeam] = useState<'PF' | 'TU'>('PF');
const [expandedPeriod, setExpandedPeriod] = useState<string | null>(null);
const handleCreate = () => {
if (isOffline) {
alert("Du måste vara ansluten till internet för att skapa en ny period.");
return;
}
const name = (document.getElementById('periodName') as HTMLInputElement).value;
const start = (document.getElementById('periodStart') as HTMLInputElement).value;
if (name && start) createPeriod(name, start);
};
const handleDeletePeriod = (id: string) => {
if (isOffline) {
alert("Åtgärd nekad: Du måste vara ansluten till internet för att ta bort en period.");
return;
}
if (window.confirm('Är du säker på att du vill ta bort hela perioden? All närvarodata kopplad till perioden kommer försvinna!')) {
deletePeriod(id);
}
};
const handleRemoveYouth = (periodId: string, youthId: string, youthName: string) => {
if (isOffline) {
alert("Åtgärd nekad: Du måste vara ansluten till internet för att ta bort en ungdom.");
return;
}
if (window.confirm(`Är du säker på att du vill ta bort ${youthName} från perioden?`)) {
removeYouth(periodId, youthId);
}
};
const handleBulkAdd = () => {
if (isOffline) {
alert("Åtgärd nekad: Du måste vara ansluten till internet för att importera ungdomar.");
return;
}
if (expandedPeriod) {
const processedText = bulkText.split('\n').map(line => {
const parts = line.split(/[,|-]/).map(p => p.trim());
if (parts.length > 1) {
const t = parts[1].toUpperCase();
if (t === 'P' || t === 'PILGRIMSFALK' || t === 'PILGRIMSFALKARNA') parts[1] = 'PF';
if (t === 'T' || t === 'TUMLARE' || t === 'TUMLARNA') parts[1] = 'TU';
return parts.join(', ');
}
return line;
}).join('\n');
bulkAddYouth(expandedPeriod, processedText, bulkTeam);
setBulkText('');
}
};
// Helper to render a group of youth
const renderYouthGroup = (youthList: Youth[], periodId: string) => {
if (youthList.length === 0) return null;
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-6">
{youthList.map(youth => (
<div key={youth.id} className="flex justify-between items-center bg-white border border-slate-teal/10 p-3 rounded-lg">
<div className="flex items-center gap-3">
<div className={`flex items-center justify-center w-8 h-8 rounded-full ${youth.team === 'PF' ? 'bg-gold' : 'bg-seafoam'} shrink-0 shadow-sm`}>
<Image
src={youth.team === 'PF' ? falconIcon : porpoiseIcon}
alt={youth.team === 'PF' ? 'Pilgrimsfalk' : 'Tumlare'}
width={18}
height={18}
/>
</div>
<span className="font-bold text-ebony text-lg">{youth.name}</span>
</div>
<button
onClick={() => handleRemoveYouth(periodId, youth.id, youth.name)}
className={`transition-colors ${isOffline ? 'text-goldenrod/40 cursor-not-allowed' : 'text-goldenrod hover:text-red-500'}`}
title={isOffline ? "Kräver internet" : "Ta bort ungdom"}
>
<Trash2 size={16} />
</button>
</div>
))}
</div>
);
};
return (
<div className="space-y-6 animate-fade-in">
{/* Create New Period */}
<div className="bg-eggshell border-2 border-slate-teal/20 p-6 rounded-2xl shadow-sm">
<h2 className="text-xl font-black text-ebony uppercase tracking-widest flex items-center mb-6">
<CalendarRange className="mr-3 text-slate-teal" size={24} />
Skapa Ny Period
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<input type="text" id="periodName" placeholder="T.ex. Period 1" className="w-full bg-white border border-slate-teal/20 p-3 rounded-lg font-bold" disabled={isOffline} />
<input type="date" id="periodStart" className="w-full bg-white border border-slate-teal/20 p-3 rounded-lg font-bold" disabled={isOffline} />
<button onClick={handleCreate} disabled={isOffline} className="bg-slate-teal text-eggshell font-black uppercase px-6 py-3 rounded-lg hover:bg-ebony transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
Starta
</button>
</div>
</div>
{/* List Existing Periods */}
{periods.map(period => {
// Sort by name alphabetically
const pfYouth = period.youthList.filter(y => y.team === 'PF').sort((a, b) => a.name.localeCompare(b.name));
const tuYouth = period.youthList.filter(y => y.team === 'TU').sort((a, b) => a.name.localeCompare(b.name));
return (
<div key={period.id} className="bg-eggshell border-2 border-slate-teal/20 rounded-2xl shadow-sm overflow-hidden">
<div className="p-6 flex justify-between items-center cursor-pointer hover:bg-slate-teal/5" onClick={() => setExpandedPeriod(expandedPeriod === period.id ? null : period.id)}>
<div>
<h3 className="text-lg font-black text-ebony uppercase">{period.name}</h3>
<p className="text-sm font-bold text-moss">{period.startDate} till {period.endDate} {period.youthList.length} ungdomar</p>
</div>
<button
onClick={(e) => { e.stopPropagation(); handleDeletePeriod(period.id); }}
className={`p-2 transition-colors ${isOffline ? 'text-goldenrod/40 cursor-not-allowed' : 'text-goldenrod/80 hover:text-goldenrod'}`}
title={isOffline ? "Kräver internet" : "Ta bort period"}
>
<Trash2 size={20} />
</button>
</div>
{/* Expandable Youth Management */}
{expandedPeriod === period.id && (
<div className="p-6 border-t border-slate-teal/10 bg-slate-teal/5">
<h4 className="font-black text-ebony mb-4 flex items-center"><UserPlus size={18} className="mr-2 text-slate-teal" /> Bulk-lägg till ungdomar</h4>
<div className="flex flex-col md:flex-row gap-4 mb-6">
<textarea
value={bulkText}
onChange={(e) => setBulkText(e.target.value)}
placeholder="Klistra in namn, ett per rad.&#10;T.ex. 'Anna' eller 'Anna, P'"
className="w-full h-24 bg-white border border-slate-teal/20 p-3 rounded-lg font-bold resize-none"
disabled={isOffline}
/>
<div className="flex flex-col gap-2 shrink-0">
<select value={bulkTeam} onChange={(e) => setBulkTeam(e.target.value as 'PF' | 'TU')} className="bg-white border border-slate-teal/20 p-3 rounded-lg font-bold" disabled={isOffline}>
<option value="PF">Standard: Pilgrimsfalk (P)</option>
<option value="TU">Standard: Tumlare (T)</option>
</select>
<button onClick={handleBulkAdd} disabled={isOffline} className="bg-slate-teal text-eggshell font-black uppercase px-6 py-3 rounded-lg hover:bg-ebony transition-colors h-full disabled:opacity-50 disabled:cursor-not-allowed">
Importera
</button>
</div>
</div>
{pfYouth.length > 0 && <h4 className="text-xs font-black text-moss uppercase tracking-widest mb-3">Pilgrimsfalkarna</h4>}
{renderYouthGroup(pfYouth, period.id)}
{tuYouth.length > 0 && <h4 className="text-xs font-black text-moss uppercase tracking-widest mb-3">Tumlarna</h4>}
{renderYouthGroup(tuYouth, period.id)}
</div>
)}
</div>
);
})}
</div>
);
};
+79
View File
@@ -0,0 +1,79 @@
// app/admin/adminTypes.ts
export type Role = 'Admin' | 'Staff' | 'Viewer';
export interface AppUser {
id: string;
name: string;
role: Role;
}
export interface Youth {
id: string;
name: string;
team: 'PF' | 'TU';
}
export interface Period {
id: string;
name: string;
startDate: string;
endDate: string;
youthList: Youth[];
}
export interface AttendanceEntry {
hoursWorked: number;
weightedHours: number;
status: 'Absent' | 'Late' | 'Present' | 'Pending';
note?: string;
date: string;
youthId: string;
shiftId: 'MORNING' | 'AFTERNOON';
}
export type AttendanceDataMap = Record<string, AttendanceEntry>;
export const toIsoDate = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
export const isWeekend = (dateStr: string): boolean => {
const day = new Date(dateStr + 'T12:00:00').getDay();
return day === 0 || day === 6;
};
export const calculateShiftDuration = (timeStr?: string): number => {
if (!timeStr || timeStr === 'Ledig') return 0;
const matches = timeStr.match(/(\d{1,2}):(\d{2})/g);
if (!matches || matches.length < 2) return 0;
const parse = (t: string) => {
const [h, m] = t.split(':').map(Number);
return h + (m / 60);
};
return parse(matches[1]) - parse(matches[0]);
};
export const getAttendanceKey = (date: string, youthId: string, shiftId: string) => `${date}|${youthId}|${shiftId}`;
export const formatTimeHHMM = (decimalHours: number): string => {
if (decimalHours <= 0) return "0:00";
const hrs = Math.floor(decimalHours);
const mins = Math.round((decimalHours - hrs) * 60);
return `${hrs}:${mins.toString().padStart(2, '0')}`;
};
export const parseTimeInput = (input: string): number => {
if (!input) return 0;
const cleanInput = input.trim().replace(',', '.');
if (cleanInput.includes(':')) {
const [h, m] = cleanInput.split(':').map(Number);
return (h || 0) + ((m || 0) / 60);
}
return parseFloat(cleanInput) || 0;
};
+191
View File
@@ -0,0 +1,191 @@
// app/admin/page.tsx
"use client";
import React, { useState, useEffect } from 'react';
import { Lock, Unlock, CalendarRange, ClipboardCheck, Users as UsersIcon, Loader2, WifiOff } from 'lucide-react';
import { AppUser } from './adminTypes';
import { useAdminState } from './useAdminState';
import { SetupTab } from './SetupTab';
import { AttendanceTab } from './AttendanceTab';
import { ReportTab } from './ReportTab';
import { verifyLogin } from '../actions/admin';
export default function Admin() {
const [currentUser, setCurrentUser] = useState<AppUser | null>(null);
const [isCheckingSession, setIsCheckingSession] = useState(true); // NEW: Prevents login screen flashing
const [username, setUsername] = useState('');
const [pin, setPin] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [loginError, setLoginError] = useState(false);
const [activeTab, setActiveTab] = useState<'setup' | 'today' | 'report'>('report');
const adminState = useAdminState();
// NEW: Check for a saved session when the app loads
useEffect(() => {
const savedSession = localStorage.getItem('kullaberg_admin_session');
if (savedSession) {
const user = JSON.parse(savedSession) as AppUser;
setCurrentUser(user);
// Set their correct tab based on role
if (user.role === 'Viewer') setActiveTab('report');
else if (user.role === 'Staff') setActiveTab('today');
else setActiveTab('today'); // Admins go to today by default if logged in via session
}
setIsCheckingSession(false);
}, []);
const handleLogin = async (e: React.SyntheticEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setLoginError(false);
const result = await verifyLogin(username, pin);
if (result.success && result.user) {
const user = result.user as AppUser;
setCurrentUser(user);
// NEW: Save the session to the browser
localStorage.setItem('kullaberg_admin_session', JSON.stringify(user));
if (user.role === 'Viewer') setActiveTab('report');
else if (user.role === 'Staff') setActiveTab('today');
else setActiveTab(adminState.periods.length > 0 ? 'today' : 'setup');
} else {
setPin('');
setLoginError(true);
}
setIsLoading(false);
};
const handleLogout = () => {
setCurrentUser(null);
setUsername('');
setPin('');
localStorage.removeItem('kullaberg_admin_session');
};
if (isCheckingSession) {
return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-slate-teal" size={40} /></div>;
}
if (!currentUser) {
return (
<div className="flex flex-col items-center justify-center py-20 animate-fade-in px-4">
<div className="bg-eggshell border-2 border-slate-teal/20 p-8 rounded-2xl shadow-lg w-full max-w-sm text-center">
<div className="bg-slate-teal/10 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
<Lock size={32} className="text-slate-teal" />
</div>
<h2 className="text-2xl font-black text-ebony uppercase mb-6">Admin Login</h2>
<form onSubmit={handleLogin} className="space-y-4 text-left">
{/* New Text Input for Username */}
<input
type="text"
value={username}
onChange={(e) => { setUsername(e.target.value); setLoginError(false); }}
placeholder="Användarnamn"
className="w-full bg-eggshell/50 border-2 border-slate-teal/30 text-ebony font-bold p-3 rounded-xl focus:outline-none focus:border-slate-teal"
/>
<input
type="password"
value={pin}
onChange={(e) => { setPin(e.target.value); setLoginError(false); }}
placeholder="•••••"
className={`w-full bg-eggshell/50 border-2 text-center text-2xl tracking-[0.5em] text-ebony font-mono p-3 rounded-xl focus:outline-none ${loginError ? 'border-emergency/50 bg-emergency/10' : 'border-slate-teal/30 focus:border-slate-teal'}`}
/>
{loginError && <p className="text-emergency text-xs font-bold text-center mt-1">Fel namn eller lösenord.</p>}
<button type="submit" disabled={isLoading || !pin || !username} className="w-full bg-slate-teal text-eggshell font-black uppercase py-3 rounded-xl hover:bg-ebony transition-colors disabled:opacity-50">
{isLoading ? <Loader2 className="animate-spin mx-auto" /> : 'Logga in'}
</button>
</form>
</div>
</div>
);
}
// Role-based tabs logic that was missing!
const availableTabs = [];
if (currentUser.role === 'Admin') {
availableTabs.push({ id: 'setup', icon: CalendarRange, label: 'Perioder' });
}
if (currentUser.role === 'Admin' || currentUser.role === 'Staff') {
availableTabs.push({ id: 'today', icon: ClipboardCheck, label: 'Närvaro' });
}
availableTabs.push({ id: 'report', icon: UsersIcon, label: 'Rapport' });
return (
<div className="space-y-8 animate-fade-in w-full">
<div className="flex justify-between items-end border-b-2 border-slate-teal/20 pb-4">
<div className="flex flex-col">
<h1 className="text-3xl font-black text-slate-teal uppercase flex items-center">
<Unlock className="mr-3 text-seafoam" size={28} /> Admin
</h1>
{/* Display Offline Badge if no internet! */}
{adminState.isOffline && (
<div className="flex items-center mt-2 text-xs font-bold bg-goldenrod/20 text-goldenrod px-3 py-1 rounded-full w-fit">
<WifiOff size={14} className="mr-2" /> Offline Läge (Synkar när nätverk finns)
</div>
)}
</div>
<div className="text-right">
<p className="text-xs font-bold text-moss mb-1">Inloggad: {currentUser.name}</p>
<button onClick={handleLogout} className="text-sm font-bold text-ebony/60 hover:text-goldenrod">Logga ut</button>
</div>
</div>
{adminState.isLoadingData && adminState.periods.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-slate-teal">
<Loader2 size={48} className="animate-spin mb-4" />
<p className="font-bold animate-pulse">Hämtar data...</p>
</div>
) : (
<>
{availableTabs.length > 1 && (
<div className="border-b border-slate-teal/20 flex gap-2 overflow-x-auto scrollbar-hide">
{availableTabs.map(tab => (
<button key={tab.id} onClick={() => setActiveTab(tab.id as any)} className={`flex items-center whitespace-nowrap px-4 py-2.5 rounded-t-lg font-bold text-sm transition-colors border-b-2 ${activeTab === tab.id ? 'bg-slate-teal text-eggshell border-slate-teal' : 'bg-eggshell text-slate-teal border-transparent hover:bg-slate-teal/5'}`}>
<tab.icon size={16} className="mr-2 hidden md:block shrink-0" /> {tab.label}
</button>
))}
</div>
)}
{activeTab === 'setup' && currentUser.role === 'Admin' && <SetupTab {...adminState} />}
{activeTab === 'today' && (currentUser.role === 'Admin' || currentUser.role === 'Staff') && (
<AttendanceTab
periods={adminState.periods}
attendance={adminState.attendance}
setManualAttendance={adminState.setManualAttendance}
bulkSetManualAttendance={adminState.bulkSetManualAttendance}
addPendingAttendance={adminState.addPendingAttendance}
removeAttendanceEntry={adminState.removeAttendanceEntry}
activePeriodId={adminState.activePeriodId}
setActivePeriodId={adminState.setActivePeriodId}
/>
)}
{activeTab === 'report' && (
<ReportTab
periods={adminState.periods}
attendance={adminState.attendance}
activePeriodId={adminState.activePeriodId}
setActivePeriodId={adminState.setActivePeriodId}
currentUserRole={currentUser.role}
/>
)}
</>
)}
</div>
);
}
+257
View File
@@ -0,0 +1,257 @@
// app/admin/useAdminState.ts
"use client";
import { useState, useEffect, useCallback } from 'react';
import localforage from 'localforage';
import { Period, AttendanceDataMap, Youth, toIsoDate, isWeekend, getAttendanceKey } from './adminTypes';
import { getAdminData, createPeriodDb, deletePeriodDb, bulkAddYouthDb, removeYouthDb, setAttendanceDb, removeAttendanceDb, syncOfflineQueueDb, bulkSetAttendanceDb } from '../actions/admin';
export const useAdminState = () => {
const [periods, setPeriods] = useState<Period[]>([]);
const [attendance, setAttendance] = useState<AttendanceDataMap>({});
const [activePeriodId, setActivePeriodId] = useState<string>('');
const [isLoadingData, setIsLoadingData] = useState<boolean>(true);
const [isOffline, setIsOffline] = useState<boolean>(false);
// --- 1. DATA MAPPING ---
const mapDbDataToUI = (dbPeriods: any[]) => {
const loadedPeriods: Period[] = [];
const loadedAttendance: AttendanceDataMap = {};
for (const dbPeriod of dbPeriods) {
const youthList: Youth[] = dbPeriod.youths.map((y: any) => ({ id: y.id, name: y.name, team: y.team as 'PF' | 'TU' }));
loadedPeriods.push({ id: dbPeriod.id, name: dbPeriod.name, startDate: dbPeriod.startDate, endDate: dbPeriod.endDate, youthList });
for (const youth of dbPeriod.youths) {
for (const att of youth.attendance) {
const key = getAttendanceKey(att.date, att.youthId, att.shiftId);
loadedAttendance[key] = {
date: att.date, youthId: att.youthId, shiftId: att.shiftId as 'MORNING' | 'AFTERNOON',
hoursWorked: att.hoursWorked, weightedHours: att.weightedHours, status: att.status as any, note: att.note || ''
};
}
}
}
return { loadedPeriods, loadedAttendance };
};
// --- 2. OFFLINE QUEUE FLUSHER ---
const flushOfflineQueue = useCallback(async () => {
try {
const queue = await localforage.getItem<any[]>('sync-queue') || [];
if (queue.length > 0) {
console.log(`Flushing ${queue.length} items from offline queue...`, queue);
await syncOfflineQueueDb(queue);
// ONLY clear the queue if the server action succeeds without throwing an error
await localforage.setItem('sync-queue', []);
return true; // Indicates we successfully synced something
}
} catch (error) {
console.error("Server sync failed, keeping items in offline queue for later.", error);
}
return false;
}, []);
// --- 3. MAIN DATA FETCHING ---
const refreshData = useCallback(async (isInitialLoad = false) => {
try {
// First, check if we are online and have a backlog to sync before pulling fresh data
if (navigator.onLine) {
await flushOfflineQueue();
}
const dbPeriods = await getAdminData();
await localforage.setItem('cachedAdminData', dbPeriods);
setIsOffline(false);
const { loadedPeriods, loadedAttendance } = mapDbDataToUI(dbPeriods);
setPeriods(loadedPeriods);
setAttendance(loadedAttendance);
// Auto-select the active period if it's the initial load
if (isInitialLoad && loadedPeriods.length > 0) {
const today = toIsoDate(new Date());
const activePeriods = loadedPeriods.filter(p => today >= p.startDate && today <= p.endDate);
if (activePeriods.length > 0) {
setActivePeriodId(activePeriods[0].id);
} else {
setActivePeriodId(loadedPeriods[0].id);
}
}
} catch (error) {
console.error("Offline or Server Error! Loading from cache...");
setIsOffline(true);
const cachedData = await localforage.getItem<any[]>('cachedAdminData');
if (cachedData) {
const { loadedPeriods, loadedAttendance } = mapDbDataToUI(cachedData);
setPeriods(loadedPeriods);
setAttendance(loadedAttendance);
if (isInitialLoad && loadedPeriods.length > 0) setActivePeriodId(loadedPeriods[0].id);
}
} finally {
setIsLoadingData(false);
}
}, [flushOfflineQueue]);
// Initial load
useEffect(() => {
refreshData(true);
}, [refreshData]);
// --- 4. OFFLINE/ONLINE EVENT LISTENERS ---
useEffect(() => {
const handleOnline = async () => {
setIsOffline(false);
const didSync = await flushOfflineQueue();
if (didSync) {
// If we successfully pushed old data, pull the fresh DB state so the UI updates
refreshData();
}
};
window.addEventListener('online', handleOnline);
window.addEventListener('offline', () => setIsOffline(true));
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', () => setIsOffline(true));
};
}, [flushOfflineQueue, refreshData]);
// Helper to add actions to local queue
const addToOfflineQueue = async (action: any) => {
const queue = await localforage.getItem<any[]>('sync-queue') || [];
queue.push(action);
await localforage.setItem('sync-queue', queue);
};
// --- 5. DATABASE WRITE ACTIONS ---
const createPeriod = async (name: string, startDateStr: string) => {
setIsLoadingData(true);
const start = new Date(startDateStr + 'T12:00:00');
const end = new Date(start); end.setDate(start.getDate() + 20);
const newPeriod = await createPeriodDb(name, toIsoDate(start), toIsoDate(end));
await refreshData();
setActivePeriodId(newPeriod.id);
};
const deletePeriod = async (periodId: string) => {
setPeriods(periods.filter(p => p.id !== periodId));
await deletePeriodDb(periodId);
await refreshData();
};
const bulkAddYouth = async (periodId: string, text: string, defaultTeam: 'PF' | 'TU') => {
setIsLoadingData(true);
const newYouthData = text.split('\n').map(l => l.trim()).filter(l => l.length > 0).map((line) => {
const parts = line.split(/[,|-]/).map(p => p.trim());
return { name: parts[0], team: (parts[1]?.toUpperCase() === 'PF' || parts[1]?.toUpperCase() === 'TU') ? parts[1].toUpperCase() : defaultTeam };
});
await bulkAddYouthDb(periodId, newYouthData);
await refreshData();
};
const removeYouth = async (periodId: string, youthId: string) => {
setPeriods(periods.map(p => p.id === periodId ? { ...p, youthList: p.youthList.filter(y => y.id !== youthId) } : p));
await removeYouthDb(youthId);
await refreshData();
};
// --- 6. ATTENDANCE ACTIONS (WITH OFFLINE QUEUE SUPPORT) ---
const setManualAttendance = async (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON', status: 'Absent' | 'Late' | 'Present', hoursManual: number = 0, note?: string) => {
const weight = isWeekend(date) ? 1.5 : 1.0;
let weightedHours = status === 'Late' || status === 'Present' ? hoursManual * weight : 0;
let hoursWorked = status === 'Late' || status === 'Present' ? hoursManual : 0;
const noteStr = note || '';
// Optimistic UI update
setAttendance(prev => ({
...prev, [getAttendanceKey(date, youthId, shiftId)]: { date, youthId, shiftId, hoursWorked, weightedHours, status, note: noteStr }
}));
if (!navigator.onLine) {
await addToOfflineQueue({ type: 'SET_ATTENDANCE', payload: { date, youthId, shiftId, hoursWorked, weightedHours, status, note: noteStr } });
} else {
await setAttendanceDb(date, youthId, shiftId, hoursWorked, weightedHours, status, noteStr).catch(() => {
// If it fails (e.g., server timeout despite being "online"), put it in the queue
addToOfflineQueue({ type: 'SET_ATTENDANCE', payload: { date, youthId, shiftId, hoursWorked, weightedHours, status, note: noteStr } });
});
}
};
const bulkSetManualAttendance = async (records: { date: string; youthId: string; shiftId: 'MORNING' | 'AFTERNOON'; hoursWorked: number; weightedHours: number; status: 'Absent' | 'Late' | 'Present'; note: string }[]) => {
// 1. Optimistic UI update (Instant feedback for all youths)
setAttendance(prev => {
const next = { ...prev };
records.forEach(record => {
next[getAttendanceKey(record.date, record.youthId, record.shiftId)] = record;
});
return next;
});
// 2. Offline Queue Handling
if (!navigator.onLine) {
const queue = await localforage.getItem<any[]>('sync-queue') || [];
// We can safely push them as individual actions to the queue to re-use the existing sync logic
records.forEach(record => {
queue.push({ type: 'SET_ATTENDANCE', payload: record });
});
await localforage.setItem('sync-queue', queue);
} else {
// 3. Single Network Request
await bulkSetAttendanceDb(records).catch(async () => {
// Fallback to queue if the server times out
const queue = await localforage.getItem<any[]>('sync-queue') || [];
records.forEach(record => {
queue.push({ type: 'SET_ATTENDANCE', payload: record });
});
await localforage.setItem('sync-queue', queue);
});
}
};
const addPendingAttendance = async (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => {
setAttendance(prev => ({ ...prev, [getAttendanceKey(date, youthId, shiftId)]: { date, youthId, shiftId, hoursWorked: 0, weightedHours: 0, status: 'Pending', note: '' } }));
if (!navigator.onLine) {
await addToOfflineQueue({ type: 'SET_ATTENDANCE', payload: { date, youthId, shiftId, hoursWorked: 0, weightedHours: 0, status: 'Pending', note: '' } });
} else {
await setAttendanceDb(date, youthId, shiftId, 0, 0, 'Pending', '').catch(() => {
addToOfflineQueue({ type: 'SET_ATTENDANCE', payload: { date, youthId, shiftId, hoursWorked: 0, weightedHours: 0, status: 'Pending', note: '' } });
});
}
};
const removeAttendanceEntry = async (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => {
setAttendance(prev => { const next = { ...prev }; delete next[getAttendanceKey(date, youthId, shiftId)]; return next; });
if (!navigator.onLine) {
await addToOfflineQueue({ type: 'REMOVE_ATTENDANCE', payload: { date, youthId, shiftId } });
} else {
await removeAttendanceDb(date, youthId, shiftId).catch(() => {
addToOfflineQueue({ type: 'REMOVE_ATTENDANCE', payload: { date, youthId, shiftId } });
});
}
};
return {
periods,
attendance,
activePeriodId,
setActivePeriodId,
isLoadingData,
isOffline,
createPeriod,
deletePeriod,
bulkAddYouth,
removeYouth,
setManualAttendance,
bulkSetManualAttendance,
addPendingAttendance,
removeAttendanceEntry
};
};
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 64 64">
<!-- Generator: Adobe Illustrator 30.1.0, SVG Export Plug-In . SVG Version: 2.1.1 Build 136) -->
<defs>
<style>
.st0 {
fill: #fff;
}
</style>
</defs>
<path class="st0" d="M51.7,9.8c.5,0,.9.2,1.2.6.2.3.2.7.3,1.1.2.8.6,1.5,1.2,2,1,.9,2.3,1.2,3.5.8.6-.3,1.1-.7,1.4-1.3,0-.3-.2-.6-.2-1,0-.5.2-1,.6-1.3.5-.6,1.4-.8,1.8-1.5.4-.9.4-1.9-.1-2.7-.7-1-1.6-1.7-2.7-2.2-1.6-.8-3.3-1.1-5.1-1.1-1.6,0-3.1.6-4.3,1.5-1.5,1.1-2.7,2.7-3.4,4.4-.6,1.5-1.3,2.9-2.2,4.2-1.4,1.6-3,3-4.9,4.1-2.7,1.9-5.1,4.1-7.3,6.6-2.4,2.8-4.7,5.8-6.9,8.7-4.1,5.3-8.6,10.4-13.4,15.1-2.2,2.2-4.5,4.3-6.9,6.3-.8.6-1.6,1.3-2.2,2.1-.3.5-.6,1-.8,1.5.7,0,1.5,0,2.2-.2,1.5-.4,3-1.1,4.3-2,1.7-1,3.3-1.9,5-2.9,3-1.8,6-3.7,9-5.5.7-.4,1.5-.7,2.3-.9,2.5-.8,5-1.6,7.5-2.4,1.6-.5,3.4-.4,4.9.3,1.2.7,2.4,1.6,3.5,2.5,1.4,1.1,3,2,4.5,2.9,1.1.6,1.9,1.5,2.4,2.6-2,.3-3.8,1-5.6,1.9-1.4.7-2.6,1.5-3.8,2.5-1,.8-1.7,1.8-2.2,3,.6.3,1.2.7,1.8,1,.4-.4.8-.8,1.3-1.1,1.8-1.3,3.9-2.4,6-3.1,2.8-1.1,5.8-1.7,8.8-2,1,0,1.9-.4,2.6-1.1,0,0,0,0,0,0-1.3-.7-2.8-1-4.2-1,.5-.1,1-.2,1.5-.2,0-.4-.4-.7-.7-.8-.6-.2-1.2-.3-1.8-.2-.6,0-1.2-.3-1.7-.6-1.3-.8-2.5-1.8-3.7-2.8-.7-.5-1.2-1.2-1.6-2-.2-.7,0-1.5.4-2.1.5-.7,1.2-1.4,1.9-1.9,2-1.5,3.9-3.1,5.7-4.9,2.5-2.5,4.5-5.3,6.1-8.5.8-1.6,1.3-3.3,1.6-5.1.3-1.9.2-3.8-.3-5.6,0-.3-.2-.7-.3-1,0-.3,0-.6.1-.9-.5.3-1,.5-1.6.5-1-.1-2.1-.4-3-.9-1.3-.3-2.6-.4-3.9-.3-.9,0-1.8,0-2.7.2-.8.2-1.6.5-2.4.9.9-1.2,2.3-2.1,3.8-2.4.5,0,1-.2,1.5-.3.3-.1.5-.5.4-.9,0,0,0,0,0,0-.2-.4-.3-.9-.1-1.4.2-.5.6-.9,1.1-.9ZM54.7,9c0-.9.7-1.5,1.6-1.5.2,0,.3,0,.5,0,.7.2,1.1.9,1,1.6,0,.9-.8,1.5-1.7,1.4,0,0,0,0-.1,0-.8-.1-1.3-.8-1.3-1.6ZM45.4,31c1.3-1.2,2.6-2.5,3.8-3.9,1.2-1.3,2.3-2.8,3.2-4.3.7-1.1,1.3-2.4,1.5-3.8.3,0,.4.4.5.6.2.9.3,1.8,0,2.7-.4,1.7-1.1,3.2-2.1,4.6-2,2.6-4.3,4.8-6.9,6.7-3.2,2.3-6.5,4.4-10,6.2-2,1-4,2-6,2.9,5.7-3.3,11.1-7.2,15.9-11.7Z"/>
<path class="st0" d="M56.7,9.9c.4-.2.6-.7.4-1.2-.2-.5-.8-.7-1.2-.5-.5.2-.7.8-.5,1.2,0,0,0,0,0,0,.2.5.8.7,1.3.5,0,0,0,0,0,0Z"/>
<path class="st0" d="M61.7,9.6c0,.8-.6,1.6-1.4,1.9-.3,0-.6.2-.8.3.4.2.9.4,1.3.5.7.2,1.4.7,1.9,1.3.4-1.4,0-2.9-.9-3.9Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 64 64">
<!-- Generator: Adobe Illustrator 30.1.0, SVG Export Plug-In . SVG Version: 2.1.1 Build 136) -->
<defs>
<style>
.st0 {
fill: #fff;
}
</style>
</defs>
<path class="st0" d="M63.2,49.1c-.3-1.1-.9-2.1-1.8-2.9-1.3-1.1-2.8-2-4.4-2.5-.2-2.6-.7-5.1-1.5-7.6-1.4-4.2-3.7-8-6.9-11-2-1.9-4.2-3.5-6.6-4.7-.2-1.4-.2-2.8,0-4.1.2-1.2.8-2.3,1.7-3.1.8-.8,1.8-1.4,2.8-1.9.3-.1.6-.3.7-.6,0-.3-.2-.5-.4-.5-.7,0-1.4,0-2,0-2.7.4-5.3,1.3-7.7,2.8-2.1,1.3-4,2.9-5.6,4.8-3.3-.1-6.6.2-9.8,1-3.4.8-6.8,2-9.9,3.6-2.1,1.1-3.9,2.5-5.6,4.1-1.1,1.1-2.1,2.4-2.8,3.8-.3.6-.5,1.2-.5,1.8,0,.5.3.9.7,1,.4,0,.7.1,1.1.1,1.7-.1,3.3-.5,4.8-1.1.3-.1.6-.2.9-.2.2,0,.4.2.3.4,0,0,0,0,0,0,0,.2-.3.3-.4.4-1.7.7-3.5,1.1-5.3,1.1-.7,0-1.3,0-1.9-.3-.8.2-1.5.7-2,1.2-.3.3-.4.7-.2,1,.3.3.6.6,1,.7.9.4,1.7.6,2.7.8,3.2.5,6.4.6,9.6.3,1.7,0,3.4-.3,5.1-.4-.3-.5-.6-1.1-.7-1.8-.8.1-1.6.2-2.4.3-3.4.5-6.8.6-10.2.4-1.4,0-2.9-.3-4.2-.8,2.6.4,5.2.5,7.9.3,3-.1,5.9-.6,8.9-1,0-.7.3-1.3.8-1.8.8-.8,1.9-1.2,3-1.1,2.2.3,4.3,1.1,6.1,2.5,0,0,.2.1.3.1,4.5-.3,9,.7,13,2.7.9.4,1.7.9,2.5,1.5-.6-.3-1.2-.6-1.8-.8-4.1-1.7-8.4-2.6-12.9-2.5.5.5.9,1.1,1.4,1.7,0,0,0,.2.2.2,1.1,0,2.3,0,3.4.2,1.8.2,3.6.6,5.3,1.1,2.5.7,4.9,1.8,7.1,3.1,1.6,1,3,2.2,4.2,3.7-1.3,1.2-2.4,2.7-3.1,4.4-.4.9-.6,1.9-.7,2.9,0,.4.2.7.5.9.4.1.8,0,1.2-.2,1.6-.9,2.9-2.2,4.5-3.1,1-.5,2-.8,3.1-.7,1.8,0,3.5.5,5.3.7.4,0,.8,0,1.1-.3.2-.3.3-.6.2-.9ZM13.6,30.9c0-.2-.1-.3-.2-.5-.4-.3-.9-.3-1.3,0-.1.1-.2.3-.3.5-.1-.3-.1-.6,0-.8.3-.5.9-.6,1.3-.4,0,0,0,0,.1,0,.3.3.4.7.3,1.1ZM51.1,33.4c-1.5-1.9-3.2-3.6-5.1-5.1-2.3-1.7-4.9-3.1-7.6-3.9-1.4-.4-2.9-.7-4.3-.8-2.9-.2-5.8.2-8.6,1.3-1.4.5-2.8,1.2-4.3,1.8-1.5.6-3.2,1-4.9,1.2-1.5,0-3,.2-4.4.5-1.9.4-3.7,1.3-5.2,2.4.9-1.1,1.9-2,3.1-2.8,1.5-1,3.1-1.6,4.8-2,1.5-.2,3-.6,4.5-1.1,2.7-.9,5.2-2.3,8-3.1,1.7-.5,3.5-.7,5.3-.7,2.7,0,5.4.7,7.9,1.8,2.7,1.2,5.1,2.9,7.1,5,1.5,1.6,2.8,3.4,3.8,5.4.1.2.2.5.3.7-.2-.2-.3-.5-.5-.7Z"/>
<path class="st0" d="M30.9,39.2c-.6-1.1-1.4-2.2-2.3-3.1-1.7-1.7-3.9-2.7-6.2-2.9-.4,0-.8,0-1.1,0-.9.2-1.6.8-1.8,1.7,0,.7,0,1.4.4,2.1.4.9,1,1.7,1.6,2.5.7.9,1.4,1.7,2.2,2.5,1,.9,2.2,1.6,3.4,2.1,1.5.6,3,1,4.6,1.2.3,0,.7,0,1-.1.3-.1.5-.4.6-.7,0-.2,0-.5,0-.7-.6-1.5-1.3-3-2.2-4.4Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+39
View File
@@ -0,0 +1,39 @@
// app/components/Navigation.tsx
"use client";
import { Briefcase, Calendar, FileText, Home as HomeIcon, LifeBuoy, Lock, Map as MapIcon } from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
const NavItem = ({ to, icon: Icon, label, className = "" }: { to: string, icon: any, label: string, className?: string }) => {
const pathname = usePathname();
const isActive = pathname === to;
return (
<Link
href={to}
className={`flex flex-col items-center justify-center px-4 py-2 min-w-18 transition-colors border-b-[3px] ${className} ${isActive
? 'border-gold text-gold bg-ebony/30'
: 'border-transparent text-eggshell/80 hover:text-eggshell hover:bg-eggshell/5'
}`}
>
<Icon size={20} className="mb-1" strokeWidth={isActive ? 2.5 : 2} />
<span className="text-[10px] font-bold uppercase tracking-wider">{label}</span>
</Link>
);
};
export const Navigation = () => {
return (
<nav className="flex overflow-x-auto scrollbar-hide border-t border-eggshell/10">
<NavItem to="/" icon={HomeIcon} label="Hem" />
<NavItem to="/schedule" icon={Calendar} label="Schema" />
<NavItem to="/faq" icon={MapIcon} label="FAQ" />
<NavItem to="/info" icon={Briefcase} label="Info" />
<NavItem to="/documents" icon={FileText} label="Filer" />
<NavItem to="/emergency" icon={LifeBuoy} label="Nödläge" className='text-emergency' />
<NavItem to="/admin" icon={Lock} label="Admin" className="md:ml-auto" />
</nav>
);
};
+34
View File
@@ -0,0 +1,34 @@
// app/components/PhoneLinks.tsx
"use client";
import React from 'react';
interface SafePhoneLinkProps {
parts: string[];
display: string;
className?: string;
}
export const SafePhoneLink: React.FC<SafePhoneLinkProps> = ({
parts,
display,
className = "text-sm text-slate-teal font-mono font-bold bg-seafoam/20 hover:bg-seafoam/40 px-3 py-1.5 rounded-lg transition-colors cursor-pointer"
}) => {
const fullNumber = parts.join('');
const handleClick = (e: React.MouseEvent<HTMLAnchorElement> | React.TouchEvent<HTMLAnchorElement>) => {
e.currentTarget.href = `tel:${fullNumber}`;
};
return (
<a
href="#"
onMouseDown={handleClick}
onTouchStart={handleClick}
className={className}
>
{display}
</a>
);
};
+3
View File
@@ -0,0 +1,3 @@
// app/css.d.ts
declare module '*.css';
+98
View File
@@ -0,0 +1,98 @@
[
{
"category": "Alla platser",
"questions": [
{
"q": "Är parkeringen gratis?",
"a": "JA!"
},
{
"q": "Var får man ställa husbilen?",
"a": "Vid golfparkeringen längst in, Stora parkeringen längst in, Vid Mölle kapell, eller First Camp."
}
]
},
{
"category": "Stora parkeringen",
"questions": [
{
"q": "Hur långt är det till fyren?",
"a": "350m."
},
{
"q": "Finns det någon bra kort vandringsrunda?",
"a": "Facit i form av förbestämda rutter med kända distanser."
},
{
"q": "Finns det några coola grottor i närheten?",
"a": "Silvergrottan, Visitgrottan (efter 15 juli), Lahibiagrottan (störst)."
},
{
"q": "Var ligger Naturum?",
"a": "Vid fyren, 350m. Här finns info, broschyrer, personal, akvarier, fågelskådning, tumlarspaning, och Kullabergsguiderna."
},
{
"q": "Tumalarsafari/ Kullabergsguiderna?",
"a": "Skicka till Naturum, infodisk på insidan."
},
{
"q": "Var kan man tälta?",
"a": "Tältrutan eller i vindskydden, se karta."
},
{
"q": "Var får man grilla?",
"a": "Bara i fasta grillplatser! INGEN EGEN GRILL! Ingen grillning under eldningsförbud."
},
{
"q": "Var kan man bada?",
"a": "Se broschyrer om badplatser."
},
{
"q": "Vad kan man göra mer i Höganäs?",
"a": "Kolla in destinationskunskaps underlaget."
}
]
},
{
"category": "Himmelstorp",
"questions": [
{
"q": "Var ligger det här Nimis stället?",
"a": "Avråd från att gå dit. Vill de gå ändå så tänk på skor, små barn, väder, ålder = det är brant. Vi vill inte behöva ringa räddningstjänsten."
},
{
"q": "Finns det någon bra vandringsrunda?",
"a": "Facit i form av förbestämda rutter med kända distanser."
},
{
"q": "Var ligger himmelstorpsgården?",
"a": "Bara upp för backen och till vänster längs med stigen."
},
{
"q": "Var parkerar man om det är fullt?",
"a": "Nedre parkeringen eller på björkerrödsparkering, lätt guidning dit."
},
{
"q": "Vart är islandshästridningen?",
"a": "Precis ovanför backen."
}
]
},
{
"category": "Josefinelust",
"questions": [
{
"q": "Finns det någon bra vandringsrunda?",
"a": "Facit i form av förbestämda rutter med kända distanser."
},
{
"q": "Var ligger Ransvik?",
"a": "Följ vägen utå mot fyren och ta första vänster där det står skyltat mot Ransvik."
},
{
"q": "Var parkerar man om det är fullt?",
"a": "Ransviks övre parkering."
}
]
}
]
+83
View File
@@ -0,0 +1,83 @@
[
{
"day": "Måndag",
"pilgrimsfalkarna": {
"time": "08:00 - 12:00",
"title": "Pilgrimsfalkarna",
"notes": "Morgonpass"
},
"tumlarna": {
"time": "12:00 - 16:00",
"title": "Tumlarna",
"notes": "Eftermiddagspass"
}
},
{
"day": "Tisdag",
"pilgrimsfalkarna": {
"time": "08:00 - 12:00",
"title": "Pilgrimsfalkarna",
"notes": "Morgonpass"
},
"tumlarna": {
"time": "12:00 - 16:00",
"title": "Tumlarna",
"notes": "Eftermiddagspass"
}
},
{
"day": "Onsdag",
"pilgrimsfalkarna": {
"time": "12:00 - 16:00",
"title": "Pilgrimsfalkarna",
"notes": "Eftermiddagspass"
},
"tumlarna": {
"time": "08:00 - 12:00",
"title": "Tumlarna",
"notes": "Morgonpass"
}
},
{
"day": "Torsdag",
"pilgrimsfalkarna": {
"time": "12:00 - 16:00",
"title": "Pilgrimsfalkarna",
"notes": "Eftermiddagspass"
},
"tumlarna": {
"time": "08:00 - 12:00",
"title": "Tumlarna",
"notes": "Morgonpass"
}
},
{
"day": "Fredag",
"pilgrimsfalkarna": {
"time": "08:00 - 12:00",
"title": "Pilgrimsfalkarna",
"notes": "Morgonpass"
},
"tumlarna": {
"time": "12:00 - 16:00",
"title": "Tumlarna",
"notes": "Eftermiddagspass"
}
},
{
"day": "Lördag",
"tumlarna": {
"time": "08:00 - 15:10",
"title": "Tumlarna",
"notes": "Heldagspass"
}
},
{
"day": "Söndag",
"pilgrimsfalkarna": {
"time": "08:00 - 15:10",
"title": "Pilgrimsfalkarna",
"notes": "Heldagspass"
}
}
]
+74
View File
@@ -0,0 +1,74 @@
// app/documents/page.tsx
import { BookCopy, Download, ExternalLink, Folder, Map, MapPinned, MapPlus, WavesLadder } from 'lucide-react';
export default function Documents() {
const docs = [
{ title: 'Turistkarta', icon: <Map size={24} />, file: '/files/Turistkarta - Kullaberg.pdf', size: '2.51 MB' },
{ title: 'Orienteringskarta', icon: <MapPlus size={24} />, file: '/files/Orienteringskarta - Kullaberg.pdf', size: '7.23 MB' },
{ title: 'Badplatser att besöka', icon: <WavesLadder size={24} />, file: '/files/Badplatser på Kullaberg.pdf', size: '7.07 MB' },
{ title: 'Vandringsrutter', icon: <MapPinned size={24} />, file: '/files/Vandringsrutter.pdf', size: '4.42 MB' },
{ title: 'Underlag för guidediplomering', icon: <BookCopy size={24} />, file: '/files/Underlag för guidediplomering.pptx', size: '3.83 MB' },
{ title: 'Destinationskunskap Kullahalvön', icon: <BookCopy size={24} />, file: '/files/Destinationskunskap 2026.pptx', size: '24.2 MB' }
];
const links = [
{ title: 'Kullabergs Naturreservat', url: 'https://www.kullabergsnatur.se/' },
{ title: 'Vandra på Kullahalvön', url: 'https://www.kullahalvon.com/upptacka--uppleva/friluftsliv--natur/vandra-pa-kullahalvon.html' },
{ title: 'Naturkartan', url: 'https://www.naturkartan.se/en/explore' },
{ title: 'Skåneleden', url: 'https://www.skaneleden.se/en' },
{ title: 'Väderprognos Mölle', url: 'https://www.smhi.se/vader/prognoser-och-varningar/vaderprognos/q/H%C3%B6gan%C3%A4s/M%C3%B6lle/2691501' }
];
return (
<div className="space-y-8 animate-fade-in w-full">
<div>
<h1 className="text-3xl font-black text-slate-teal tracking-tight flex items-center">
<Folder className="mr-3 text-seafoam" size={32} />
Info & Dokument
</h1>
<p className="text-moss font-medium mt-2 ml-11">Allt du behöver för att guida besökarna.</p>
</div>
<section>
<h2 className="text-sm font-black text-ebony/50 uppercase tracking-widest mb-4">Nedladdningar</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{docs.map((doc, idx) => (
<a key={idx} href={doc.file} target="_blank" rel="noopener noreferrer"
className="group flex items-center justify-between bg-white/60 backdrop-blur-sm p-5 rounded-2xl border border-white hover:-translate-y-1 hover:shadow-xl hover:shadow-moss/10 transition-all duration-300"
>
<div className="flex items-center text-slate-teal">
<div className="bg-linear-to-br from-seafoam to-slate-teal p-3 rounded-xl mr-4 text-eggshell shadow-inner shrink-0">
{doc.icon}
</div>
<div className="flex flex-col">
<span className="font-bold text-sm leading-tight">{doc.title}</span>
<span className="text-[10px] font-bold text-moss bg-moss/10 px-2 py-0.5 rounded-md w-fit mt-1.5 uppercase tracking-wider border border-moss/10">
{doc.size}
</span>
</div>
</div>
<Download size={20} className="text-moss group-hover:text-slate-teal transition-colors shrink-0 ml-2" />
</a>
))}
</div>
</section>
<section>
<h2 className="text-sm font-black text-ebony/50 uppercase tracking-widest mb-4">Användbara Länkar</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{links.map((link, idx) => (
<a key={idx} href={link.url} target="_blank" rel="noopener noreferrer"
className="group flex items-center justify-between bg-white/60 backdrop-blur-sm p-5 rounded-2xl border border-white hover:-translate-y-1 hover:shadow-xl hover:shadow-moss/10 transition-all duration-300"
>
<span className="font-bold text-slate-teal text-sm leading-tight pr-4">{link.title}</span>
<div className="bg-eggshell rounded-full p-2 group-hover:bg-seafoam group-hover:text-eggshell transition-colors shrink-0">
<ExternalLink size={16} className="text-slate-teal group-hover:text-eggshell" />
</div>
</a>
))}
</div>
</section>
</div>
);
};
+88
View File
@@ -0,0 +1,88 @@
// app/emergency/page.tsx
import React from 'react';
import { Phone, AlertTriangle, MapPin, HeartPulse } from 'lucide-react';
import { SafePhoneLink } from '../components/PhoneLinks';
export default function Emergency() {
return (
<div className="space-y-6 animate-fade-in w-full mx-auto">
<div>
<h1 className="text-3xl font-black text-emergency tracking-tight uppercase flex items-center">
<AlertTriangle className="mr-3 text-emergency" size={32} />
Vid Nödsituation
</h1>
<p className="text-ebony font-medium mt-2 ml-11">Agera lugnt, stanna kvar platsen och tillkalla hjälp.</p>
</div>
{/* 112 Card */}
<div className="bg-emergency/20 border-l-4 border-emergency p-6 rounded-r-xl shadow-sm">
<h2 className="text-2xl font-black text-ebony flex items-center mb-2">
<Phone className="mr-3 text-emergency" size={24} />
Ring 112
</h2>
<p className="text-ebony/80 font-medium mb-4">
Vid olycka, brand eller livshotande tillstånd. Berätta vem du är och vad som har hänt.
</p>
<div className="bg-eggshell/50 p-4 rounded-lg border border-emergency/30">
<h3 className="font-bold text-ebony flex items-center mb-1 text-sm uppercase tracking-wider">
<MapPin className="mr-2 text-emergency" size={16} />
Uppge din position
</h3>
<p className="text-ebony font-medium text-sm">
Använd appen <strong>112</strong> eller GPS i telefonen. Säg att du befinner dig i Kullabergs Naturreservat. Var specifik (t.ex. "Nära fyren" eller "Vid Josefinelust").
</p>
</div>
</div>
{/* First Aid / HLR Card */}
<div className="bg-eggshell border-2 border-slate-teal/20 p-6 rounded-xl shadow-sm">
<h2 className="text-xl font-black text-slate-teal flex items-center mb-4 uppercase tracking-wide">
<HeartPulse className="mr-3 text-seafoam" size={24} />
Första Hjälpen & Hjärtstartare
</h2>
<ul className="space-y-3 text-ebony font-medium">
<li className="flex items-start">
<span className="bg-seafoam text-eggshell font-bold px-2 py-0.5 rounded mr-3 text-sm">1</span>
Säkra platsen se till att varken du eller personen utsätts för mer fara.
</li>
<li className="flex items-start">
<span className="bg-seafoam text-eggshell font-bold px-2 py-0.5 rounded mr-3 text-sm shrink-0">2</span>
{/* Mobile View: Standard wrapping text (hides on medium screens and up) */}
<p className="text-ebony font-medium">
Finns hjärtstartare? Ja, närmaste hjärtstartare finns inne <strong className="text-ebony font-black">Naturum Kullaberg</strong> (vid fyren) under deras öppettider.
</p>
</li>
</ul>
</div>
{/* Internal Contact */}
<div className="bg-eggshell border-2 border-slate-teal/20 p-6 rounded-xl shadow-sm">
<h2 className="text-xl font-black text-slate-teal mb-2 uppercase tracking-wide">
Intern Rapportering
</h2>
<p className="text-ebony/80 font-medium mb-4 text-sm">
När situationen är under kontroll, meddela alltid arbetsledaren om vad som inträffat.
</p>
<div className="space-y-4">
<div className="flex flex-col md:flex-row md:justify-between md:items-center pb-4 border-b border-moss/20 gap-3">
<span className="text-base font-bold text-ebony">William Söderberg</span>
<div className='flex gap-2 flex-wrap md:justify-end'>
<SafePhoneLink parts={['072', '247', '02', '91']} display="072-247 02 91" />
<SafePhoneLink parts={['078', '389', '355', '81', '0604']} display="078-389 35 58 10 604" />
</div>
</div>
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-3">
<span className="text-base font-bold text-ebony">Oliver Nilsson</span>
<div className='flex gap-2 flex-wrap md:justify-end'>
<SafePhoneLink parts={['072', '717', '74', '40']} display="072-717 74 40" />
<SafePhoneLink parts={['078', '389', '355', '81', '0605']} display="078-389 35 58 10 605" />
</div>
</div>
</div>
</div>
</div>
);
};
+61
View File
@@ -0,0 +1,61 @@
// app/faq/page.tsx
"use client";
import React, { useState } from 'react';
import faqData from '../data/faq.json';
import { ChevronDown, ChevronUp, MessageCircleQuestionMark } from 'lucide-react';
export default function FAQ() {
const [openIndex, setOpenIndex] = useState<string | null>(null);
const toggleQuestion = (index: string) => {
setOpenIndex(openIndex === index ? null : index);
};
return (
<div className="w-full animate-fade-in">
<div className="mb-8">
<h1 className="text-3xl font-black text-slate-teal tracking-tight flex items-center">
<MessageCircleQuestionMark className="mr-3 text-seafoam" size={32} />
Vanliga Frågor (FAQ)
</h1>
<p className="text-moss font-medium mt-2 ml-11">Använd den här guiden för att snabbt svara turisternas vanligaste frågor.</p>
</div>
<div className="columns-1 lg:columns-2 gap-6 space-y-6">
{faqData.map((section, sIndex) => (
<div key={sIndex} className="break-inside-avoid bg-white/40 backdrop-blur-md border border-white p-6 rounded-3xl shadow-lg shadow-ebony/5">
<h2 className="text-lg font-black text-ebony/70 uppercase tracking-widest mb-4">
{section.category}
</h2>
<div className="space-y-3">
{section.questions.map((item, qIndex) => {
const id = `${sIndex}-${qIndex}`;
const isOpen = openIndex === id;
return (
<div key={id} className={`rounded-2xl overflow-hidden transition-all duration-300 ${isOpen ? 'bg-white shadow-md shadow-seafoam/10' : 'bg-eggshell/50 hover:bg-white'}`}>
<button
className="w-full text-left p-4 flex justify-between items-center focus:outline-none"
onClick={() => toggleQuestion(id)}
>
<span className="font-bold text-slate-teal text-sm pr-4 leading-snug">{item.q}</span>
<div className={`p-1 rounded-full transition-colors ${isOpen ? 'bg-seafoam text-eggshell' : 'bg-transparent text-seafoam'}`}>
{isOpen ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</div>
</button>
<div className={`transition-all duration-300 ease-in-out ${isOpen ? 'max-h-125 opacity-100' : 'max-h-0 opacity-0 overflow-hidden'}`}>
<div className="p-2 pb-4 text-ebony font-medium text-sm leading-relaxed border-t border-ebony/20 mx-4">
{item.a}
</div>
</div>
</div>
);
})}
</div>
</div>
))}
</div>
</div>
);
};
+19
View File
@@ -0,0 +1,19 @@
/* app/globals.css */
@import "tailwindcss";
@theme {
--color-ebony: #5e6545;
--color-moss: #878b5a;
--color-eggshell: #e7e4cf;
--color-gold: #d6b456;
--color-goldenrod: #c48d2e;
--color-slate-teal: #456466;
--color-seafoam: #5B8C8A;
--color-emergency: #EB514C;
}
body {
background-color: var(--color-eggshell);
}
+63
View File
@@ -0,0 +1,63 @@
// app/info/page.tsx
import { Briefcase, CheckCircle2, Navigation } from 'lucide-react';
export default function JobInfo() {
return (
<div className="space-y-8 animate-fade-in w-full mx-auto">
<div>
<h1 className="text-3xl font-black text-slate-teal tracking-tight flex items-center">
<Briefcase className="mr-3 text-seafoam" size={32} />
Jobbinfo & Regler
</h1>
<p className="text-moss font-medium mt-2 ml-11">Läs igenom detta noggrant inför dina arbetspass.</p>
</div>
<section className="bg-eggshell border-2 border-slate-teal/20 p-6 rounded-2xl shadow-sm">
<h2 className="text-lg font-black text-ebony uppercase tracking-widest mb-3 flex items-center">
<CheckCircle2 className="mr-2 text-moss" size={20} /> Förväntningar
</h2>
<p className="text-sm text-ebony/80 font-medium mb-4 leading-relaxed">
Vi förväntar oss att du tar ansvar, visar respekt för både natur och varandra, samt gör ditt bästa med dina arbetsuppgifter.
</p>
<div className="bg-moss/10 rounded-xl p-4 space-y-2 border border-moss/20">
<div className="flex justify-between items-center">
<span className="font-bold text-ebony text-sm">Period 1</span>
<span className="text-xs font-bold bg-eggshell text-moss px-2 py-1 rounded">23 juni kl 08:10 (Buss Mölle)</span>
</div>
<div className="flex justify-between items-center border-t border-moss/20 pt-2">
<span className="font-bold text-ebony text-sm">Period 2</span>
<span className="text-xs font-bold bg-eggshell text-moss px-2 py-1 rounded">7 juli kl 08:10</span>
</div>
</div>
</section>
<section className="bg-eggshell border-2 border-slate-teal/20 p-6 rounded-2xl shadow-sm">
<h2 className="text-lg font-black text-ebony uppercase tracking-widest mb-3 flex items-center">
<Navigation className="mr-2 text-moss" size={20} /> Daglig Utrustning
</h2>
<ul className="grid grid-cols-1 md:grid-cols-2 gap-3">
{['Bra promenadskor (mycket gång!)', 'Oömma/väderanpassade kläder', 'En bekväm ryggsäck', 'Egen lunch (ingen kyl/mikro)', 'Minst 1 liter vatten', 'Myggmedel & Solkräm'].map((item, idx) => (
<li key={idx} className="flex items-center text-sm font-bold text-ebony/80 bg-seafoam/10 p-3 rounded-lg border border-seafoam/20">
<div className="w-2 h-2 rounded-full bg-slate-teal mr-3 shrink-0"></div>
{item}
</li>
))}
</ul>
</section>
<section className="bg-eggshell border-2 border-slate-teal/20 p-6 rounded-2xl shadow-sm">
<h2 className="text-lg font-black text-ebony uppercase tracking-widest mb-3 flex items-center">
<Briefcase className="mr-2 text-moss" size={20} /> Exempel uppgifter
</h2>
<ul className="space-y-2 text-sm font-bold text-ebony/80">
{['Parkeringsguide', 'Naturvägledare för besökare', 'Plocka skräp & ta bort invasiva växter', 'Städa anläggningar', 'Allmänt underhåll (olja skyltar, fixa staket)'].map((task, idx) => (
<li key={idx} className="flex items-center">
<span className="text-seafoam mr-2"></span> {task}
</li>
))}
</ul>
</section>
</div>
);
};
+66
View File
@@ -0,0 +1,66 @@
// app/layout.tsx
import { Leaf } from "lucide-react";
import type { Metadata, Viewport } from "next";
import { Navigation } from "./components/Navigation";
import "./globals.css";
export const viewport: Viewport = {
themeColor: "#0E292E",
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
};
export const metadata: Metadata = {
title: "Naturvärdarna Kullaberg",
description: "Intern app för naturvärdarna på Kullaberg",
manifest: "/manifest.json",
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "Naturvärdarna",
startupImage: [
{
url: "/apple-splash.png",
},
],
},
icons: {
icon: '/favicon.svg',
shortcut: '/favicon.svg',
apple: [
{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' },
],
},
formatDetection: {
telephone: false,
}
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="sv" suppressHydrationWarning>
<body className="min-h-screen bg-eggshell font-sans text-ebony selection:bg-gold selection:text-ebony pb-10">
{/* Header & Navigation */}
<header className="sticky top-0 z-50 bg-ebony shadow-md">
<div className="max-w-5xl mx-auto">
<div className="px-4 py-3 flex items-center justify-between">
<h1 className="text-xl font-black text-eggshell flex items-center tracking-tight uppercase drop-shadow-sm">
<Leaf className="mr-2 text-seafoam" size={20} />
Naturvärdarna
</h1>
</div>
<Navigation />
</div>
</header>
{/* Main Content Area */}
<main className="max-w-5xl mx-auto p-4 py-6 md:py-8">
{children}
</main>
</body>
</html>
);
}
+15
View File
@@ -0,0 +1,15 @@
// app/network.d.ts
interface NetworkInformation extends EventTarget {
readonly effectiveType: 'slow-2g' | '2g' | '3g' | '4g';
readonly type: 'bluetooth' | 'cellular' | 'ethernet' | 'none' | 'wifi' | 'wimax' | 'other' | 'unknown';
readonly saveData: boolean;
onchange: EventListener;
}
interface Navigator extends NavigatorNetworkInformation { }
interface WorkerNavigator extends NavigatorNetworkInformation { }
interface NavigatorNetworkInformation {
readonly connection?: NetworkInformation;
}
+75
View File
@@ -0,0 +1,75 @@
// app/page.tsx
import { AlertTriangle, ArrowRight, PhoneCall } from 'lucide-react';
import Link from 'next/link';
import { SafePhoneLink } from './components/PhoneLinks';
export default function Home() {
return (
<div className="space-y-6 animate-fade-in">
{/* Welcome Banner */}
<div className="bg-slate-teal rounded-2xl p-6 md:p-8 shadow-lg shadow-slate-teal/10 text-eggshell">
<h1 className="text-3xl md:text-4xl font-black tracking-tight mb-2 drop-shadow-sm">Välkommen!</h1>
<p className="text-eggshell/90 font-bold text-lg">Naturvärdarna Kullaberg Sommar {new Date().getFullYear()}</p>
</div>
{/* Daily Notice Card */}
<div className="bg-gold rounded-2xl p-5 flex items-start shadow-md shadow-goldenrod/20 text-ebony">
<div className="bg-eggshell/40 p-2 rounded-xl mr-4 shrink-0">
<AlertTriangle className="text-ebony" size={24} />
</div>
<div>
<h3 className="font-black uppercase tracking-widest text-sm mb-1">Dagens Påminnelse</h3>
<p className="text-sm font-medium leading-relaxed">
Glöm inte minst 1 liter vatten, solkräm och myggmedel. Det förväntas bli mycket varmt idag!
</p>
</div>
</div>
{/* Quick Action Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Link href="/schedule" className="group bg-eggshell/50 border-2 border-moss/30 rounded-2xl p-6 hover:-translate-y-1 hover:bg-moss/10 hover:border-moss/60 hover:shadow-lg transition-all duration-300 flex flex-col items-start">
<div className="bg-seafoam/20 w-12 h-12 rounded-xl flex items-center justify-center mb-4 text-slate-teal group-hover:bg-seafoam group-hover:text-eggshell transition-colors">
<ArrowRight size={24} className="group-hover:rotate-45 transition-transform" />
</div>
<h3 className="font-black text-ebony text-xl mb-2 uppercase tracking-wide">Ditt Schema</h3>
<p className="text-sm text-ebony/70 font-medium">Kolla dina arbetspass och dagliga rundor snabbt.</p>
</Link>
<Link href="/faq" className="group bg-eggshell/50 border-2 border-moss/30 rounded-2xl p-6 hover:-translate-y-1 hover:bg-moss/10 hover:border-moss/60 hover:shadow-lg transition-all duration-300 flex flex-col items-start">
<div className="bg-seafoam/20 w-12 h-12 rounded-xl flex items-center justify-center mb-4 text-slate-teal group-hover:bg-seafoam group-hover:text-eggshell transition-colors">
<ArrowRight size={24} className="group-hover:rotate-45 transition-transform" />
</div>
<h3 className="font-black text-ebony text-xl mb-2 uppercase tracking-wide">Vanliga Frågor</h3>
<p className="text-sm text-ebony/70 font-medium">Snabba svar turisternas vanligaste funderingar.</p>
</Link>
</div>
{/* Contact Card */}
<div className="bg-eggshell/50 border-2 border-moss/30 rounded-2xl overflow-hidden shadow-sm">
<div className="bg-moss/20 px-6 py-4 flex items-center border-b-2 border-moss/30">
<PhoneCall size={20} className="text-ebony mr-3" />
<h3 className="font-black text-ebony uppercase tracking-widest text-sm">Snabbkontakt</h3>
</div>
<div className="p-6 space-y-4">
<div className="flex flex-col md:flex-row md:justify-between md:items-center pb-4 border-b border-moss/20 gap-3">
<span className="text-base font-bold text-ebony">William Söderberg</span>
<div className='flex gap-2 flex-wrap md:justify-end'>
<SafePhoneLink parts={['072', '247', '02', '91']} display="072-247 02 91" />
<SafePhoneLink parts={['078', '389', '355', '81', '0604']} display="078-389 35 58 10 604" />
</div>
</div>
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-3">
<span className="text-base font-bold text-ebony">Oliver Nilsson</span>
<div className='flex gap-2 flex-wrap md:justify-end'>
<SafePhoneLink parts={['072', '717', '74', '40']} display="072-717 74 40" />
<SafePhoneLink parts={['078', '389', '355', '81', '0605']} display="078-389 35 58 10 605" />
</div>
</div>
</div>
</div>
</div>
);
}
+207
View File
@@ -0,0 +1,207 @@
// app/schedule/page.tsx
"use client"
import React, { useRef } from 'react';
import scheduleData from '../data/schedule.json';
import { Clock, CalendarRange } from 'lucide-react';
// --- Helper Functions ---
// Bulletproof parser: Finds the times regardless of spaces or dashes!
const parseTimeBlock = (timeStr: string) => {
if (!timeStr || timeStr === 'Ledig') return null;
// Scans the string and extracts any "HH:MM" patterns
const matches = timeStr.match(/(\d{1,2}):(\d{2})/g);
if (!matches || matches.length < 2) return null;
const parse = (t: string) => {
const [h, m] = t.split(':').map(Number);
return h + (m / 60);
};
return { start: parse(matches[0]), end: parse(matches[1]) };
};
// Checks if the two time blocks actually collide
const checkOverlap = (time1?: string, time2?: string) => {
const t1 = time1 ? parseTimeBlock(time1) : null;
const t2 = time2 ? parseTimeBlock(time2) : null;
if (!t1 || !t2) return false;
return t1.start < t2.end && t1.end > t2.start;
};
// --- Types & Components ---
interface ShiftData {
time: string;
title?: string;
notes?: string;
}
interface ShiftBlockProps {
team: 'PF' | 'TU';
data: ShiftData;
pos: { top: number; height: number };
isOverlapping: boolean;
}
// Reusable Component for the Event Cards
const ShiftBlock: React.FC<ShiftBlockProps> = ({ team, data, pos, isOverlapping }) => {
const isPF = team === 'PF';
// Calculate layout based on overlap
const widthClasses = isOverlapping
? isPF ? "left-1 right-1/2 mr-0.5" : "left-1/2 right-1 ml-0.5"
: "left-1 right-1";
// Team-specific styling
const bgClasses = isPF
? "bg-gradient-to-br from-gold to-goldenrod border-goldenrod"
: "bg-gradient-to-br from-seafoam to-slate-teal border-slate-teal";
const textMain = isPF ? "text-ebony" : "text-eggshell";
const textMuted = isPF ? "text-ebony/90" : "text-eggshell/90";
const notesBg = isPF ? "bg-eggshell/30 border-seafoam/5" : "bg-gold/20 border-eggshell/10";
const fallbackTitle = isPF ? "PF" : "TU";
return (
<div
// Removed scale on hover! Added brightness-105 for a safe, non-clipping hover effect.
className={`absolute ${widthClasses} ${bgClasses} rounded-lg p-1.5 flex flex-col overflow-hidden shadow-sm border cursor-pointer transition-all hover:brightness-105 hover:shadow-md`}
style={{ top: `${pos.top}px`, height: `${pos.height}px` }}
>
<p className={`text-[10px] font-black ${textMain} uppercase tracking-wide leading-tight truncate`}>
{data.title || fallbackTitle}
</p>
<p className={`text-[9px] font-bold ${textMuted} flex items-center mt-0.5 whitespace-nowrap`}>
<Clock size={8} className="mr-0.5 shrink-0" />
<span className="truncate">{data.time}</span>
</p>
{data.notes && pos.height > 50 && (
<div className={`mt-1 ${notesBg} p-1 rounded-md border`}>
<p className={`text-[9px] font-medium ${textMain} leading-tight truncate pb-1`}>
{data.notes}
</p>
</div>
)}
</div>
);
};
// --- Main Schedule View ---
export default function Schedule() {
const startHour = 7;
const endHour = 17;
const hours = Array.from({ length: endHour - startHour + 1 }, (_, i) => startHour + i);
const PIXELS_PER_HOUR = 60;
const GRID_PADDING_TOP = 24;
const TOTAL_GRID_HEIGHT = (hours.length * PIXELS_PER_HOUR) + GRID_PADDING_TOP;
const containerRef = useRef<HTMLDivElement>(null);
const calculatePosition = (timeStr: string) => {
const t = parseTimeBlock(timeStr);
if (!t) return null;
const topOffset = (t.start - startHour) * PIXELS_PER_HOUR;
const duration = (t.end - t.start) * PIXELS_PER_HOUR;
return { top: topOffset + GRID_PADDING_TOP + 1, height: duration - 2 };
};
return (
<div className="space-y-4 animate-fade-in w-full">
<div>
<h1 className="text-3xl font-black text-slate-teal tracking-tight flex items-center">
<CalendarRange className="mr-3 text-seafoam" size={32} />
Veckoschema
</h1>
<p className="text-moss font-medium mt-2 ml-11">Här hittar du när vi bemannar Kullaberg.</p>
</div>
<div className="bg-eggshell border-2 border-slate-teal/20 rounded-2xl shadow-sm overflow-hidden flex flex-col max-h-[65vh] h-max min-h-125">
<div className="flex-1 overflow-auto scrollbar-hide" ref={containerRef}>
<div className="flex min-w-200 w-full">
{/* Sticky Time Column */}
<div className="sticky left-0 z-20 w-18 flex-none bg-eggshell border-r-2 border-slate-teal/10 shadow-[2px_0_5px_rgba(0,0,0,0.02)]">
<div className="sticky top-0 z-30 h-12 border-b-2 border-slate-teal/10 bg-eggshell"></div>
<div className="relative w-full" style={{ height: `${TOTAL_GRID_HEIGHT}px` }}>
{hours.map((hour, i) => (
<div key={hour} className="absolute w-full flex justify-end pr-3" style={{ top: `${i * PIXELS_PER_HOUR + GRID_PADDING_TOP}px` }}>
<span className="text-[11px] font-bold text-slate-teal -mt-2 bg-eggshell px-1">
{hour.toString().padStart(2, '0')}:00
</span>
</div>
))}
</div>
</div>
{/* Grid Area */}
<div className="flex-auto relative">
{/* Sticky Day Headers */}
<div className="sticky top-0 z-10 flex h-12 border-b-2 border-slate-teal/10 bg-eggshell">
{scheduleData.map((d) => (
<div key={d.day} className="flex-1 flex items-center justify-center border-l-2 border-slate-teal/5 first:border-l-0 min-w-25">
<span className="text-xs font-black text-ebony uppercase tracking-widest">{d.day.substring(0, 3)}</span>
</div>
))}
</div>
{/* Main Grid Canvas */}
<div className="relative w-full" style={{ height: `${TOTAL_GRID_HEIGHT}px` }}>
<div className="absolute inset-0 z-0">
{hours.map((hour, i) => (
<div key={hour} className="absolute w-full border-t border-slate-teal/10" style={{ top: `${i * PIXELS_PER_HOUR + GRID_PADDING_TOP}px`, height: `${PIXELS_PER_HOUR}px` }} />
))}
</div>
{/* Vertical Columns & Blocks */}
<div className="absolute inset-0 flex z-0">
{scheduleData.map((d, colIndex) => {
// Type casting here assumes scheduleData is typed loosely.
const pfData = d.pilgrimsfalkarna as ShiftData | undefined;
const tuData = d.tumlarna as ShiftData | undefined;
const hasPF = !!(pfData && pfData.time && pfData.time !== 'Ledig');
const hasTU = !!(tuData && tuData.time && tuData.time !== 'Ledig');
const isOverlapping = hasPF && hasTU && checkOverlap(pfData.time, tuData.time);
return (
<div key={colIndex} className="flex-1 border-l border-slate-teal/10 relative min-w-25">
{/* Pilgrimsfalkarna Component */}
{hasPF && (() => {
const pos = calculatePosition(pfData.time);
return pos ? <ShiftBlock team="PF" data={pfData} pos={pos} isOverlapping={isOverlapping} /> : null;
})()}
{/* Tumlarna Component */}
{hasTU && (() => {
const pos = calculatePosition(tuData.time);
return pos ? <ShiftBlock team="TU" data={tuData} pos={pos} isOverlapping={isOverlapping} /> : null;
})()}
</div>
);
})}
</div>
</div>
</div>
</div>
</div>
</div>
{/* Legend */}
<div className="flex items-center justify-center space-x-6 py-2 text-xs font-bold text-slate-teal">
<span className="flex items-center"><div className="w-3 h-3 bg-linear-to-br from-gold to-goldenrod rounded-full mr-2 shadow-sm"></div> Pilgrimsfalkarna</span>
<span className="flex items-center"><div className="w-3 h-3 bg-linear-to-br from-seafoam to-slate-teal rounded-full mr-2 shadow-sm"></div> Tumlarna</span>
</div>
</div>
);
};
+52
View File
@@ -0,0 +1,52 @@
// app/sw.ts
/// <reference lib="webworker" />
import { defaultCache } from "@serwist/next/worker";
import type { PrecacheEntry, SerwistGlobalConfig } from "serwist";
import { CacheFirst, Serwist, ExpirationPlugin } from "serwist";
declare global {
interface WorkerGlobalScope extends SerwistGlobalConfig {
__SW_MANIFEST: (PrecacheEntry | string)[] | undefined;
}
}
declare const self: ServiceWorkerGlobalScope;
const serwist = new Serwist({
precacheEntries: self.__SW_MANIFEST,
skipWaiting: true,
clientsClaim: true,
navigationPreload: true,
runtimeCaching: [
{
matcher: ({ url }) => url.pathname.startsWith("/files/"),
handler: async ({ request, event }) => {
const conn = navigator.connection;
const isStrictWifi = conn && (conn.type === 'wifi' || conn.type === 'ethernet');
const shouldCache = isStrictWifi && !conn?.saveData;
if (shouldCache) {
const cacheStrategy = new CacheFirst({
cacheName: "kullaberg-files-cache",
plugins: [new ExpirationPlugin({ maxEntries: 20, maxAgeSeconds: 2592000 })],
});
return cacheStrategy.handle({ request, event });
}
return fetch(request);
}
},
...defaultCache,
],
fallbacks: {
entries: [
{
url: "/",
matcher({ request }) {
return request.destination === "document";
},
},
],
},
});
serwist.addEventListeners();
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+82
View File
@@ -0,0 +1,82 @@
C:\Users\Wiking\Code\naturvardarna-pwa\.env
C:\Users\Wiking\Code\naturvardarna-pwa\.git
C:\Users\Wiking\Code\naturvardarna-pwa\.gitignore
C:\Users\Wiking\Code\naturvardarna-pwa\.next
C:\Users\Wiking\Code\naturvardarna-pwa\app
C:\Users\Wiking\Code\naturvardarna-pwa\eslint.config.mjs
C:\Users\Wiking\Code\naturvardarna-pwa\files.txt
C:\Users\Wiking\Code\naturvardarna-pwa\next-env.d.ts
C:\Users\Wiking\Code\naturvardarna-pwa\next.config.ts
C:\Users\Wiking\Code\naturvardarna-pwa\node_modules
C:\Users\Wiking\Code\naturvardarna-pwa\package-lock.json
C:\Users\Wiking\Code\naturvardarna-pwa\package.json
C:\Users\Wiking\Code\naturvardarna-pwa\postcss.config.mjs
C:\Users\Wiking\Code\naturvardarna-pwa\prisma
C:\Users\Wiking\Code\naturvardarna-pwa\prisma.config.ts
C:\Users\Wiking\Code\naturvardarna-pwa\public
C:\Users\Wiking\Code\naturvardarna-pwa\README.md
C:\Users\Wiking\Code\naturvardarna-pwa\src
C:\Users\Wiking\Code\naturvardarna-pwa\tsconfig.json
C:\Users\Wiking\Code\naturvardarna-pwa\app\actions
C:\Users\Wiking\Code\naturvardarna-pwa\app\admin
C:\Users\Wiking\Code\naturvardarna-pwa\app\components
C:\Users\Wiking\Code\naturvardarna-pwa\app\css.d.ts
C:\Users\Wiking\Code\naturvardarna-pwa\app\data
C:\Users\Wiking\Code\naturvardarna-pwa\app\documents
C:\Users\Wiking\Code\naturvardarna-pwa\app\emergency
C:\Users\Wiking\Code\naturvardarna-pwa\app\faq
C:\Users\Wiking\Code\naturvardarna-pwa\app\globals.css
C:\Users\Wiking\Code\naturvardarna-pwa\app\info
C:\Users\Wiking\Code\naturvardarna-pwa\app\layout.tsx
C:\Users\Wiking\Code\naturvardarna-pwa\app\page.tsx
C:\Users\Wiking\Code\naturvardarna-pwa\app\schedule
C:\Users\Wiking\Code\naturvardarna-pwa\app\sw.ts
C:\Users\Wiking\Code\naturvardarna-pwa\app\actions\admin.ts
C:\Users\Wiking\Code\naturvardarna-pwa\app\admin\adminTypes.ts
C:\Users\Wiking\Code\naturvardarna-pwa\app\admin\AttendanceTab.tsx
C:\Users\Wiking\Code\naturvardarna-pwa\app\admin\page.tsx
C:\Users\Wiking\Code\naturvardarna-pwa\app\admin\ReportTab.tsx
C:\Users\Wiking\Code\naturvardarna-pwa\app\admin\SetupTab.tsx
C:\Users\Wiking\Code\naturvardarna-pwa\app\admin\useAdminState.ts
C:\Users\Wiking\Code\naturvardarna-pwa\app\components\Navigation.tsx
C:\Users\Wiking\Code\naturvardarna-pwa\app\components\PhoneLinks.tsx
C:\Users\Wiking\Code\naturvardarna-pwa\app\data\faq.json
C:\Users\Wiking\Code\naturvardarna-pwa\app\data\schedule.json
C:\Users\Wiking\Code\naturvardarna-pwa\app\documents\page.tsx
C:\Users\Wiking\Code\naturvardarna-pwa\app\emergency\page.tsx
C:\Users\Wiking\Code\naturvardarna-pwa\app\faq\page.tsx
C:\Users\Wiking\Code\naturvardarna-pwa\app\info\page.tsx
C:\Users\Wiking\Code\naturvardarna-pwa\app\schedule\page.tsx
C:\Users\Wiking\Code\naturvardarna-pwa\prisma\dev.db
C:\Users\Wiking\Code\naturvardarna-pwa\prisma\migrations
C:\Users\Wiking\Code\naturvardarna-pwa\prisma\schema.prisma
C:\Users\Wiking\Code\naturvardarna-pwa\prisma\migrations\20260316123212_init
C:\Users\Wiking\Code\naturvardarna-pwa\prisma\migrations\migration_lock.toml
C:\Users\Wiking\Code\naturvardarna-pwa\prisma\migrations\20260316123212_init\migration.sql
C:\Users\Wiking\Code\naturvardarna-pwa\public\favicon.svg
C:\Users\Wiking\Code\naturvardarna-pwa\public\files
C:\Users\Wiking\Code\naturvardarna-pwa\public\manifest.json
C:\Users\Wiking\Code\naturvardarna-pwa\public\files\Badplatser på Kullaberg.pdf
C:\Users\Wiking\Code\naturvardarna-pwa\public\files\Destinationskunskap 2026.pptx
C:\Users\Wiking\Code\naturvardarna-pwa\public\files\Orienteringskarta - Kullaberg.pdf
C:\Users\Wiking\Code\naturvardarna-pwa\public\files\Turistkarta - Kullaberg.pdf
C:\Users\Wiking\Code\naturvardarna-pwa\public\files\Underlag för guidediplomering.pptx
C:\Users\Wiking\Code\naturvardarna-pwa\public\files\Vandringsrutter.pdf
C:\Users\Wiking\Code\naturvardarna-pwa\src\generated
C:\Users\Wiking\Code\naturvardarna-pwa\src\lib
C:\Users\Wiking\Code\naturvardarna-pwa\src\generated\prisma
C:\Users\Wiking\Code\naturvardarna-pwa\src\generated\prisma\browser.ts
C:\Users\Wiking\Code\naturvardarna-pwa\src\generated\prisma\client.ts
C:\Users\Wiking\Code\naturvardarna-pwa\src\generated\prisma\commonInputTypes.ts
C:\Users\Wiking\Code\naturvardarna-pwa\src\generated\prisma\enums.ts
C:\Users\Wiking\Code\naturvardarna-pwa\src\generated\prisma\internal
C:\Users\Wiking\Code\naturvardarna-pwa\src\generated\prisma\models
C:\Users\Wiking\Code\naturvardarna-pwa\src\generated\prisma\models.ts
C:\Users\Wiking\Code\naturvardarna-pwa\src\generated\prisma\internal\class.ts
C:\Users\Wiking\Code\naturvardarna-pwa\src\generated\prisma\internal\prismaNamespace.ts
C:\Users\Wiking\Code\naturvardarna-pwa\src\generated\prisma\internal\prismaNamespaceBrowser.ts
C:\Users\Wiking\Code\naturvardarna-pwa\src\generated\prisma\models\Attendance.ts
C:\Users\Wiking\Code\naturvardarna-pwa\src\generated\prisma\models\Period.ts
C:\Users\Wiking\Code\naturvardarna-pwa\src\generated\prisma\models\User.ts
C:\Users\Wiking\Code\naturvardarna-pwa\src\generated\prisma\models\Youth.ts
C:\Users\Wiking\Code\naturvardarna-pwa\src\lib\prisma.ts
+28
View File
@@ -0,0 +1,28 @@
// lib/prisma.ts
import "dotenv/config";
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
import { PrismaClient } from "../generated/prisma/client";
const isProd = process.env.NODE_ENV === "production";
const defaultUrl = isProd ? "file:/app/data/kullaberg.db" : "file:./prisma/dev.db";
const dbUrl = process.env.DATABASE_URL || defaultUrl;
const adapter = new PrismaBetterSqlite3({
url: dbUrl
});
const prismaClientSingleton = () => {
return new PrismaClient({ adapter });
};
declare global {
var prismaGlobal: undefined | ReturnType<typeof prismaClientSingleton>;
}
const prisma = globalThis.prismaGlobal ?? prismaClientSingleton();
export default prisma;
if (!isProd) {
globalThis.prismaGlobal = prisma;
}
+24
View File
@@ -0,0 +1,24 @@
// next.config.ts
import type { NextConfig } from "next";
import withSerwistInit from "@serwist/next";
const manualRevision = "v1.0.0";
const withSerwist = withSerwistInit({
swSrc: "app/sw.ts",
swDest: "public/sw.js",
disable: process.env.NODE_ENV === "development",
cacheOnNavigation: true,
additionalPrecacheEntries: [
{ url: "/", revision: manualRevision },
{ url: "/emergency", revision: manualRevision },
{ url: "/faq", revision: manualRevision },
],
});
const nextConfig: NextConfig = {
output: "standalone",
};
export default withSerwist(nextConfig);
+9202
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
{
"name": "naturvardarna-pwa",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --webpack",
"build": "next build --webpack",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@prisma/adapter-better-sqlite3": "^7.5.0",
"@prisma/client": "^7.5.0",
"@serwist/next": "^9.5.7",
"better-sqlite3": "^12.8.0",
"dotenv": "^17.3.1",
"localforage": "^1.10.0",
"lucide-react": "^0.577.0",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3",
"serwist": "^9.5.7"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20",
"@types/pg": "^8.18.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"prisma": "^7.5.0",
"tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+14
View File
@@ -0,0 +1,14 @@
// prisma.config.ts
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: env("DATABASE_URL"),
},
});
@@ -0,0 +1,40 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"pin" TEXT NOT NULL,
"role" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "Period" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"startDate" TEXT NOT NULL,
"endDate" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "Youth" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"team" TEXT NOT NULL,
"periodId" TEXT NOT NULL,
CONSTRAINT "Youth_periodId_fkey" FOREIGN KEY ("periodId") REFERENCES "Period" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Attendance" (
"id" TEXT NOT NULL PRIMARY KEY,
"date" TEXT NOT NULL,
"shiftId" TEXT NOT NULL,
"hoursWorked" REAL NOT NULL,
"weightedHours" REAL NOT NULL,
"status" TEXT NOT NULL,
"note" TEXT,
"youthId" TEXT NOT NULL,
CONSTRAINT "Attendance_youthId_fkey" FOREIGN KEY ("youthId") REFERENCES "Youth" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "Attendance_date_youthId_shiftId_key" ON "Attendance"("date", "youthId", "shiftId");
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"
+55
View File
@@ -0,0 +1,55 @@
// prisma/schema.prisma
generator client {
provider = "prisma-client"
output = "../generated/prisma"
}
datasource db {
provider = "sqlite"
// Notice: No URL here anymore!
}
// 1. Your Staff/Admins
model User {
id String @id @default(cuid())
name String
pin String
role String // "Admin", "Staff", "Viewer"
}
// 2. The 3-Week Periods
model Period {
id String @id @default(cuid())
name String
startDate String
endDate String
youths Youth[]
}
// 3. The Youth Workers
model Youth {
id String @id @default(cuid())
name String
team String // "PF" or "TU"
periodId String
period Period @relation(fields: [periodId], references: [id], onDelete: Cascade)
attendance Attendance[]
}
// 4. The Daily Attendance Records
model Attendance {
id String @id @default(cuid())
date String
shiftId String // "MORNING" or "AFTERNOON"
hoursWorked Float
weightedHours Float
status String // "Present", "Late", "Absent", "Pending"
note String?
youthId String
youth Youth @relation(fields: [youthId], references: [id], onDelete: Cascade)
// A youth can only have one specific shift record per day
@@unique([date, youthId, shiftId])
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

+51
View File
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160">
<defs>
<style>
.cls-1 {
fill: #fff;
}
</style>
</defs>
<polygon class="cls-1" points="100.54 88.73 117.62 78.86 117.62 67.01 117.62 67.01 107.35 61.08 90.27 70.94 90.27 51.21 80 45.29 69.73 51.21 69.73 70.94 52.65 61.08 42.38 67.01 42.38 67.01 42.38 78.86 59.46 88.73 42.38 98.59 42.38 110.45 52.65 116.38 69.73 106.51 69.73 126.24 80 132.17 90.27 126.24 90.27 106.51 107.35 116.38 117.62 110.45 117.62 98.59 100.54 88.73"/>
<g>
<path class="cls-1" d="M35,21.54h-3.59l-3.67-5.48c-.07-.11-.18-.34-.33-.69h-.04v6.17h-2.84v-12.6h2.84v5.96h.04c.07-.16.19-.4.35-.7l3.48-5.26h3.38l-4.39,6.01,4.77,6.59Z"/>
<path class="cls-1" d="M46.6,16.08c0,3.79-1.76,5.68-5.29,5.68s-5.13-1.85-5.13-5.55v-7.28h2.85v7.31c0,2.04.8,3.06,2.39,3.06s2.35-.98,2.35-2.95v-7.42h2.84v7.15Z"/>
<path class="cls-1" d="M56.98,21.54h-7.51v-12.6h2.84v10.3h4.67v2.3Z"/>
<path class="cls-1" d="M66.31,21.54h-7.51v-12.6h2.84v10.3h4.67v2.3Z"/>
<path class="cls-1" d="M79.21,21.54h-3.09l-.9-2.8h-4.48l-.89,2.8h-3.08l4.59-12.6h3.37l4.48,12.6ZM74.57,16.56l-1.35-4.24c-.1-.32-.17-.69-.21-1.13h-.07c-.03.37-.1.74-.22,1.1l-1.37,4.27h3.23Z"/>
<path class="cls-1" d="M80.91,21.54v-12.6h4.59c1.41,0,2.49.26,3.24.77s1.13,1.24,1.13,2.18c0,.68-.23,1.27-.69,1.78-.46.51-1.05.86-1.76,1.06v.04c.9.11,1.61.44,2.15.99.54.55.8,1.22.8,2.01,0,1.15-.41,2.07-1.24,2.75s-1.95,1.02-3.38,1.02h-4.84ZM83.75,11.03v2.99h1.25c.59,0,1.05-.14,1.38-.43.34-.28.51-.68.51-1.17,0-.93-.69-1.39-2.07-1.39h-1.06ZM83.75,16.13v3.32h1.54c.66,0,1.17-.15,1.54-.46.37-.3.56-.72.56-1.25s-.18-.9-.55-1.19c-.37-.29-.88-.43-1.53-.43h-1.55Z"/>
<path class="cls-1" d="M100.14,21.54h-7.56v-12.6h7.27v2.31h-4.43v2.8h4.12v2.3h-4.12v2.88h4.72v2.3Z"/>
<path class="cls-1" d="M112.86,21.54h-3.26l-1.96-3.24c-.15-.25-.29-.47-.42-.66s-.27-.36-.41-.5-.28-.24-.43-.32c-.15-.07-.31-.11-.49-.11h-.76v4.83h-2.84v-12.6h4.5c3.06,0,4.59,1.14,4.59,3.43,0,.44-.07.85-.2,1.22s-.33.71-.57,1.01-.54.56-.89.77-.74.39-1.16.51v.04c.19.06.37.15.54.29s.35.29.51.46.32.36.47.57c.15.2.29.4.41.59l2.38,3.73ZM105.12,11.07v3.51h1.23c.61,0,1.1-.18,1.47-.53.38-.36.56-.8.56-1.33,0-1.1-.66-1.65-1.98-1.65h-1.28Z"/>
<path class="cls-1" d="M124.5,20.7c-1.23.71-2.76,1.06-4.59,1.06-2.03,0-3.63-.56-4.79-1.68-1.17-1.12-1.75-2.67-1.75-4.64s.64-3.59,1.92-4.84,2.98-1.87,5.1-1.87c1.34,0,2.51.18,3.53.55v2.66c-.97-.56-2.16-.84-3.57-.84-1.18,0-2.14.38-2.88,1.15s-1.11,1.79-1.11,3.06.33,2.3,1,3.01c.66.71,1.56,1.06,2.69,1.06.68,0,1.22-.1,1.62-.29v-2.46h-2.52v-2.27h5.36v6.34Z"/>
<path class="cls-1" d="M126.5,21.06v-2.81c.51.43,1.06.75,1.66.96s1.2.32,1.81.32c.36,0,.67-.03.94-.1.27-.06.49-.15.67-.27.18-.11.31-.25.4-.4s.13-.32.13-.51c0-.25-.07-.47-.21-.66s-.33-.37-.58-.54-.53-.32-.87-.47-.69-.31-1.08-.47c-.98-.41-1.72-.91-2.2-1.5-.48-.59-.72-1.31-.72-2.14,0-.66.13-1.22.4-1.69s.62-.86,1.08-1.16c.45-.3.98-.53,1.58-.67s1.23-.22,1.9-.22,1.24.04,1.75.12c.51.08.97.2,1.4.36v2.63c-.21-.15-.44-.28-.69-.39s-.5-.2-.77-.28-.53-.13-.79-.16c-.26-.04-.51-.05-.74-.05-.32,0-.62.03-.88.09s-.49.15-.67.26-.32.24-.42.4-.15.33-.15.52c0,.21.06.4.17.57s.27.33.47.47.45.3.75.44.62.29.99.44c.5.21.96.44,1.36.67.4.24.75.51,1.03.8s.51.64.66,1.02.23.83.23,1.34c0,.7-.13,1.29-.4,1.77s-.63.86-1.08,1.16-.99.51-1.59.64c-.61.13-1.25.19-1.92.19s-1.35-.06-1.97-.18c-.62-.12-1.16-.29-1.62-.53Z"/>
<path class="cls-1" d="M16.35,40.54h-2.87l-5.19-7.92c-.3-.46-.52-.81-.63-1.05h-.04c.05.45.07,1.12.07,2.04v6.93h-2.68v-12.6h3.06l5,7.67c.23.35.44.69.63,1.03h.04c-.05-.29-.07-.87-.07-1.73v-6.97h2.68v12.6Z"/>
<path class="cls-1" d="M30.45,40.54h-3.09l-.9-2.8h-4.48l-.89,2.8h-3.08l4.59-12.6h3.37l4.48,12.6ZM25.81,35.56l-1.35-4.24c-.1-.32-.17-.69-.21-1.13h-.07c-.03.37-.1.74-.22,1.1l-1.37,4.27h3.23Z"/>
<path class="cls-1" d="M41,30.25h-3.59v10.29h-2.85v-10.29h-3.58v-2.31h10.02v2.31Z"/>
<path class="cls-1" d="M53.09,35.08c0,3.79-1.76,5.68-5.29,5.68s-5.13-1.85-5.13-5.55v-7.28h2.85v7.31c0,2.04.8,3.06,2.39,3.06s2.35-.98,2.35-2.95v-7.42h2.84v7.15Z"/>
<path class="cls-1" d="M66.53,40.54h-3.26l-1.96-3.24c-.15-.25-.29-.47-.42-.66s-.27-.36-.41-.5-.28-.24-.43-.32-.31-.11-.49-.11h-.76v4.83h-2.84v-12.6h4.5c3.06,0,4.59,1.14,4.59,3.43,0,.44-.07.85-.2,1.22s-.33.71-.57,1.01-.54.56-.89.77-.74.39-1.16.51v.04c.19.06.37.15.54.29s.35.29.51.46.32.36.47.57.29.4.41.59l2.38,3.73ZM58.8,30.07v3.51h1.23c.61,0,1.1-.18,1.47-.53.38-.36.56-.8.56-1.33,0-1.1-.66-1.65-1.98-1.65h-1.28Z"/>
<path class="cls-1" d="M78.41,40.54h-3.26l-1.96-3.24c-.15-.25-.29-.47-.42-.66s-.27-.36-.41-.5-.28-.24-.43-.32-.31-.11-.49-.11h-.76v4.83h-2.84v-12.6h4.5c3.06,0,4.59,1.14,4.59,3.43,0,.44-.07.85-.2,1.22s-.33.71-.57,1.01-.54.56-.89.77-.74.39-1.16.51v.04c.19.06.37.15.54.29s.35.29.51.46.32.36.47.57.29.4.41.59l2.38,3.73ZM70.68,30.07v3.51h1.23c.61,0,1.1-.18,1.47-.53.38-.36.56-.8.56-1.33,0-1.1-.66-1.65-1.98-1.65h-1.28Z"/>
<path class="cls-1" d="M87.27,40.54h-7.56v-12.6h7.27v2.31h-4.43v2.8h4.12v2.3h-4.12v2.88h4.72v2.3Z"/>
<path class="cls-1" d="M88.84,40.06v-2.81c.51.43,1.06.75,1.66.96s1.2.32,1.81.32c.36,0,.67-.03.94-.1.27-.06.49-.15.67-.27.18-.11.31-.25.4-.4s.13-.32.13-.51c0-.25-.07-.47-.21-.66s-.33-.37-.58-.54-.53-.32-.87-.47-.69-.31-1.08-.47c-.98-.41-1.72-.91-2.2-1.5-.48-.59-.72-1.31-.72-2.14,0-.66.13-1.22.4-1.69s.62-.86,1.08-1.16c.45-.3.98-.53,1.58-.67s1.23-.22,1.9-.22,1.24.04,1.75.12c.51.08.97.2,1.4.36v2.63c-.21-.15-.44-.28-.69-.39s-.5-.2-.77-.28-.53-.13-.79-.16c-.26-.04-.51-.05-.74-.05-.32,0-.62.03-.88.09s-.49.15-.67.26-.32.24-.42.4-.15.33-.15.52c0,.21.06.4.17.57s.27.33.47.47.45.3.75.44.62.29.99.44c.5.21.96.44,1.36.67.4.24.75.51,1.03.8s.51.64.66,1.02.23.83.23,1.34c0,.7-.13,1.29-.4,1.77s-.63.86-1.08,1.16-.99.51-1.59.64c-.61.13-1.25.19-1.92.19s-1.35-.06-1.97-.18c-.62-.12-1.16-.29-1.62-.53Z"/>
<path class="cls-1" d="M107.19,40.54h-7.56v-12.6h7.27v2.31h-4.43v2.8h4.12v2.3h-4.12v2.88h4.72v2.3Z"/>
<path class="cls-1" d="M119.92,40.54h-3.26l-1.96-3.24c-.15-.25-.29-.47-.42-.66s-.27-.36-.41-.5-.28-.24-.43-.32c-.15-.07-.31-.11-.49-.11h-.76v4.83h-2.84v-12.6h4.5c3.06,0,4.59,1.14,4.59,3.43,0,.44-.07.85-.2,1.22s-.33.71-.57,1.01-.54.56-.89.77-.74.39-1.16.51v.04c.19.06.37.15.54.29s.35.29.51.46.32.36.47.57c.15.2.29.4.41.59l2.38,3.73ZM112.18,30.07v3.51h1.23c.61,0,1.1-.18,1.47-.53.38-.36.56-.8.56-1.33,0-1.1-.66-1.65-1.98-1.65h-1.28Z"/>
<path class="cls-1" d="M131.71,27.94l-4.34,12.6h-3.22l-4.29-12.6h3.06l2.63,8.77c.14.47.23.89.25,1.26h.05c.04-.39.13-.82.27-1.29l2.61-8.74h2.97Z"/>
<path class="cls-1" d="M144.43,40.54h-3.09l-.9-2.8h-4.48l-.89,2.8h-3.08l4.59-12.6h3.37l4.48,12.6ZM139.79,35.56l-1.35-4.24c-.1-.32-.17-.69-.21-1.13h-.07c-.03.37-.1.74-.22,1.1l-1.37,4.27h3.23Z"/>
<path class="cls-1" d="M154.98,30.25h-3.59v10.29h-2.85v-10.29h-3.58v-2.31h10.02v2.31Z"/>
</g>
<g>
<path class="cls-1" d="M18.5,151.08h-2.55l-4.62-7.04c-.27-.41-.46-.72-.56-.93h-.03c.04.4.06,1,.06,1.81v6.16h-2.38v-11.2h2.72l4.45,6.82c.2.31.39.61.56.91h.03c-.04-.26-.06-.77-.06-1.54v-6.2h2.38v11.2Z"/>
<path class="cls-1" d="M31.03,151.08h-2.75l-.8-2.49h-3.98l-.79,2.49h-2.73l4.08-11.2h2.99l3.98,11.2ZM26.9,146.65l-1.2-3.77c-.09-.28-.15-.62-.19-1.01h-.06c-.03.33-.09.65-.2.98l-1.22,3.8h2.87Z"/>
<path class="cls-1" d="M40.4,141.93h-3.2v9.15h-2.53v-9.15h-3.18v-2.05h8.91v2.05Z"/>
<path class="cls-1" d="M51.16,146.23c0,3.37-1.57,5.05-4.7,5.05s-4.56-1.64-4.56-4.93v-6.47h2.53v6.5c0,1.81.71,2.72,2.12,2.72s2.09-.88,2.09-2.62v-6.59h2.52v6.35Z"/>
<path class="cls-1" d="M63.1,151.08h-2.9l-1.74-2.88c-.13-.22-.26-.41-.38-.59-.12-.17-.24-.32-.36-.44-.12-.12-.25-.22-.38-.28-.13-.06-.28-.1-.43-.1h-.68v4.29h-2.52v-11.2h4c2.72,0,4.08,1.02,4.08,3.05,0,.39-.06.75-.18,1.08-.12.33-.29.63-.51.89s-.48.5-.79.69c-.31.19-.66.34-1.04.45v.03c.17.05.33.14.48.25s.31.25.45.41c.15.16.29.32.42.5s.25.36.36.53l2.12,3.31ZM56.23,141.76v3.12h1.09c.54,0,.98-.16,1.3-.47.33-.32.5-.71.5-1.18,0-.98-.59-1.47-1.76-1.47h-1.14Z"/>
<path class="cls-1" d="M73.59,139.87l-3.86,11.2h-2.86l-3.81-11.2h2.72l2.34,7.8c.12.42.2.79.23,1.12h.05c.04-.35.12-.73.24-1.15l2.32-7.77h2.64Z"/>
<path class="cls-1" d="M84.9,151.08h-2.75l-.8-2.49h-3.98l-.79,2.49h-2.73l4.08-11.2h2.99l3.98,11.2ZM77.57,138.83c-.35,0-.64-.1-.87-.31-.22-.21-.34-.46-.34-.76s.11-.57.34-.77c.23-.2.52-.3.86-.3s.64.1.86.3c.22.2.33.46.33.77s-.11.58-.33.77c-.22.2-.51.3-.86.3ZM80.77,146.65l-1.2-3.77c-.09-.28-.15-.62-.19-1.01h-.06c-.03.33-.09.65-.2.98l-1.22,3.8h2.87ZM81.15,138.83c-.35,0-.64-.1-.86-.3-.22-.2-.34-.46-.34-.77s.11-.58.34-.77c.22-.2.51-.3.86-.3s.64.1.86.3c.23.2.34.46.34.77s-.11.57-.34.77c-.22.2-.51.3-.87.3Z"/>
<path class="cls-1" d="M95.81,151.08h-2.9l-1.74-2.88c-.13-.22-.25-.41-.38-.59s-.24-.32-.36-.44c-.12-.12-.25-.22-.38-.28-.13-.06-.28-.1-.43-.1h-.68v4.29h-2.52v-11.2h4c2.72,0,4.08,1.02,4.08,3.05,0,.39-.06.75-.18,1.08-.12.33-.29.63-.51.89s-.48.5-.79.69c-.31.19-.66.34-1.04.45v.03c.17.05.33.14.48.25s.31.25.45.41.29.32.42.5.25.36.36.53l2.12,3.31ZM88.93,141.76v3.12h1.09c.54,0,.98-.16,1.3-.47.33-.32.5-.71.5-1.18,0-.98-.59-1.47-1.76-1.47h-1.14Z"/>
<path class="cls-1" d="M96.97,151.08v-11.2h3.97c3.98,0,5.97,1.82,5.97,5.46,0,1.75-.54,3.14-1.63,4.18-1.09,1.04-2.53,1.56-4.34,1.56h-3.97ZM99.49,141.93v7.1h1.25c1.09,0,1.95-.33,2.57-.98s.93-1.55.93-2.68c0-1.07-.31-1.91-.93-2.52-.62-.61-1.48-.92-2.6-.92h-1.23Z"/>
<path class="cls-1" d="M118.73,151.08h-2.75l-.8-2.49h-3.98l-.79,2.49h-2.73l4.08-11.2h2.99l3.98,11.2ZM114.6,146.65l-1.2-3.77c-.09-.28-.15-.62-.19-1.01h-.06c-.03.33-.09.65-.2.98l-1.22,3.8h2.87Z"/>
<path class="cls-1" d="M129.64,151.08h-2.9l-1.74-2.88c-.13-.22-.25-.41-.38-.59s-.24-.32-.36-.44c-.12-.12-.25-.22-.38-.28-.13-.06-.28-.1-.43-.1h-.68v4.29h-2.52v-11.2h4c2.72,0,4.08,1.02,4.08,3.05,0,.39-.06.75-.18,1.08-.12.33-.29.63-.51.89s-.48.5-.79.69c-.31.19-.66.34-1.04.45v.03c.17.05.33.14.48.25s.31.25.45.41.29.32.42.5.25.36.36.53l2.12,3.31ZM122.76,141.76v3.12h1.09c.54,0,.98-.16,1.3-.47.33-.32.5-.71.5-1.18,0-.98-.59-1.47-1.76-1.47h-1.14Z"/>
<path class="cls-1" d="M140.87,151.08h-2.55l-4.62-7.04c-.27-.41-.46-.72-.56-.93h-.03c.04.4.06,1,.06,1.81v6.16h-2.38v-11.2h2.72l4.45,6.82c.2.31.39.61.56.91h.03c-.04-.26-.06-.77-.06-1.54v-6.2h2.38v11.2Z"/>
<path class="cls-1" d="M153.4,151.08h-2.75l-.8-2.49h-3.98l-.79,2.49h-2.73l4.08-11.2h2.99l3.98,11.2ZM149.27,146.65l-1.2-3.77c-.09-.28-.15-.62-.19-1.01h-.06c-.03.33-.09.65-.2.98l-1.22,3.8h2.87Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+38
View File
@@ -0,0 +1,38 @@
{
"id": "/",
"name": "Naturvärdarna Kullaberg",
"short_name": "Naturvärdarna",
"description": "Intern app för naturvärdarna på Kullaberg",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#5e6545",
"theme_color": "#5e6545",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}