7 Commits
93 changed files with 42808 additions and 8886 deletions
-38
View File
@@ -1,38 +0,0 @@
# Ignore the heavy PDFs during build
public/files/*
# 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
+20 -55
View File
@@ -1,61 +1,26 @@
*.csv
# Logs
generated/ logs
# SQLite Database & Docker Volume files *.log
*.db
*.db-journal
*.sqlite
*.sqlite3
# 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* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
.pnpm-debug.log* pnpm-debug.log*
lerna-debug.log*
# env files (can opt-in for committing if needed) node_modules
.env* dist
dist-ssr
*.local
# vercel # Editor directories and files
.vercel .vscode/*
!.vscode/extensions.json
# typescript .idea
*.tsbuildinfo .DS_Store
next-env.d.ts *.suo
*.ntvs*
/app/generated/prisma *.njsproj
*.sln
*.sw?
-24
View File
@@ -1,24 +0,0 @@
stages:
- Build
"Build Docker":
stage: Build
image: docker:24.0.5
before_script:
- echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
script:
- echo "Building Docker-image..."
- >
docker build
--label "org.opencontainers.image.url=$CI_PROJECT_URL"
--label "org.opencontainers.image.source=$CI_PROJECT_URL"
-t $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG
-t $CI_REGISTRY_IMAGE:latest .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG
- docker push $CI_REGISTRY_IMAGE:latest
rules:
- if: $CI_COMMIT_TAG
-38
View File
@@ -1,38 +0,0 @@
# 1. Install dependencies
FROM node:25-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package*.json ./
RUN npm ci
# 2. Build the app
FROM node:25-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV DATABASE_URL="file:./dummy.db"
RUN npx prisma generate
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# 3. Production image (Slimmad!)
FROM node:25-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN mkdir -p data
RUN mkdir -p public/files
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
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"]
+72 -2
View File
@@ -1,3 +1,73 @@
# Naturvärdarna PWA # React + TypeScript + Vite
Källkod för [app.naturvardarna.com](https://app.naturvardarna.com/) This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
-169
View File
@@ -1,169 +0,0 @@
// 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
}
}
}
});
}
export async function getDailyLogsDb() {
noStore();
return await prisma.dailyLog.findMany();
}
// ==========================================
// 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 };
}
// ==========================================
// 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 & LOG 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 }[]) {
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 };
}
export async function setDailyLogDb(date: string, content: string) {
await prisma.dailyLog.upsert({
where: { date },
update: { content },
create: { date, content }
});
return { success: true };
}
// ==========================================
// 5. AUTHENTICATION ACTIONS
// ==========================================
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);
} else if (action.type === 'SET_DAILY_LOG') {
await setDailyLogDb(action.payload.date, action.payload.content);
}
}
return { success: true };
}
-37
View File
@@ -1,37 +0,0 @@
// app/actions/files.ts
'use server';
import crypto from 'crypto';
import fs from 'fs';
import fsPromises from 'fs/promises';
import path from 'path';
const isProd = process.env.NODE_ENV === "production";
const DATA_DIR = isProd ? "/app/data" : path.join(process.cwd(), "app/data");
const FILES_DIR = path.join(DATA_DIR, "files");
const generateFileHash = (filePath: string): Promise<string> => {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('md5');
const stream = fs.createReadStream(filePath);
stream.on('error', (err) => reject(err));
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex').substring(0, 8)));
});
};
export async function getLocalFileMeta(fileUrl: string) {
try {
const cleanName = decodeURIComponent(fileUrl.split('/').pop() || '');
const fullPath = path.join(FILES_DIR, cleanName);
const stats = await fsPromises.stat(fullPath);
const sizeMb = (stats.size / (1024 * 1024)).toFixed(2) + ' MB';
const fileHash = await generateFileHash(fullPath);
return { size: sizeMb, version: fileHash };
} catch (error) {
console.error(`Kunde inte läsa filen (actions): ${fileUrl}`, error);
return { size: "Okänd", version: "v1" };
}
}
-33
View File
@@ -1,33 +0,0 @@
// app/actions/jsonEditor.ts
"use server";
import fs from 'fs/promises';
import path from 'path';
const isProd = process.env.NODE_ENV === "production";
const DATA_DIR = isProd ? "/app/data" : path.join(process.cwd(), "app/data");
export async function readJsonFile(filename: string) {
try {
const filePath = path.join(DATA_DIR, filename);
const fileContent = await fs.readFile(filePath, 'utf-8');
return { success: true, data: JSON.parse(fileContent) };
} catch (error) {
console.error(`Kunde inte läsa ${filename}:`, error);
return { success: false, data: null };
}
}
export async function writeJsonFile(filename: string, data: any) {
try {
const filePath = path.join(DATA_DIR, filename);
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.writeFile(filePath, JSON.stringify(data, null, 4), 'utf-8');
return { success: true };
} catch (error) {
console.error(`Kunde inte spara ${filename}:`, error);
return { success: false, error: "Kunde inte spara filen." };
}
}
-171
View File
@@ -1,171 +0,0 @@
// app/admin/AttendanceTab.tsx
"use client";
import { CalendarRange, CheckCircle, ChevronLeft, ChevronRight } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import { type AttendanceDataMap, getAttendanceKey, type Period, toIsoDate } from './adminTypes';
import { TeamAttendanceCard } from './TeamAttendanceCard';
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;
scheduleData: any[];
}
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, scheduleData: any[]) => {
const dayNameStr = new Date(date + 'T12:00:00').toLocaleDateString('sv-SE', { weekday: 'long' }).toLowerCase();
const daySchedule = scheduleData.find(d => d.day.toLowerCase() === dayNameStr);
if (!daySchedule) return { expected: 0, completed: 0, isComplete: true, hasWork: false };
let expected = 0; let completed = 0; let hasWork = false;
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, scheduleData }) => {
const activePeriod = periods.find(p => p.id === activePeriodId);
const [currentDate, setCurrentDate] = useState<string>(() => {
const today = toIsoDate(new Date());
if (activePeriod && today >= activePeriod.startDate && today <= activePeriod.endDate) return today;
return activePeriod ? activePeriod.startDate : today;
});
useEffect(() => {
if (activePeriod) {
setCurrentDate(prevDate => {
if (prevDate >= activePeriod.startDate && prevDate <= activePeriod.endDate) {
return prevDate;
}
const today = toIsoDate(new Date());
if (today >= activePeriod.startDate && today <= activePeriod.endDate) return today;
return 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, scheduleData);
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">
<div className="rounded-3xl shadow-sm transition-all duration-300 bg-white/40 backdrop-blur-md border border-white/40 p-3 sm:p-5 hover:shadow-md">
<div className="flex justify-between items-center mb-3">
<h2 className="text-sm font-black text-ebony uppercase tracking-widest flex items-center mb-2">
<CalendarRange className="mr-2 text-slate-teal" size={20} /> Periodöversikt
</h2>
<select value={activePeriodId} onChange={(e) => setActivePeriodId(e.target.value)} className="bg-white border border-slate-teal/10 py-1.5 px-3 rounded-xl font-bold text-slate-teal text-xs cursor-pointer focus:outline-none shadow-sm">
{periods.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
<div className="flex overflow-x-auto gap-2.5 pb-3 pt-1 px-2 scrollbar-hide">
{timelineDays.map(day => {
const stats = getDailyCompletionStats(day, activePeriod, attendance, scheduleData);
const isSelected = day === currentDate;
let bgClass = "bg-white text-ebony border-slate-teal/10";
if (!stats.hasWork) bgClass = "bg-slate-teal/5 text-ebony/40 border-transparent";
else if (stats.isComplete) bgClass = "bg-moss text-white border-moss shadow-inner";
else if (day < todayIso || day === todayIso) bgClass = "bg-goldenrod text-white border-goldenrod shadow-inner";
return (
<button key={day} onClick={() => setCurrentDate(day)} className={`flex flex-col items-center justify-center min-w-14 p-2 rounded-xl border transition-colors ${bgClass} ${isSelected ? 'ring-2 ring-slate-teal ring-offset-2 ring-offset-eggshell' : 'hover:brightness-95 shadow-sm'}`}>
<span className="text-[10px] font-black uppercase tracking-wider">{new Date(day + 'T12:00:00').toLocaleDateString('sv-SE', { weekday: 'short' })}</span>
<span className="text-sm font-black">{new Date(day + 'T12:00:00').getDate()}/{new Date(day + 'T12:00:00').getMonth() + 1}</span>
</button>
);
})}
</div>
</div>
<div className="flex flex-row justify-between items-center rounded-3xl shadow-sm transition-all duration-300 bg-white/40 backdrop-blur-md border border-white/40 p-3 sm:p-5 hover:shadow-md gap-4">
<button onClick={() => changeDate(-1)} className="p-3 text-slate-teal bg-slate-teal/10 hover:bg-slate-teal/30 rounded-2xl transition-colors"><ChevronLeft size={24} /></button>
<div className="text-center flex-1">
<h2 className="text-lg font-black text-ebony capitalize mb-0.5">{dayNameStr}</h2>
<p className="text-[11px] font-black uppercase tracking-widest text-moss">{currentDate}</p>
</div>
<button onClick={() => changeDate(1)} className="p-3 text-slate-teal bg-slate-teal/10 hover:bg-slate-teal/30 rounded-2xl transition-colors"><ChevronRight size={24} /></button>
</div>
{currentDayStats.hasWork && (
<div className={`px-5 py-3 rounded-2xl font-black text-xs uppercase tracking-widest flex items-center justify-center shadow-sm border transition-colors ${currentDayStats.isComplete ? 'bg-moss/10 text-moss border-moss/20' : 'bg-goldenrod/10 text-goldenrod border-goldenrod/20'}`}>
{currentDayStats.isComplete ? <><CheckCircle size={16} className="mr-2" /> All närvaro rapporterad</> : `Ifyllt: ${currentDayStats.completed} av ${currentDayStats.expected} pers`}
</div>
)}
{!daySchedule || !currentDayStats.hasWork ? (
<div className="bg-eggshell border border-slate-teal/10 p-8 rounded-3xl text-center shadow-sm">
<p className="font-black text-sm 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;
return (
<TeamAttendanceCard
key={team}
team={team}
currentDate={currentDate}
shiftId={shiftId}
standardTime={standardTime}
periodYouthList={activePeriod.youthList}
attendance={attendance}
setManualAttendance={setManualAttendance}
bulkSetManualAttendance={bulkSetManualAttendance}
addPendingAttendance={addPendingAttendance}
removeAttendanceEntry={removeAttendanceEntry}
/>
);
})
)}
</div>
);
};
-173
View File
@@ -1,173 +0,0 @@
// app/admin/LogTab.tsx
"use client";
import { NotebookText, CalendarRange, CheckCircle, ChevronLeft, ChevronRight, Save, AlertTriangle, FileText, Download } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import { type Period, toIsoDate } from './adminTypes';
interface Props {
periods: Period[];
dailyLogs: Record<string, string>;
setDailyLog: (date: string, content: string) => void;
activePeriodId: string;
setActivePeriodId: (id: string) => void;
scheduleData: any[];
}
const getDaysInPeriod = (start: string, end: string) => {
const days = []; let curr = new Date(start + 'T12:00:00'); const endDate = new Date(end + 'T12:00:00');
while (curr <= endDate) { days.push(toIsoDate(curr)); curr.setDate(curr.getDate() + 1); }
return days;
};
export const LogTab: React.FC<Props> = ({ periods, dailyLogs, setDailyLog, activePeriodId, setActivePeriodId, scheduleData }) => {
const activePeriod = periods.find(p => p.id === activePeriodId);
const [currentDate, setCurrentDate] = useState<string>(() => {
const today = toIsoDate(new Date());
if (activePeriod && today >= activePeriod.startDate && today <= activePeriod.endDate) return today;
return activePeriod ? activePeriod.startDate : today;
});
const [currentText, setCurrentText] = useState("");
const [saveStatus, setSaveStatus] = useState<'idle' | 'saved'>('idle');
useEffect(() => {
setCurrentText(dailyLogs[currentDate] || "");
setSaveStatus('idle');
}, [currentDate]);
useEffect(() => {
if (saveStatus === 'idle' && currentText === (dailyLogs[currentDate] || "")) {
setCurrentText(dailyLogs[currentDate] || "");
}
}, [dailyLogs, currentDate]);
if (!activePeriod) return <p className="text-center font-bold text-slate-teal mt-10">Ingen period aktiv.</p>;
const handleSave = () => {
setDailyLog(currentDate, currentText);
setSaveStatus('saved');
setTimeout(() => setSaveStatus('idle'), 2000);
};
const changeDate = (days: number) => {
const newDateObj = new Date(currentDate + 'T12:00:00'); newDateObj.setDate(newDateObj.getDate() + days);
const startObj = new Date(activePeriod.startDate + 'T12:00:00'); const endObj = new Date(activePeriod.endDate + 'T12:00:00');
if (newDateObj >= startObj && newDateObj <= endObj) setCurrentDate(toIsoDate(newDateObj));
};
const dayNameStr = new Date(currentDate + 'T12:00:00').toLocaleDateString('sv-SE', { weekday: 'long' });
const timelineDays = getDaysInPeriod(activePeriod.startDate, activePeriod.endDate);
const todayIso = toIsoDate(new Date());
const exportToCsv = () => {
if (!activePeriod) return;
let csvContent = "Datum;Logg\n";
const sortedDays = Object.keys(dailyLogs).sort((a, b) => a.localeCompare(b));
sortedDays.forEach(date => {
if (date >= activePeriod.startDate && date <= activePeriod.endDate) {
const logContent = dailyLogs[date] || "";
const cleanContent = logContent
.replace(/\n/g, " ")
.replace(/;/g, ",")
.replace(/"/g, '""');
csvContent += `${date};"${cleanContent}"\n`;
}
});
const blob = new Blob(["\uFEFF" + csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.setAttribute("href", url);
link.setAttribute("download", `Logg_${activePeriod.name.replace(/ /g, '_')}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
return (
<div className="space-y-6 animate-fade-in">
{/* Datumskrollare likt AttendanceTab */}
<div className="rounded-3xl shadow-sm transition-all duration-300 bg-white/40 backdrop-blur-md border border-white/40 p-3 sm:p-5 hover:shadow-md">
<div className="flex justify-between items-center mb-3">
<h2 className="text-sm font-black text-ebony uppercase tracking-widest flex items-center mb-2">
<CalendarRange className="mr-2 text-slate-teal" size={20} /> Journalöversikt
</h2>
<select value={activePeriodId} onChange={(e) => setActivePeriodId(e.target.value)} className="bg-white border border-slate-teal/10 py-1.5 px-3 rounded-xl font-bold text-slate-teal text-xs cursor-pointer focus:outline-none shadow-sm">
{periods.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
<div className="flex overflow-x-auto gap-2.5 pb-3 pt-1 px-2 scrollbar-hide">
{timelineDays.map(day => {
const dayName = new Date(day + 'T12:00:00').toLocaleDateString('sv-SE', { weekday: 'long' }).toLowerCase();
const daySchedule = scheduleData.find(d => d.day.toLowerCase() === dayName);
const hasWork = daySchedule && ((daySchedule.pilgrimsfalkarna?.time && daySchedule.pilgrimsfalkarna.time !== 'Ledig') || (daySchedule.tumlarna?.time && daySchedule.tumlarna.time !== 'Ledig'));
const hasLog = !!dailyLogs[day] && dailyLogs[day].trim() !== "";
const isSelected = day === currentDate;
let bgClass = "bg-white text-ebony border-slate-teal/10";
if (!hasWork) bgClass = "bg-slate-teal/5 text-ebony/40 border-transparent";
else if (hasLog) bgClass = "bg-moss text-white border-moss shadow-inner";
else if (day <= todayIso) bgClass = "bg-goldenrod text-white border-goldenrod shadow-inner";
return (
<button key={day} onClick={() => setCurrentDate(day)} className={`flex flex-col items-center justify-center min-w-14 p-2 rounded-xl border transition-colors ${bgClass} ${isSelected ? 'ring-2 ring-slate-teal ring-offset-2 ring-offset-eggshell' : 'hover:brightness-95 shadow-sm'}`}>
<span className="text-[10px] font-black uppercase tracking-wider">{new Date(day + 'T12:00:00').toLocaleDateString('sv-SE', { weekday: 'short' })}</span>
<span className="text-sm font-black">{new Date(day + 'T12:00:00').getDate()}/{new Date(day + 'T12:00:00').getMonth() + 1}</span>
</button>
);
})}
</div>
</div>
<div className="flex flex-row justify-between items-center rounded-3xl shadow-sm transition-all duration-300 bg-white/40 backdrop-blur-md border border-white/40 p-3 sm:p-5 hover:shadow-md gap-4">
<button onClick={() => changeDate(-1)} className="p-3 text-slate-teal bg-slate-teal/10 hover:bg-slate-teal/30 rounded-2xl transition-colors"><ChevronLeft size={24} /></button>
<div className="text-center flex-1">
<h2 className="text-lg font-black text-ebony capitalize mb-0.5">{dayNameStr}</h2>
<p className="text-[11px] font-black uppercase tracking-widest text-moss">{currentDate}</p>
</div>
<button onClick={() => changeDate(1)} className="p-3 text-slate-teal bg-slate-teal/10 hover:bg-slate-teal/30 rounded-2xl transition-colors"><ChevronRight size={24} /></button>
</div>
{/* Skrivyta */}
<div className="rounded-3xl shadow-sm transition-all duration-300 bg-white/40 backdrop-blur-md border border-white/40 p-3 sm:p-5 hover:shadow-md">
<h2 className="text-xl font-black text-ebony uppercase tracking-widest flex items-center mb-6">
<div className="bg-white p-2.5 rounded-xl mr-3 text-slate-teal shadow-sm">
<NotebookText size={24} />
</div>
Daglig Journal
</h2>
<textarea
value={currentText}
onChange={(e) => setCurrentText(e.target.value)}
placeholder="Vad har hänt idag? Något trasigt staket? Spännande djurobservation? Sur turist?"
className="w-full h-48 bg-white border border-slate-teal/10 p-4 rounded-2xl text-sm font-medium resize-none focus:outline-none focus:border-slate-teal shadow-sm mb-6"
/>
<div className="flex items-center justify-between">
<div>
{saveStatus === 'saved' && <span className="text-xs font-bold text-moss flex items-center"><CheckCircle size={14} className="mr-1" /> Sparat!</span>}
{dailyLogs[currentDate] && currentText !== dailyLogs[currentDate] && <span className="text-xs font-bold text-goldenrod flex items-center"><AlertTriangle size={14} className="mr-1" /> Osparade ändringar</span>}
</div>
<button
onClick={handleSave}
className="bg-slate-teal text-eggshell font-black uppercase tracking-widest text-xs px-6 py-3 rounded-xl hover:bg-ebony transition-colors shadow-sm flex items-center gap-2"
>
<Save size={16} /> Spara
</button>
</div>
</div>
<div className="flex flex-row justify-center items-center p-3 sm:p-5">
<button onClick={exportToCsv} className="flex items-center justify-center gap-2 bg-seafoam text-eggshell font-black uppercase tracking-widest text-[10px] px-5 py-3 rounded-xl hover:bg-slate-teal transition-colors shadow-sm">
<Download size={16} /> Exportera Journal till Excel
</button>
</div>
</div>
);
};
-164
View File
@@ -1,164 +0,0 @@
// app/admin/NoticeTab.tsx
"use client";
import { AlertCircle, AlertTriangle, BellRing, Info, Loader2, Save, CheckCircle } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import { readJsonFile, writeJsonFile } from '../actions/jsonEditor';
interface NoticeData {
isActive: boolean;
type: 'notice' | 'warning' | 'important';
message: string;
}
export const NoticeTab = ({ isOffline }: { isOffline: boolean }) => {
const [notice, setNotice] = useState<NoticeData>({ isActive: false, type: 'warning', message: '' });
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle');
useEffect(() => {
let isMounted = true;
const fetchNotice = async () => {
const cached = localStorage.getItem('admin_notice_cache');
if (cached) {
setNotice(JSON.parse(cached));
setIsLoading(false);
}
const fetchWithTimeout = new Promise<any>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Timeout')), 5000);
readJsonFile('notice.json').then(res => {
clearTimeout(timer);
resolve(res);
}).catch(err => {
clearTimeout(timer);
reject(err);
});
});
try {
if (navigator.onLine) {
const res = await fetchWithTimeout;
if (res.success && res.data && isMounted) {
setNotice(res.data);
localStorage.setItem('admin_notice_cache', JSON.stringify(res.data));
}
}
} catch (error) {
console.warn("Kunde inte hämta färsk notice.json (Liar-Fi), använder cache.");
} finally {
if (isMounted) setIsLoading(false);
}
};
fetchNotice();
return () => { isMounted = false; };
}, []);
const handleSave = async () => {
setIsSaving(true);
setSaveStatus('idle');
try {
const saveWithTimeout = new Promise<any>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Timeout')), 8000);
writeJsonFile('notice.json', notice).then(res => {
clearTimeout(timer);
resolve(res);
}).catch(err => {
clearTimeout(timer);
reject(err);
});
});
const res = await saveWithTimeout;
if (res.success) {
setSaveStatus('success');
localStorage.setItem('admin_notice_cache', JSON.stringify(notice));
setTimeout(() => setSaveStatus('idle'), 3000);
} else {
setSaveStatus('error');
}
} catch (error) {
console.error("Liar-Fi: Sparande tog för lång tid", error);
setSaveStatus('error');
} finally {
setIsSaving(false);
}
};
if (isLoading) return <div className="flex justify-center py-10"><Loader2 className="animate-spin text-slate-teal" size={32} /></div>;
return (
<div className="space-y-6 animate-fade-in">
<div className="rounded-3xl shadow-sm transition-all duration-300 bg-white/40 backdrop-blur-md border border-white/40 p-3 sm:p-5 hover:shadow-md">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-black text-ebony uppercase tracking-widest flex items-center">
<div className="bg-white p-2.5 rounded-xl mr-3 text-slate-teal shadow-sm">
<BellRing size={24} />
</div>
Meddelande startsidan
</h2>
{/* Av/På Switch */}
<button
onClick={() => setNotice({ ...notice, isActive: !notice.isActive })}
disabled={isOffline}
className={`relative inline-flex h-7 w-12 shrink-0 items-center rounded-full transition-colors focus:outline-none shadow-inner disabled:opacity-50 ${notice.isActive ? 'bg-moss' : 'bg-slate-teal/20'}`}
>
<span className={`inline-block h-5 w-5 transform rounded-full bg-white transition-transform shadow-sm ${notice.isActive ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
<div className={`transition-all duration-300 ${!notice.isActive ? 'opacity-40 grayscale pointer-events-none' : ''}`}>
<div className="space-y-6">
<div>
<label className="block text-sm font-black text-ebony/60 uppercase tracking-widest mb-2">Typ av meddelande</label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<button onClick={() => setNotice({ ...notice, type: 'notice' })} className={`flex items-center p-3 rounded-xl border-2 transition-all font-bold text-sm ${notice.type === 'notice' ? 'bg-seafoam/10 border-seafoam text-slate-teal' : 'bg-white border-transparent text-ebony/60 hover:bg-white/80 shadow-sm'}`}>
<Info size={18} className="mr-2" /> Information
</button>
<button onClick={() => setNotice({ ...notice, type: 'warning' })} className={`flex items-center p-3 rounded-xl border-2 transition-all font-bold text-sm ${notice.type === 'warning' ? 'bg-goldenrod/10 border-goldenrod text-goldenrod' : 'bg-white border-transparent text-ebony/60 hover:bg-white/80 shadow-sm'}`}>
<AlertTriangle size={18} className="mr-2" /> Varning
</button>
<button onClick={() => setNotice({ ...notice, type: 'important' })} className={`flex items-center p-3 rounded-xl border-2 transition-all font-bold text-sm ${notice.type === 'important' ? 'bg-emergency/10 border-emergency text-emergency' : 'bg-white border-transparent text-ebony/60 hover:bg-white/80 shadow-sm'}`}>
<AlertCircle size={18} className="mr-2" /> Akut / Viktigt
</button>
</div>
</div>
<div>
<label className="block text-sm font-black text-ebony/60 uppercase tracking-widest mb-2">Text</label>
<textarea
value={notice.message}
onChange={(e) => setNotice({ ...notice, message: e.target.value })}
placeholder="Skriv ditt meddelande här..."
className="w-full h-32 bg-white border border-slate-teal/10 p-4 rounded-2xl text-sm font-bold resize-none focus:outline-none focus:border-slate-teal shadow-sm"
/>
</div>
</div>
</div>
<div className="mt-8 flex items-center justify-between border-t border-slate-teal/10 pt-6">
<p className="text-xs font-bold text-slate-teal/60">
{saveStatus === 'success' && <span className="text-moss flex items-center"><CheckCircle size={14} className="mr-1" /> Sparat och live!</span>}
{saveStatus === 'error' && <span className="text-emergency flex items-center"><AlertTriangle size={14} className="mr-1" /> Kunde inte spara.</span>}
{isOffline && "Du är offline. Går ej att uppdatera."}
</p>
<button
onClick={handleSave}
disabled={isOffline || isSaving}
className="bg-slate-teal text-eggshell font-black uppercase tracking-widest text-xs px-6 py-3 rounded-xl hover:bg-ebony transition-colors disabled:opacity-50 shadow-sm flex items-center gap-2"
>
{isSaving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
Spara ändringar
</button>
</div>
</div>
</div>
);
};
-120
View File
@@ -1,120 +0,0 @@
// app/admin/ReportTab.tsx
"use client";
import { Download, Users } from 'lucide-react';
import React, { useState } from 'react';
import { type AttendanceDataMap, formatTimeHHMM, isWeekend, type Period, type Role } from './adminTypes';
import { YouthReportCard } from './YouthReportCard';
interface Props {
periods: Period[]; attendance: AttendanceDataMap; activePeriodId: string; setActivePeriodId: (id: string) => void; currentUserRole: Role;
scheduleData: any[];
}
const POT_HOUR_LIMIT = 90;
export const ReportTab: React.FC<Props> = ({ periods, attendance, activePeriodId, setActivePeriodId, currentUserRole, scheduleData }) => {
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 shift = isWeekend(entry.date) ? '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">
<div className="flex flex-col md:flex-row justify-between items-center gap-4 rounded-3xl shadow-sm transition-all duration-300 bg-white/40 backdrop-blur-md border border-white/40 p-3 sm:p-5 hover:shadow-md">
<select value={activePeriodId} onChange={(e) => { setActivePeriodId(e.target.value); setExpandedYouthId(null); }} className="bg-white border border-slate-teal/10 p-3 rounded-xl font-bold text-slate-teal text-sm w-full md:w-auto focus:outline-none shadow-sm">
{periods.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
<div className="text-center md:text-right">
<h2 className="text-base font-black text-ebony uppercase tracking-widest">{activePeriod.name}</h2>
<p className="text-xs font-bold text-moss">{activePeriod.startDate} {activePeriod.endDate}</p>
</div>
</div>
<div>
<div className="flex flex-col md:flex-row justify-between md:items-center mb-6 gap-3">
<h2 className="font-black text-ebony uppercase tracking-widest flex items-center text-2xl justify-center sm:justify-normal">
<Users className="mr-2 text-slate-teal" size={24} /> Timrapport
</h2>
{currentUserRole !== 'Viewer' && (
<button onClick={exportToCSV} className="flex items-center justify-center gap-2 bg-seafoam text-eggshell font-black uppercase tracking-widest text-[10px] px-5 py-3 rounded-xl hover:bg-slate-teal transition-colors shadow-sm">
<Download size={16} /> Exportera till Excel
</button>
)}
</div>
<div className="space-y-3">
{sortedActiveYouth.map(youth => (
<YouthReportCard
key={youth.id}
youth={youth}
totalHours={getPeriodHoursTotal(youth.id)}
potHourLimit={POT_HOUR_LIMIT}
isExpanded={expandedYouthId === youth.id}
onToggle={() => setExpandedYouthId(expandedYouthId === youth.id ? null : youth.id)}
timeline={getTimelineForYouth(youth.id)}
scheduleData={scheduleData}
/>
))}
</div>
</div>
</div>
);
};
-171
View File
@@ -1,171 +0,0 @@
// app/admin/SetupTab.tsx
"use client";
import { CalendarPlus, 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?')) 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!"); 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('');
}
};
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-xl shadow-sm hover:shadow-md transition-shadow">
<div className="flex items-center gap-3">
<div className={`flex items-center justify-center w-8 h-8 rounded-xl ${youth.team === 'PF' ? 'bg-gold' : 'bg-seafoam'} shrink-0 shadow-inner`}>
<Image src={youth.team === 'PF' ? falconIcon : porpoiseIcon} alt={youth.team === 'PF' ? 'PF' : 'TU'} width={16} height={16} />
</div>
<span className="font-bold text-ebony text-base">{youth.name}</span>
</div>
<button onClick={() => handleRemoveYouth(periodId, youth.id, youth.name)} className={`p-2 transition-colors rounded-lg ${isOffline ? 'text-goldenrod/40 cursor-not-allowed' : 'text-goldenrod hover:bg-emergency/10 hover:text-emergency'}`}>
<Trash2 size={18} />
</button>
</div>
))}
</div>
);
};
return (
<div className="space-y-6 animate-fade-in">
<div className="rounded-3xl shadow-sm transition-all duration-300 bg-white/40 backdrop-blur-md border border-white/40 p-3 sm:p-5 hover:shadow-md">
<h2 className="text-sm font-black text-ebony uppercase tracking-widest flex items-center mb-4">
<CalendarPlus className="mr-2 text-slate-teal" size={20} /> Skapa Ny Period
</h2>
{/* Changed from Grid to Flex */}
<div className="flex flex-col md:flex-row gap-4 w-full">
{/* Wrapping inputs in flex-1 min-w-0 forces Safari to respect the bounds */}
<div className="flex-1 min-w-0">
<input
type="text"
id="periodName"
placeholder="T.ex. Period 1"
className="w-full appearance-none bg-white border border-slate-teal/10 p-3 rounded-xl text-sm font-bold focus:outline-none focus:border-slate-teal shadow-sm"
disabled={isOffline}
/>
</div>
<div className="flex-1 min-w-0">
<input
type="date"
id="periodStart"
className="w-full appearance-none bg-white border border-slate-teal/10 p-3 rounded-xl text-sm font-bold focus:outline-none focus:border-slate-teal shadow-sm"
disabled={isOffline}
/>
</div>
<button
onClick={handleCreate}
disabled={isOffline}
className="w-full md:w-auto md:px-8 shrink-0 bg-slate-teal text-eggshell font-black uppercase tracking-widest text-xs py-3 rounded-xl hover:bg-ebony transition-colors disabled:opacity-50 shadow-sm"
>
Starta
</button>
</div>
</div>
{periods.map(period => {
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="rounded-3xl shadow-sm transition-all duration-300 bg-white/40 backdrop-blur-md border border-white/40 hover:shadow-md overflow-hidden">
<div className="p-5 flex justify-between items-center cursor-pointer hover:bg-moss/10 transition-colors" onClick={() => setExpandedPeriod(expandedPeriod === period.id ? null : period.id)}>
<div>
<h3 className="text-xl font-black text-ebony uppercase tracking-wide">{period.name}</h3>
<p className="text-xs font-bold text-moss mt-1">{period.startDate} till {period.endDate} {period.youthList.length} ungdomar</p>
</div>
<button onClick={(e) => { e.stopPropagation(); handleDeletePeriod(period.id); }} className={`p-2.5 rounded-xl transition-colors ${isOffline ? 'text-goldenrod/40 cursor-not-allowed' : 'text-goldenrod bg-white shadow-sm hover:bg-emergency hover:text-white'}`}>
<Trash2 size={20} />
</button>
</div>
{expandedPeriod === period.id && (
<div className="p-5 border-t border-slate-teal/10 bg-moss/10">
<h4 className="font-black text-slate-teal mb-4 flex items-center uppercase tracking-widest text-xs">
<UserPlus size={16} className="mr-2" /> Bulk-lägg till ungdomar
</h4>
<div className="flex flex-col md:flex-row gap-4 mb-8">
<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-32 md:h-40 bg-white border border-slate-teal/10 p-4 rounded-2xl text-sm font-bold resize-none focus:outline-none focus:border-slate-teal shadow-sm"
disabled={isOffline}
/>
<div className="flex flex-col gap-3 shrink-0">
<select value={bulkTeam} onChange={(e) => setBulkTeam(e.target.value as 'PF' | 'TU')} className="bg-white border border-slate-teal/10 p-3 rounded-xl text-sm font-bold focus:outline-none shadow-sm" disabled={isOffline}>
<option value="PF">Standard: Pilgrimsfalk (P)</option>
<option value="TU">Standard: Tumlare (T)</option>
</select>
<button onClick={handleBulkAdd} disabled={isOffline} className="bg-seafoam text-eggshell font-black uppercase tracking-widest text-xs px-6 py-3 rounded-xl hover:bg-slate-teal transition-colors h-full disabled:opacity-50 shadow-sm">
Importera
</button>
</div>
</div>
{pfYouth.length > 0 && <h4 className="text-xs font-black text-moss uppercase tracking-widest mb-3 flex items-center"><span className="w-2.5 h-2.5 rounded-full bg-gold mr-2 shadow-sm"></span> Pilgrimsfalkarna</h4>}
{renderYouthGroup(pfYouth, period.id)}
{tuYouth.length > 0 && <h4 className="text-xs font-black text-moss uppercase tracking-widest mb-3 flex items-center"><span className="w-2.5 h-2.5 rounded-full bg-seafoam mr-2 shadow-sm"></span> Tumlarna</h4>}
{renderYouthGroup(tuYouth, period.id)}
</div>
)}
</div>
);
})}
</div>
);
};
-110
View File
@@ -1,110 +0,0 @@
// app/admin/TeamAttendanceCard.tsx
"use client";
import { ClockFading, Zap } from 'lucide-react';
import Image from 'next/image';
import React from 'react';
import falconIcon from '../assets/falcon.svg';
import porpoiseIcon from '../assets/porpoise.svg';
import { formatTimeHHMM, getAttendanceKey, calculateShiftDuration, isWeekend, type AttendanceDataMap, type Youth } from './adminTypes';
import { YouthAttendanceRow } from './YouthAttendanceRow';
interface TeamAttendanceCardProps {
team: 'PF' | 'TU';
currentDate: string;
shiftId: 'MORNING' | 'AFTERNOON';
standardTime: string;
periodYouthList: Youth[];
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;
}
export const TeamAttendanceCard: React.FC<TeamAttendanceCardProps> = ({
team,
currentDate,
shiftId,
standardTime,
periodYouthList,
attendance,
setManualAttendance,
bulkSetManualAttendance,
addPendingAttendance,
removeAttendanceEntry
}) => {
// Logic moved inside the component
const rawDuration = calculateShiftDuration(standardTime);
const isWknd = isWeekend(currentDate);
const actualDuration = isWknd ? Math.max(0, rawDuration - 0.5) : rawDuration;
const weightedDuration = actualDuration * (isWknd ? 1.5 : 1.0);
const scheduledYouth = periodYouthList.filter(y => y.team === team);
const extraYouth = periodYouthList.filter(y => y.team !== team && attendance[getAttendanceKey(currentDate, y.id, shiftId)]);
const displayYouth = [...scheduledYouth, ...extraYouth].sort((a, b) => a.name.localeCompare(b.name));
const availableExtras = periodYouthList.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, status: 'Present', hoursWorked: actualDuration, weightedHours: weightedDuration, note: '' });
}
});
if (recordsToUpdate.length > 0) bulkSetManualAttendance(recordsToUpdate);
};
const handleMarkStandard = (youthId: string) => {
if (actualDuration > 0) setManualAttendance(currentDate, youthId, shiftId, 'Present', actualDuration);
};
return (
<div className="rounded-3xl shadow-sm transition-all duration-300 bg-white/40 backdrop-blur-md border border-white/40 p-3 sm:p-5 hover:shadow-md">
<div className="flex flex-wrap justify-between items-center mb-4 gap-3">
<div className="flex items-center">
<div className={`flex items-center justify-center w-10 h-10 rounded-xl ${team === 'PF' ? 'bg-gold' : 'bg-seafoam'} mr-3 shadow-inner shrink-0`}>
<Image src={team === 'PF' ? falconIcon : porpoiseIcon} alt={team === 'PF' ? 'Pilgrimsfalk' : 'Tumlare'} width={18} height={18} />
</div>
<h2 className="text-base font-black text-ebony uppercase tracking-widest">{team === 'PF' ? 'Pilgrimsfalkarna' : 'Tumlarna'}</h2>
</div>
<div className="flex items-center gap-3 sm:w-fit w-full justify-between">
<div className="flex items-center gap-2 bg-white px-3 py-1.5 rounded-xl border border-slate-teal/5 text-xs text-ebony font-bold shadow-sm">
<ClockFading size={14} className="text-slate-teal" /> {standardTime} ({formatTimeHHMM(rawDuration)})
</div>
<button onClick={handleQuickLogAll} className="flex items-center gap-1.5 bg-moss/70 text-white hover:bg-moss hover:text-white px-3 py-1.5 rounded-xl font-black uppercase tracking-widest text-[10px] transition-colors shadow-sm">
<Zap size={14} /> Snabblog
</button>
</div>
</div>
<div className="space-y-2">
{displayYouth.map(youth => (
<YouthAttendanceRow
key={youth.id}
youth={youth}
entry={attendance[getAttendanceKey(currentDate, youth.id, shiftId)]}
currentDate={currentDate}
shiftId={shiftId}
team={team}
actualDuration={actualDuration}
onMarkStandard={() => handleMarkStandard(youth.id)}
setManualAttendance={setManualAttendance}
removeAttendanceEntry={removeAttendanceEntry}
/>
))}
{availableExtras.length > 0 && (
<div className="pt-3 mt-3 border-t border-slate-teal/5">
<select value="" onChange={(e) => { if (e.target.value) addPendingAttendance(currentDate, e.target.value, shiftId); }} className="bg-white border border-slate-teal/10 p-3 rounded-2xl font-bold text-slate-teal text-sm w-full outline-none shadow-sm">
<option value="">+ Lägg till extra person...</option>
{availableExtras.map(y => <option key={y.id} value={y.id}>{y.name}</option>)}
</select>
</div>
)}
</div>
</div>
);
};
-96
View File
@@ -1,96 +0,0 @@
// app/admin/YouthAttendanceRow.tsx
"use client";
import { CheckCircle, ClipboardCheck, Edit3, Undo } from 'lucide-react';
import Image from 'next/image';
import React from 'react';
import falconIcon from '../assets/falcon.svg';
import porpoiseIcon from '../assets/porpoise.svg';
import { formatTimeHHMM, parseTimeInput, type Youth } from './adminTypes';
interface YouthAttendanceRowProps {
youth: Youth;
entry: any;
currentDate: string;
shiftId: 'MORNING' | 'AFTERNOON';
team: 'PF' | 'TU';
actualDuration: number;
onMarkStandard: () => void;
setManualAttendance: (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON', status: 'Absent' | 'Late' | 'Present', hours: number, note?: string) => void;
removeAttendanceEntry: (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => void;
}
export const YouthAttendanceRow: React.FC<YouthAttendanceRowProps> = ({
youth,
entry,
currentDate,
shiftId,
team,
actualDuration,
onMarkStandard,
setManualAttendance,
removeAttendanceEntry
}) => {
// Logic moved inside the component
const isExtra = youth.team !== team;
const isPending = entry?.status === 'Pending';
const isCompleted = !!entry && !isPending;
return (
<div className={`flex flex-col md:flex-row md:justify-between md:items-center p-3 rounded-2xl border transition-colors ${isCompleted ? 'bg-moss/5 border-moss/20' : (isPending ? 'bg-goldenrod/5 border-goldenrod/30' : 'bg-white border-slate-teal/5 shadow-sm')}`}>
<div className="flex items-center gap-3 mb-3 md: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 bg-white"></div>}
<div className={`flex items-center justify-center w-6 h-6 rounded-full ${youth.team === 'PF' ? 'bg-gold' : 'bg-seafoam'} shrink-0 shadow-inner`}>
<Image src={youth.team === 'PF' ? falconIcon : porpoiseIcon} alt={youth.team === 'PF' ? 'PF' : 'TU'} width={12} height={12} />
</div>
<span className={`font-bold text-base ${isCompleted ? 'text-moss' : 'text-ebony'}`}>
{youth.name} {isExtra && <span className="ml-2 text-[10px] text-slate-teal bg-slate-teal/10 px-2 py-0.5 rounded-lg uppercase tracking-widest">Extra pass</span>}
</span>
</div>
<div className="flex items-center justify-end gap-3 w-full md:w-auto flex-wrap">
{isPending && <span className="text-[10px] font-black uppercase tracking-widest bg-goldenrod/10 text-goldenrod px-3 py-1.5 rounded-xl">Väntar...</span>}
{isCompleted && (
<span className="text-xs font-black uppercase tracking-widest bg-white text-moss px-3 py-1.5 rounded-xl border border-moss/10 shadow-sm">
{entry.status === 'Absent' ? entry.note : `${formatTimeHHMM(entry.hoursWorked)} (+${entry.weightedHours.toFixed(1)}h)`}
</span>
)}
{(!entry || isPending) && (
<>
<button onClick={onMarkStandard} className="bg-slate-teal/10 text-slate-teal hover:bg-slate-teal hover:text-white px-3 py-1.5 rounded-xl font-black uppercase tracking-widest text-[10px] flex items-center gap-1.5 transition-colors shadow-sm">
<ClipboardCheck size={14} /> Hela passet
</button>
<button onClick={() => {
const input = prompt(`Timmar arbetade:`, 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 hover:text-white p-1.5 rounded-xl transition-colors shadow-sm">
<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 px-2 py-1.5 rounded-xl font-black uppercase tracking-widest text-[10px] outline-none shadow-sm"
>
<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-emergency/60 hover:text-emergency p-1.5 bg-white rounded-xl border border-emergency/10 transition-colors shadow-sm">
<Undo size={16} />
</button>
)}
</div>
</div>
);
};
-135
View File
@@ -1,135 +0,0 @@
// app/admin/YouthReportCard.tsx
"use client";
import { AlertTriangle, ChevronDown, ChevronUp, Clock } from 'lucide-react';
import Image from 'next/image';
import React from 'react';
import falconIcon from '../assets/falcon.svg';
import porpoiseIcon from '../assets/porpoise.svg';
import { formatTimeHHMM, isWeekend, type Youth } from './adminTypes';
import { getTeamShiftInfo } from './AttendanceTab';
interface YouthReportCardProps {
youth: Youth;
totalHours: number;
potHourLimit: number;
isExpanded: boolean;
onToggle: () => void;
timeline: any[];
scheduleData: any[];
}
const formatHours = (h: number) => Number(h.toFixed(1)).toString();
export const YouthReportCard: React.FC<YouthReportCardProps> = ({
youth,
totalHours,
potHourLimit,
isExpanded,
onToggle,
timeline,
scheduleData
}) => {
const warningStatus = totalHours > potHourLimit ? 'red' : (totalHours >= potHourLimit - 10 ? 'yellow' : 'none');
return (
<div className={`bg-white border transition-all duration-300 rounded-2xl overflow-hidden ${isExpanded ? 'border-slate-teal/20 shadow-md' : 'border-slate-teal/10 shadow-sm hover:border-slate-teal/20'}`}>
<div
onClick={onToggle}
className="flex flex-col sm:flex-row sm:justify-between sm:items-center p-4 gap-4 cursor-pointer hover:bg-slate-teal/5 transition-colors"
>
<div className="flex items-center gap-4">
<div className={`flex items-center justify-center w-12 h-12 rounded-2xl ${youth.team === 'PF' ? 'bg-gold' : 'bg-seafoam'} shrink-0 shadow-inner`}>
<Image src={youth.team === 'PF' ? falconIcon : porpoiseIcon} alt={youth.team === 'PF' ? 'Pilgrimsfalk' : 'Tumlare'} width={24} height={24} />
</div>
<div className="flex flex-col">
<span className="font-black text-ebony text-xl leading-tight">{youth.name}</span>
<div className="flex items-center text-[10px] font-black uppercase tracking-widest text-slate-teal mt-1">
{isExpanded ? <ChevronUp size={14} className="mr-1" /> : <ChevronDown size={14} className="mr-1" />}
{isExpanded ? 'Dölj detaljer' : 'Klicka för detaljer'}
</div>
</div>
</div>
<div className="flex items-center justify-end gap-4">
{warningStatus === 'red' && <AlertTriangle className="text-goldenrod shrink-0" size={28} />}
<div className="text-right">
<div className={`text-3xl md:text-4xl font-black tracking-tight ${warningStatus === 'red' ? 'text-goldenrod' : (warningStatus === 'yellow' ? 'text-goldenrod/80' : 'text-slate-teal')}`}>
{formatHours(totalHours)}<span className="text-sm font-bold text-ebony/40"> / {potHourLimit}h</span>
</div>
<p className="text-[10px] font-black uppercase tracking-widest mt-0.5 text-ebony/50">Viktade timmar</p>
</div>
</div>
</div>
{isExpanded && (
<div className="bg-slate-teal/5 border-t border-slate-teal/10 p-4 md:p-6">
<h4 className="text-xs font-black text-ebony uppercase tracking-widest mb-4 flex items-center">
<Clock size={16} className="mr-2 text-slate-teal" /> Arbetspass
</h4>
{timeline.length === 0 ? (
<p className="text-sm font-bold text-slate-teal/60 italic">Ingen närvaro loggad.</p>
) : (
<div className="space-y-2">
{timeline.map((entry, idx) => {
const isWknd = isWeekend(entry.date);
const dateObj = new Date(entry.date + 'T12:00:00');
const dayNameStrFull = dateObj.toLocaleDateString('sv-SE', { weekday: 'long' }).toLowerCase();
const dayNameShort = dateObj.toLocaleDateString('sv-SE', { weekday: 'short' });
const capitalizedShortDay = dayNameShort.charAt(0).toUpperCase() + dayNameShort.slice(1);
const daySchedule = scheduleData.find(d => d.day.toLowerCase() === dayNameStrFull);
let isExtra = false;
if (daySchedule) {
const expectedShift = getTeamShiftInfo(daySchedule, youth.team);
if (expectedShift.time.toLowerCase() === 'ledig') {
isExtra = true;
} else if (!isWknd) {
isExtra = entry.shiftId !== expectedShift.shiftId;
}
} else {
isExtra = true;
}
const shiftLabel = entry.shiftId === 'MORNING' ? 'Morgon' : 'Eftermiddag';
return (
<div key={idx} className="flex flex-col sm:flex-row sm:justify-between sm:items-center bg-white p-3 rounded-xl border border-slate-teal/5 text-sm gap-2 hover:shadow-sm transition-shadow">
<div className="flex items-center flex-wrap gap-2">
<span className="font-black text-ebony min-w-24">
{entry.date} | {capitalizedShortDay}
</span>
{!isWknd && (
<span className="text-[10px] font-black uppercase tracking-widest text-slate-teal bg-slate-teal/10 px-2 py-1 rounded-lg">
{shiftLabel}
</span>
)}
{isWknd && <span className="text-[10px] font-black uppercase tracking-widest text-goldenrod bg-goldenrod/10 px-2 py-1 rounded-lg">Helg</span>}
{isExtra && (
<span className="text-[10px] font-black uppercase tracking-widest bg-slate-teal text-eggshell px-2 py-1 rounded-lg shadow-sm">
Extra
</span>
)}
</div>
<div className="flex items-center justify-between sm:justify-end gap-4 w-full sm:w-auto">
{entry.status === 'Pending' ? <span className="text-[10px] font-black uppercase tracking-widest text-goldenrod bg-goldenrod/10 px-2 py-1 rounded-lg">Väntar...</span> :
entry.status === 'Absent' ? <span className="text-[10px] font-black uppercase tracking-widest text-goldenrod bg-goldenrod/10 px-2 py-1 rounded-lg">{entry.note || 'Frånvarande'}</span> :
<span className="text-[10px] font-black uppercase tracking-widest text-moss bg-moss/10 px-2 py-1 rounded-lg">{entry.status === 'Late' ? 'Manuell' : 'Närvarande'}</span>}
<span className="font-black text-slate-teal text-base">
{formatTimeHHMM(entry.hoursWorked)} <span className="text-xs opacity-70">(+{entry.weightedHours.toFixed(1)}h)</span>
</span>
</div>
</div>
);
})}
</div>
)}
</div>
)}
</div>
);
};
-267
View File
@@ -1,267 +0,0 @@
// app/admin/page.tsx
"use client";
import { CalendarRange, ClipboardCheck, Loader2, Lock, Unlock, Users as UsersIcon, BellRing, NotebookText } from 'lucide-react';
import React, { useEffect, useRef, useState } from 'react';
import { verifyLogin } from '../actions/admin';
import { OfflineBadge } from '../components/ui/OfflineBadge';
import { AppUser, toIsoDate, getAttendanceKey } from './adminTypes';
import { AttendanceTab, getTeamShiftInfo } from './AttendanceTab';
import { ReportTab } from './ReportTab';
import { SetupTab } from './SetupTab';
import { NoticeTab } from './NoticeTab';
import { LogTab } from './LogTab';
import { useAdminState } from './useAdminState';
export default function Admin() {
const [currentUser, setCurrentUser] = useState<AppUser | null>(null);
const [isCheckingSession, setIsCheckingSession] = useState(true);
const usernameRef = useRef<HTMLInputElement>(null);
const pinRef = useRef<HTMLInputElement>(null);
const [isLoading, setIsLoading] = useState(false);
const [loginError, setLoginError] = useState(false);
const [activeTab, setActiveTab] = useState<'setup' | 'notice' | 'today' | 'log' | 'report'>('report');
const adminState = useAdminState();
useEffect(() => {
const savedSession = localStorage.getItem('kullaberg_admin_session');
if (savedSession) {
const user = JSON.parse(savedSession) as AppUser;
setCurrentUser(user);
if (user.role === 'Viewer') setActiveTab('report');
else if (user.role === 'Staff') setActiveTab('today');
else setActiveTab('today');
}
setIsCheckingSession(false);
}, []);
const handleLogin = async (e: React.SyntheticEvent<HTMLFormElement>) => {
e.preventDefault();
setLoginError(false);
const usernameVal = usernameRef.current?.value || '';
const pinVal = pinRef.current?.value || '';
if (!usernameVal || !pinVal) {
setLoginError(true);
return;
}
setIsLoading(true);
const result = await verifyLogin(usernameVal, pinVal);
if (result.success && result.user) {
const user = result.user as AppUser;
setCurrentUser(user);
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 {
if (pinRef.current) pinRef.current.value = '';
setLoginError(true);
}
setIsLoading(false);
};
const handleLogout = () => {
setCurrentUser(null);
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-6 md:p-8 rounded-2xl shadow-sm 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 tracking-widest">Admin Login</h2>
<form onSubmit={handleLogin} className="space-y-3 text-left">
<div className="relative">
<input
id="username"
name="username"
autoComplete="username"
type="text"
ref={usernameRef}
placeholder="Användarnamn"
className="w-full bg-white border border-slate-teal/20 text-center text-ebony font-bold p-3 rounded-xl focus:outline-none focus:border-slate-teal"
onChange={() => setLoginError(false)}
/>
</div>
<div className="relative">
<input
id="pin"
name="pin"
autoComplete="current-password"
type="password"
ref={pinRef}
placeholder="•••••"
className={`w-full bg-white border text-center text-2xl text-ebony font-mono p-3 rounded-xl tracking-widest focus:outline-none ${loginError ? 'border-emergency/50 bg-emergency/5' : 'border-slate-teal/20 focus:border-slate-teal'}`}
onChange={() => setLoginError(false)}
/>
</div>
{loginError && <p className="text-emergency text-xs font-bold text-center mt-1">Fel namn eller lösenord.</p>}
<button type="submit" disabled={isLoading} className="w-full bg-slate-teal text-eggshell font-black uppercase tracking-widest py-3 mt-2 rounded-xl hover:bg-ebony transition-colors disabled:opacity-50">
{isLoading ? <Loader2 className="animate-spin mx-auto" /> : 'Logga in'}
</button>
</form>
</div>
</div>
);
}
// SMART NOTIS-LOGIK FÖR BÅDE DAGBOK OCH NÄRVARO
let missingLogsCount = 0;
let missingAttendanceCount = 0;
if (adminState.periods.length > 0 && adminState.scheduleData.length > 0) {
const todayIso = toIsoDate(new Date());
const activePeriod = adminState.periods.find(p => p.id === adminState.activePeriodId) || adminState.periods[0];
let curr = new Date(activePeriod.startDate + 'T12:00:00');
const end = new Date((activePeriod.endDate < todayIso ? activePeriod.endDate : todayIso) + 'T12:00:00');
while (curr <= end) {
const dateStr = toIsoDate(curr);
const dayName = curr.toLocaleDateString('sv-SE', { weekday: 'long' }).toLowerCase();
const daySchedule = adminState.scheduleData.find(d => d.day.toLowerCase() === dayName);
if (daySchedule) {
const pfWork = daySchedule.pilgrimsfalkarna?.time && daySchedule.pilgrimsfalkarna.time !== 'Ledig';
const tuWork = daySchedule.tumlarna?.time && daySchedule.tumlarna.time !== 'Ledig';
if (pfWork || tuWork) {
// 1. Kolla Dagboken
const hasLog = !!adminState.dailyLogs?.[dateStr] && adminState.dailyLogs[dateStr].trim() !== "";
if (!hasLog) missingLogsCount++;
// 2. Kolla Närvaron
if (pfWork) {
const { shiftId } = getTeamShiftInfo(daySchedule, 'PF');
activePeriod.youthList.filter(y => y.team === 'PF').forEach(y => {
const entry = adminState.attendance[getAttendanceKey(dateStr, y.id, shiftId)];
if (!entry || entry.status === 'Pending') missingAttendanceCount++;
});
}
if (tuWork) {
const { shiftId } = getTeamShiftInfo(daySchedule, 'TU');
activePeriod.youthList.filter(y => y.team === 'TU').forEach(y => {
const entry = adminState.attendance[getAttendanceKey(dateStr, y.id, shiftId)];
if (!entry || entry.status === 'Pending') missingAttendanceCount++;
});
}
}
}
curr.setDate(curr.getDate() + 1);
}
}
const availableTabs = [];
if (currentUser.role === 'Admin') availableTabs.push({ id: 'setup', icon: CalendarRange, label: 'Perioder' });
if (currentUser.role === 'Admin' || currentUser.role === 'Staff') availableTabs.push({ id: 'notice', icon: BellRing, label: 'Notis' });
if (currentUser.role === 'Admin' || currentUser.role === 'Staff') {
availableTabs.push({ id: 'today', icon: ClipboardCheck, label: 'Närvaro', badge: missingAttendanceCount });
availableTabs.push({ id: 'log', icon: NotebookText, label: 'Journal', badge: missingLogsCount });
}
availableTabs.push({ id: 'report', icon: UsersIcon, label: 'Rapport' });
return (
<div className="space-y-6 animate-fade-in w-full">
<div className="flex flex-col md:flex-row justify-between md:items-end border-b border-slate-teal/20 pb-3 gap-2">
<div className="flex flex-col">
<div className="flex items-center gap-3">
<Unlock className="text-seafoam" size={24} />
<h1 className="text-2xl font-black text-slate-teal uppercase tracking-widest">Admin</h1>
</div>
{adminState.isOffline && <OfflineBadge className="mt-2" />}
</div>
<div className="flex items-center gap-3 text-sm md:text-right bg-eggshell md:bg-transparent p-2 md:p-0 rounded-lg">
<span className="font-bold text-ebony">Inloggad: {currentUser.name}</span>
<span className="text-slate-teal/30">|</span>
<button onClick={handleLogout} className="font-bold text-slate-teal hover:text-goldenrod transition-colors">Logga ut</button>
</div>
</div>
{adminState.isLoadingData && (adminState.periods.length === 0 || adminState.scheduleData.length === 0) ? (
<div className="flex flex-col items-center justify-center py-20 text-slate-teal">
<Loader2 size={40} className="animate-spin mb-4" />
<p className="font-bold text-sm animate-pulse">Hämtar data...</p>
</div>
) : (
<>
{availableTabs.length > 1 && (
<div className="flex gap-2 overflow-x-auto scrollbar-hide pt-3 pb-2 px-1 -mx-1">
{availableTabs.map(tab => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`relative flex items-center px-4 py-2.5 rounded-lg font-bold text-xs uppercase tracking-widest transition-colors ${isActive ? 'bg-slate-teal text-eggshell' : 'bg-white/60 text-slate-teal hover:bg-slate-teal/10'}`}
>
<Icon size={16} className="mr-2 hidden md:block shrink-0" /> {tab.label}
{!!tab.badge && tab.badge > 0 && (
<div className="absolute -top-1.5 -right-1.5 bg-emergency text-white text-[10px] font-black w-5 h-5 flex items-center justify-center rounded-full shadow-sm ring-[1.5px] ring-white">
{tab.badge}
</div>
)}
</button>
);
})}
</div>
)}
{activeTab === 'setup' && currentUser.role === 'Admin' && <SetupTab {...adminState} />}
{activeTab === 'notice' && (currentUser.role === 'Admin' || currentUser.role === 'Staff') && <NoticeTab isOffline={adminState.isOffline} />}
{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}
scheduleData={adminState.scheduleData}
/>
)}
{activeTab === 'log' && (currentUser.role === 'Admin' || currentUser.role === 'Staff') && (
<LogTab
periods={adminState.periods}
dailyLogs={adminState.dailyLogs}
setDailyLog={adminState.setDailyLog}
activePeriodId={adminState.activePeriodId}
setActivePeriodId={adminState.setActivePeriodId}
scheduleData={adminState.scheduleData}
/>
)}
{activeTab === 'report' && (
<ReportTab
periods={adminState.periods}
attendance={adminState.attendance}
activePeriodId={adminState.activePeriodId}
setActivePeriodId={adminState.setActivePeriodId}
currentUserRole={currentUser.role}
scheduleData={adminState.scheduleData}
/>
)}
</>
)}
</div>
);
}
-283
View File
@@ -1,283 +0,0 @@
// app/admin/useAdminState.ts
"use client";
import localforage from 'localforage';
import { useCallback, useEffect, useState } from 'react';
import { bulkAddYouthDb, bulkSetAttendanceDb, createPeriodDb, deletePeriodDb, getAdminData, removeAttendanceDb, removeYouthDb, setAttendanceDb, syncOfflineQueueDb, getDailyLogsDb, setDailyLogDb } from '../actions/admin';
import { readJsonFile } from '../actions/jsonEditor';
import { AttendanceDataMap, Period, Youth, getAttendanceKey, isWeekend, toIsoDate } from './adminTypes';
export const useAdminState = () => {
const [periods, setPeriods] = useState<Period[]>([]);
const [attendance, setAttendance] = useState<AttendanceDataMap>({});
const [scheduleData, setScheduleData] = useState<any[]>([]);
const [dailyLogs, setDailyLogs] = useState<Record<string, string>>({});
const [activePeriodId, setActivePeriodId] = useState<string>('');
const [isLoadingData, setIsLoadingData] = useState<boolean>(true);
const [isOffline, setIsOffline] = useState<boolean>(false);
const [isSyncing, setIsSyncing] = useState<boolean>(false);
const mapDbDataToUI = (dbPeriods: any[]) => {
const loadedPeriods: Period[] = [];
const loadedAttendance: AttendanceDataMap = {};
for (const dbPeriod of dbPeriods) {
const youthList: Youth[] = dbPeriod.youths.map((y: any) => ({ id: y.id, name: y.name, team: y.team as 'PF' | 'TU' }));
loadedPeriods.push({ id: dbPeriod.id, name: dbPeriod.name, startDate: dbPeriod.startDate, endDate: dbPeriod.endDate, youthList });
for (const youth of dbPeriod.youths) {
for (const att of youth.attendance) {
const key = getAttendanceKey(att.date, att.youthId, att.shiftId);
loadedAttendance[key] = {
date: att.date, youthId: att.youthId, shiftId: att.shiftId as 'MORNING' | 'AFTERNOON',
hoursWorked: att.hoursWorked, weightedHours: att.weightedHours, status: att.status as any, note: att.note || ''
};
}
}
}
return { loadedPeriods, loadedAttendance };
};
const applyQueueToAttendance = async (baseAttendance: AttendanceDataMap) => {
const queue = await localforage.getItem<any[]>('sync-queue') || [];
const nextAttendance = { ...baseAttendance };
for (const action of queue) {
if (action.type === 'SET_ATTENDANCE') {
const { date, youthId, shiftId, hoursWorked, weightedHours, status, note } = action.payload;
nextAttendance[getAttendanceKey(date, youthId, shiftId)] = { date, youthId, shiftId, hoursWorked, weightedHours, status, note };
} else if (action.type === 'REMOVE_ATTENDANCE') {
const { date, youthId, shiftId } = action.payload;
delete nextAttendance[getAttendanceKey(date, youthId, shiftId)];
}
}
return nextAttendance;
};
const applyQueueToLogs = async (baseLogs: Record<string, string>) => {
const queue = await localforage.getItem<any[]>('sync-queue') || [];
const nextLogs = { ...baseLogs };
for (const action of queue) {
if (action.type === 'SET_DAILY_LOG') {
nextLogs[action.payload.date] = action.payload.content;
}
}
return nextLogs;
};
const flushOfflineQueue = useCallback(async () => {
try {
const queue = await localforage.getItem<any[]>('sync-queue') || [];
if (queue.length > 0) {
await syncOfflineQueueDb(queue);
await localforage.setItem('sync-queue', []);
return true;
}
} catch (error) {
console.error("Server sync failed, keeping items in offline queue for later.", error);
}
return false;
}, []);
const refreshData = useCallback(async (isInitialLoad = false) => {
if (!isInitialLoad) setIsSyncing(true);
if (isInitialLoad) {
try {
const cachedSched = await localforage.getItem<any[]>('cachedScheduleData');
const cachedData = await localforage.getItem<any[]>('cachedAdminData');
const cachedLogs = await localforage.getItem<Record<string, string>>('cachedDailyLogs');
if (cachedData) {
const { loadedPeriods, loadedAttendance } = mapDbDataToUI(cachedData);
const finalAttendance = await applyQueueToAttendance(loadedAttendance);
const finalLogs = await applyQueueToLogs(cachedLogs || {});
if (cachedSched) setScheduleData(cachedSched);
setPeriods(loadedPeriods);
setAttendance(finalAttendance);
setDailyLogs(finalLogs);
if (loadedPeriods.length > 0) {
const today = toIsoDate(new Date());
const activePeriods = loadedPeriods.filter(p => today >= p.startDate && today <= p.endDate);
if (activePeriods.length > 0) setActivePeriodId(activePeriods[0].id);
else setActivePeriodId(loadedPeriods[0].id);
}
setIsLoadingData(false);
}
} catch (e) {
console.warn("Kunde inte ladda lokal cache", e);
}
}
const withTimeout = <T>(promise: Promise<T>, ms: number = 5000): Promise<T> => {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('NETWORK_TIMEOUT')), ms);
promise
.then(val => { clearTimeout(timer); resolve(val); })
.catch(err => { clearTimeout(timer); reject(err); });
});
};
try {
if (navigator.onLine) await flushOfflineQueue();
const schedRes = await withTimeout(readJsonFile('schedule.json'));
if (schedRes.success && schedRes.data) {
setScheduleData(schedRes.data);
await localforage.setItem('cachedScheduleData', schedRes.data);
}
const dbPeriods = await withTimeout(getAdminData());
await localforage.setItem('cachedAdminData', dbPeriods);
const dbLogs = await withTimeout(getDailyLogsDb());
const mappedLogs: Record<string, string> = {};
dbLogs.forEach((l: { date: string | number; content: string; }) => mappedLogs[l.date] = l.content);
await localforage.setItem('cachedDailyLogs', mappedLogs);
setIsOffline(false);
const { loadedPeriods, loadedAttendance } = mapDbDataToUI(dbPeriods);
const finalAttendance = await applyQueueToAttendance(loadedAttendance);
const finalLogs = await applyQueueToLogs(mappedLogs);
setPeriods(loadedPeriods);
setAttendance(finalAttendance);
setDailyLogs(finalLogs);
if (isInitialLoad && periods.length === 0 && loadedPeriods.length > 0) {
const today = toIsoDate(new Date());
const activePeriods = loadedPeriods.filter(p => today >= p.startDate && today <= p.endDate);
if (activePeriods.length > 0) setActivePeriodId(activePeriods[0].id);
else setActivePeriodId(loadedPeriods[0].id);
}
} catch (error) {
console.warn("Nätverkstimeout eller offline - faller tillbaka på cache.", error);
setIsOffline(true);
} finally {
setIsLoadingData(false);
setIsSyncing(false);
}
}, [flushOfflineQueue, periods.length]);
useEffect(() => { refreshData(true); }, [refreshData]);
useEffect(() => {
const interval = setInterval(() => { if (navigator.onLine) refreshData(); }, 10000);
const handleVisibilityChange = () => { if (document.visibilityState === 'visible' && navigator.onLine) refreshData(); };
document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('focus', handleVisibilityChange);
return () => { clearInterval(interval); document.removeEventListener('visibilitychange', handleVisibilityChange); window.removeEventListener('focus', handleVisibilityChange); };
}, [refreshData]);
useEffect(() => {
const handleOnline = async () => { setIsOffline(false); const didSync = await flushOfflineQueue(); if (didSync) refreshData(); };
window.addEventListener('online', handleOnline); window.addEventListener('offline', () => setIsOffline(true));
return () => { window.removeEventListener('online', handleOnline); window.removeEventListener('offline', () => setIsOffline(true)); };
}, [flushOfflineQueue, refreshData]);
const addToOfflineQueue = async (action: any) => {
const queue = await localforage.getItem<any[]>('sync-queue') || [];
let filteredQueue = queue;
if (action.type === 'SET_DAILY_LOG') {
filteredQueue = queue.filter(q => !(q.type === 'SET_DAILY_LOG' && q.payload.date === action.payload.date));
} else if (action.type === 'SET_ATTENDANCE' || action.type === 'REMOVE_ATTENDANCE') {
filteredQueue = queue.filter(q => !(q.payload.date === action.payload.date && q.payload.youthId === action.payload.youthId && q.payload.shiftId === action.payload.shiftId));
}
filteredQueue.push(action);
await localforage.setItem('sync-queue', filteredQueue);
setIsOffline(true);
};
const createPeriod = async (name: string, startDateStr: string) => {
setIsLoadingData(true);
const start = new Date(startDateStr + 'T12:00:00');
const end = new Date(start); end.setDate(start.getDate() + 20);
const newPeriod = await createPeriodDb(name, toIsoDate(start), toIsoDate(end));
await refreshData();
setActivePeriodId(newPeriod.id);
};
const deletePeriod = async (periodId: string) => { setPeriods(periods.filter(p => p.id !== periodId)); await deletePeriodDb(periodId); await refreshData(); };
const bulkAddYouth = async (periodId: string, text: string, defaultTeam: 'PF' | 'TU') => {
setIsLoadingData(true);
const newYouthData = text.split('\n').map(l => l.trim()).filter(l => l.length > 0).map((line) => {
const parts = line.split(/[,|-]/).map(p => p.trim());
return { name: parts[0], team: (parts[1]?.toUpperCase() === 'PF' || parts[1]?.toUpperCase() === 'TU') ? parts[1].toUpperCase() : defaultTeam };
});
await bulkAddYouthDb(periodId, newYouthData);
await refreshData();
};
const removeYouth = async (periodId: string, youthId: string) => { setPeriods(periods.map(p => p.id === periodId ? { ...p, youthList: p.youthList.filter(y => y.id !== youthId) } : p)); await removeYouthDb(youthId); await refreshData(); };
const setManualAttendance = async (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON', status: 'Absent' | 'Late' | 'Present', hoursManual: number = 0, note?: string) => {
const weight = isWeekend(date) ? 1.5 : 1.0;
let weightedHours = status === 'Late' || status === 'Present' ? hoursManual * weight : 0;
let hoursWorked = status === 'Late' || status === 'Present' ? hoursManual : 0;
const noteStr = note || '';
setAttendance(prev => ({ ...prev, [getAttendanceKey(date, youthId, shiftId)]: { date, youthId, shiftId, hoursWorked, weightedHours, status, note: noteStr } }));
if (!navigator.onLine) await addToOfflineQueue({ type: 'SET_ATTENDANCE', payload: { date, youthId, shiftId, hoursWorked, weightedHours, status, note: noteStr } });
else await setAttendanceDb(date, youthId, shiftId, hoursWorked, weightedHours, status, noteStr).catch(async () => { await addToOfflineQueue({ type: 'SET_ATTENDANCE', payload: { date, youthId, shiftId, hoursWorked, weightedHours, status, note: noteStr } }); });
};
const bulkSetManualAttendance = async (records: { date: string; youthId: string; shiftId: 'MORNING' | 'AFTERNOON'; hoursWorked: number; weightedHours: number; status: 'Absent' | 'Late' | 'Present'; note: string }[]) => {
setAttendance(prev => { const next = { ...prev }; records.forEach(record => { next[getAttendanceKey(record.date, record.youthId, record.shiftId)] = record; }); return next; });
const processOfflineQueue = async () => {
const queue = await localforage.getItem<any[]>('sync-queue') || [];
records.forEach(record => {
const idx = queue.findIndex(q => q.payload.date === record.date && q.payload.youthId === record.youthId && q.payload.shiftId === record.shiftId);
const action = { type: 'SET_ATTENDANCE', payload: record };
if (idx > -1) queue[idx] = action; else queue.push(action);
});
await localforage.setItem('sync-queue', queue);
setIsOffline(true);
};
if (!navigator.onLine) await processOfflineQueue();
else await bulkSetAttendanceDb(records).catch(async () => { await processOfflineQueue(); });
};
const addPendingAttendance = async (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => {
setAttendance(prev => ({ ...prev, [getAttendanceKey(date, youthId, shiftId)]: { date, youthId, shiftId, hoursWorked: 0, weightedHours: 0, status: 'Pending', note: '' } }));
if (!navigator.onLine) await addToOfflineQueue({ type: 'SET_ATTENDANCE', payload: { date, youthId, shiftId, hoursWorked: 0, weightedHours: 0, status: 'Pending', note: '' } });
else await setAttendanceDb(date, youthId, shiftId, 0, 0, 'Pending', '').catch(async () => { await addToOfflineQueue({ type: 'SET_ATTENDANCE', payload: { date, youthId, shiftId, hoursWorked: 0, weightedHours: 0, status: 'Pending', note: '' } }); });
};
const removeAttendanceEntry = async (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => {
setAttendance(prev => { const next = { ...prev }; delete next[getAttendanceKey(date, youthId, shiftId)]; return next; });
if (!navigator.onLine) await addToOfflineQueue({ type: 'REMOVE_ATTENDANCE', payload: { date, youthId, shiftId } });
else await removeAttendanceDb(date, youthId, shiftId).catch(async () => { await addToOfflineQueue({ type: 'REMOVE_ATTENDANCE', payload: { date, youthId, shiftId } }); });
};
const setDailyLog = async (date: string, content: string) => {
setDailyLogs(prev => ({ ...prev, [date]: content }));
if (!navigator.onLine) {
await addToOfflineQueue({ type: 'SET_DAILY_LOG', payload: { date, content } });
} else {
await setDailyLogDb(date, content).catch(async () => {
await addToOfflineQueue({ type: 'SET_DAILY_LOG', payload: { date, content } });
});
}
};
return {
periods, attendance, activePeriodId, setActivePeriodId, isLoadingData, isOffline, isSyncing, scheduleData,
dailyLogs, setDailyLog,
createPeriod, deletePeriod, bulkAddYouth, removeYouth,
setManualAttendance, bulkSetManualAttendance, addPendingAttendance, removeAttendanceEntry, refreshData
};
};
-14
View File
@@ -1,14 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 2.2 KiB

-13
View File
@@ -1,13 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 2.2 KiB

-48
View File
@@ -1,48 +0,0 @@
// 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';
import { useEffect, useLayoutEffect, useRef } from 'react';
const useSafeLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
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 = () => {
const pathname = usePathname();
const navRef = useRef<HTMLElement>(null);
useSafeLayoutEffect(() => {
window.scrollTo({ top: 0, behavior: 'instant' });
}, [pathname]);
return (
<nav ref={navRef} 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>
);
};
-31
View File
@@ -1,31 +0,0 @@
// app/components/ui/ActionLinkCard.tsx
import { ArrowRight } from 'lucide-react';
import Link from 'next/link';
interface ActionLinkCardProps {
href: string;
title: string;
description: string;
}
export function ActionLinkCard({ href, title, description }: ActionLinkCardProps) {
return (
<Link
href={href}
className="group bg-white/40 backdrop-blur-md border border-white/40 p-5 rounded-3xl shadow-sm hover:shadow-md hover:bg-white/50 transition-all duration-300 flex flex-col gap-2"
>
<div className="flex items-center gap-3">
<div className="bg-white w-10 h-10 rounded-xl flex items-center justify-center shrink-0 text-slate-teal group-hover:bg-seafoam group-hover:text-eggshell transition-colors shadow-sm">
<ArrowRight size={20} className="group-hover:rotate-45 transition-transform" />
</div>
<h3 className="font-black text-ebony text-lg uppercase tracking-wide leading-tight">
{title}
</h3>
</div>
<p className="text-sm text-ebony/70 font-medium leading-relaxed w-full m-0 pl-1">
{description}
</p>
</Link>
);
}
-87
View File
@@ -1,87 +0,0 @@
// app/components/ui/Cards.tsx
import { Archive, CheckCircle, Download, ExternalLink } from 'lucide-react';
import { ReactNode } from 'react';
// --- Types ---
export interface DocumentItem {
title: string;
file: string;
icon: ReactNode;
size: string;
}
export interface LinkItem {
title: string;
url: string;
}
// --- Document Card Component ---
interface DocumentCardProps {
doc: DocumentItem;
isOffline: boolean;
isCached: boolean;
}
export function DocumentCard({ doc, isOffline, isCached }: DocumentCardProps) {
return (
<a
href={doc.file}
target="_blank"
rel="noopener noreferrer"
className={`group flex items-center justify-between p-4 rounded-2xl border transition-colors ${isOffline && !isCached
? 'opacity-50 grayscale cursor-not-allowed pointer-events-none border-transparent bg-eggshell/50'
: 'bg-white/50 hover:bg-white border-slate-teal/10'
}`}
>
<div className="flex items-center text-slate-teal">
<div className={`p-2.5 rounded-xl mr-3 text-eggshell shrink-0 transition-colors ${isCached ? 'bg-moss' : 'bg-slate-teal'}`}>
{doc.icon}
</div>
<div className="flex flex-col">
<span className="font-bold text-sm leading-tight text-ebony">{doc.title}</span>
<div className="flex flex-wrap items-center gap-2 mt-1">
<span className="text-[10px] font-bold text-moss bg-moss/10 px-2 py-0.5 rounded uppercase tracking-wider">
{doc.size}
</span>
{isCached && (
<span className="flex items-center gap-1 text-[10px] font-bold text-moss bg-moss/10 px-2 py-0.5 rounded uppercase tracking-wider">
<Archive size={10} /> Nedladdad
</span>
)}
</div>
</div>
</div>
{isCached ? (
<CheckCircle size={18} className="text-moss shrink-0 ml-2" />
) : (
<Download size={18} className="text-slate-teal/50 group-hover:text-slate-teal transition-colors shrink-0 ml-2" />
)}
</a>
);
}
// --- External Link Card Component ---
interface ExternalLinkCardProps {
link: LinkItem;
isOffline: boolean;
}
export function ExternalLinkCard({ link, isOffline }: ExternalLinkCardProps) {
return (
<a
href={isOffline ? '#' : link.url}
target={isOffline ? '_self' : '_blank'}
rel="noopener noreferrer"
className={`group flex items-center justify-between p-4 rounded-2xl border transition-colors ${isOffline
? 'opacity-40 grayscale cursor-not-allowed pointer-events-none bg-eggshell/50 border-transparent'
: 'bg-white/50 hover:bg-white border-slate-teal/10'
}`}
>
<span className="font-bold text-slate-teal text-sm leading-tight pr-4">{link.title}</span>
<div className={`rounded-full p-2 transition-colors shrink-0 ${isOffline ? 'bg-transparent' : 'bg-white group-hover:bg-seafoam'}`}>
<ExternalLink size={16} className={isOffline ? 'text-ebony/40' : 'text-slate-teal group-hover:text-eggshell'} />
</div>
</a>
);
}
-32
View File
@@ -1,32 +0,0 @@
// app/components/ui/EmergencyButton.tsx
import { ChevronRight, Siren } from 'lucide-react';
import Link from 'next/link';
interface EmergencyButtonProps {
href: string;
title: string;
}
export function EmergencyButton({ href, title }: EmergencyButtonProps) {
return (
<Link
href={href}
className="group flex items-center justify-between w-full bg-emergency text-eggshell px-5 py-4 rounded-2xl shadow-md hover:shadow-lg hover:brightness-110 active:scale-[0.98] transition-all duration-300 border border-emergency/50"
>
<div className="flex items-center gap-3">
<div className="bg-white/20 p-2 rounded-xl group-hover:bg-white/30 transition-colors">
{/* Ikonen pulserar mjukt för att direkt fånga uppmärksamheten */}
<Siren size={24} className="animate-pulse" />
</div>
<h2 className="text-base sm:text-lg font-black uppercase tracking-widest drop-shadow-sm">
{title}
</h2>
</div>
<ChevronRight
size={24}
className="opacity-70 group-hover:opacity-100 group-hover:translate-x-1 transition-all"
/>
</Link>
);
}
-36
View File
@@ -1,36 +0,0 @@
// app/components/ui/FAQItem.tsx
import { ChevronDown, ChevronUp } from 'lucide-react';
interface FAQItemProps {
id: string;
question: string;
answer: string;
isOpen: boolean;
onToggle: (id: string) => void;
}
export function FAQItem({ id, question, answer, isOpen, onToggle }: FAQItemProps) {
return (
<div className={`rounded-xl overflow-hidden transition-colors ${isOpen ? 'bg-white/80 shadow-sm' : 'bg-white/40 hover:bg-white/60'}`}>
<button
className="w-full text-left p-3 flex justify-between items-center focus:outline-none"
onClick={() => onToggle(id)}
>
<span className="font-bold text-slate-teal text-sm pr-4 leading-snug">{question}</span>
<div className={`p-1 rounded-full transition-colors ${isOpen ? 'bg-seafoam text-eggshell' : 'text-seafoam'}`}>
{isOpen ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</div>
</button>
{/* The CSS Grid accordion trick */}
<div className={`grid transition-all duration-300 ease-in-out ${isOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'}`}>
<div className="overflow-hidden">
<div className="p-2 pb-3 text-ebony font-medium text-sm leading-relaxed border-t border-ebony/20 mx-4">
{answer}
</div>
</div>
</div>
</div>
);
}
-15
View File
@@ -1,15 +0,0 @@
// app/components/ui/OfflineBadge.tsx
import { WifiOff } from 'lucide-react';
interface OfflineBadgeProps {
className?: string;
}
export function OfflineBadge({ className = "" }: OfflineBadgeProps) {
return (
<div className={`bg-goldenrod/10 text-goldenrod border border-goldenrod/20 px-3 py-1.5 rounded-full flex items-center w-fit text-xs font-black uppercase tracking-widest ${className}`}>
<WifiOff size={14} className="mr-2" /> Offline-läge
</div>
);
}
-47
View File
@@ -1,47 +0,0 @@
// app/components/ui/PageHeader.tsx
import { LucideIcon } from 'lucide-react';
import { ReactNode } from 'react';
interface PageHeaderProps {
title: string;
icon: LucideIcon;
description: string;
variant?: 'default' | 'emergency';
actions?: ReactNode;
}
export function PageHeader({
title,
icon: Icon,
description,
variant = 'default',
actions
}: PageHeaderProps) {
// Set colors based on the variant
const isEmergency = variant === 'emergency';
const titleColor = isEmergency ? 'text-emergency uppercase' : 'text-slate-teal';
const iconColor = isEmergency ? 'text-emergency' : 'text-seafoam';
const descColor = isEmergency ? 'text-ebony' : 'text-moss';
return (
<div className="flex flex-col md:flex-row md:justify-between md:items-start gap-4 mb-4">
<div>
<h1 className={`text-3xl font-black tracking-tight flex items-center ${titleColor}`}>
<Icon className={`mr-3 shrink-0 ${iconColor}`} size={32} />
{title}
</h1>
<p className={`font-medium mt-2 ml-11 ${descColor} hidden sm:visible`}>
{description}
</p>
</div>
{actions && (
<div className="flex flex-col gap-2 items-start md:items-end ml-11 md:ml-0 mt-2 md:mt-0">
{actions}
</div>
)}
</div>
);
}
-76
View File
@@ -1,76 +0,0 @@
// app/components/ui/SectionCard.tsx
import React, { ReactNode } from 'react';
import { LucideIcon } from 'lucide-react';
interface SectionCardProps {
title: string;
icon?: LucideIcon;
children?: ReactNode;
description?: ReactNode;
variant?: 'default' | 'alert' | 'highlight';
className?: string;
}
export function SectionCard({
title,
icon: Icon,
children,
description,
variant = 'default',
className = ""
}: SectionCardProps) {
let cardStyle = "rounded-3xl shadow-sm transition-all duration-300 p-6 md:p-8 hover:shadow-md ";
let headerStyle = "text-xl font-black uppercase tracking-widest flex items-center ";
let iconStyle = "mr-3 shrink-0 ";
let descStyle = "text-sm leading-relaxed ";
switch (variant) {
case 'alert':
cardStyle += "bg-emergency/10 backdrop-blur-md border-2 border-emergency/30";
headerStyle += "text-emergency";
iconStyle += "text-emergency";
descStyle += "text-emergency/90 font-bold";
break;
case 'highlight':
cardStyle += "bg-goldenrod/30 backdrop-blur-md border border-goldenrod/20";
headerStyle += "text-goldenrod";
iconStyle += "text-goldenrod";
descStyle += "text-ebony font-medium";
break;
case 'default':
default:
cardStyle += "bg-white/40 backdrop-blur-md border border-white/40";
headerStyle += "text-ebony";
iconStyle += "text-slate-teal";
descStyle += "text-ebony/80 font-medium";
break;
}
const hasChildren = React.Children.toArray(children).length > 0;
return (
<section className={`${cardStyle} ${className}`}>
<h2 className={`${headerStyle} ${(description || hasChildren) ? 'mb-4' : 'mb-0'}`}>
{Icon && <Icon className={iconStyle} size={24} />}
{title}
</h2>
{description && (
<div className={`${descStyle} ${hasChildren ? 'mb-6' : 'mb-0'}`}>
{description}
</div>
)}
{hasChildren && (
<div className={variant === 'highlight' ? "text-sm font-medium leading-relaxed text-ebony" : ""}>
{children}
</div>
)}
</section>
);
}
-3
View File
@@ -1,3 +0,0 @@
// app/css.d.ts
declare module '*.css';
-70
View File
@@ -1,70 +0,0 @@
{
"docs": [
{
"title": "Turistkarta",
"icon": "Map",
"file": "Turistkarta - Kullaberg.pdf"
},
{
"title": "Orienteringskarta",
"icon": "MapPlus",
"file": "Orienteringskarta - Kullaberg.pdf"
},
{
"title": "Badplatser att besöka",
"icon": "WavesLadder",
"file": "Badplatser på Kullaberg.pdf"
},
{
"title": "Vandringsrutter",
"icon": "MapPinned",
"file": "Vandringsrutter.pdf"
},
{
"title": "Underlag för guidediplomering",
"icon": "BookCopy",
"file": "Underlag för guidediplomering.pdf"
},
{
"title": "Destinationskunskap Kullahalvön",
"icon": "BookCopy",
"file": "Destinationskunskap.pdf"
},
{
"title": "Tjänstgöringsrapport",
"icon": "BookCopy",
"file": "Tjänstgöringsrapport 2026.pdf"
},
{
"title": "Information om ditt sommarjobb",
"icon": "BookCopy",
"file": "Information om ditt sommarjobb 2026.pdf"
}
],
"links": [
{
"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": "RSNV Brandriskprognos",
"url": "https://rsnv.se/brandriskprognos/"
},
{
"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"
}
]
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-5
View File
@@ -1,5 +0,0 @@
{
"isActive": true,
"type": "warning",
"message": "Glöm inte minst 1 liter vatten, solkräm och myggmedel. Det förväntas bli mycket varmt idag!"
}
-209
View File
@@ -1,209 +0,0 @@
// app/documents/page.tsx
"use client";
import { BookCopy, CheckCircle, CloudDownload, FileText, Folder, Loader2, Map, MapPinned, MapPlus, WavesLadder } from 'lucide-react';
import { useEffect, useState } from 'react';
import { readJsonFile } from '../actions/jsonEditor';
import { DocumentCard, DocumentItem, ExternalLinkCard, LinkItem } from '../components/ui/Cards';
import { OfflineBadge } from '../components/ui/OfflineBadge';
import { PageHeader } from '../components/ui/PageHeader';
import { getLocalFileMeta } from '../actions/files';
const IconMap: Record<string, any> = {
"Map": Map,
"MapPlus": MapPlus,
"WavesLadder": WavesLadder,
"MapPinned": MapPinned,
"BookCopy": BookCopy,
"FileText": FileText
};
export default function Documents() {
const [isHydrated, setIsHydrated] = useState(false);
const [isOffline, setIsOffline] = useState(false);
const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'done'>('idle');
const [cachedFiles, setCachedFiles] = useState<Set<string>>(new Set());
const [showNotification, setShowNotification] = useState(false);
const [docs, setDocs] = useState<DocumentItem[]>([]);
const [links, setLinks] = useState<LinkItem[]>([]);
const [isLoadingData, setIsLoadingData] = useState(true);
const checkCacheStatus = async (currentDocs: DocumentItem[]) => {
if (!('caches' in window) || currentDocs.length === 0) return;
const cached = new Set<string>();
try {
for (const doc of currentDocs) {
const response = await caches.match(doc.file);
if (response) cached.add(doc.file);
}
setCachedFiles(cached);
if (cached.size === currentDocs.length) setSyncStatus('done');
else setSyncStatus('idle');
} catch (error) {
console.error("Cache check failed", error);
}
};
const loadDynamicData = async () => {
if (navigator.onLine) {
const res = await readJsonFile('documents.json');
if (res.success && res.data) {
const docsWithMeta = await Promise.all(
(res.data.docs || []).map(async (doc: DocumentItem) => {
const meta = await getLocalFileMeta(doc.file);
const fileNameOnly = doc.file.split('/').pop();
const versionedUrl = `/files/${fileNameOnly}?v=${meta.version}`;
return {
...doc,
size: (!doc.size || doc.size === "Okänd") ? meta.size : doc.size,
originalFile: doc.file,
file: versionedUrl
};
})
);
setDocs(docsWithMeta);
setLinks(res.data.links || []);
const dataToCache = { ...res.data, docs: docsWithMeta };
localStorage.setItem('kullaberg_documents_cache', JSON.stringify(dataToCache));
checkCacheStatus(docsWithMeta);
}
} else {
const cachedData = localStorage.getItem('kullaberg_documents_cache');
if (cachedData) {
const parsed = JSON.parse(cachedData);
setDocs(parsed.docs || []);
setLinks(parsed.links || []);
checkCacheStatus(parsed.docs || []);
}
}
setIsLoadingData(false);
};
useEffect(() => {
setIsHydrated(true);
setIsOffline(!navigator.onLine);
const handleStatus = () => setIsOffline(!navigator.onLine);
window.addEventListener('online', handleStatus);
window.addEventListener('offline', handleStatus);
loadDynamicData();
const handleVisibility = () => {
if (document.visibilityState === 'visible') checkCacheStatus(docs);
};
document.addEventListener('visibilitychange', handleVisibility);
window.addEventListener('focus', () => checkCacheStatus(docs));
const conn = (navigator as any).connection;
if (conn && (conn.type === 'wifi' || conn.type === 'ethernet') && !conn.saveData) {
handleSyncAll();
}
return () => {
window.removeEventListener('online', handleStatus);
window.removeEventListener('offline', handleStatus);
document.removeEventListener('visibilitychange', handleVisibility);
window.removeEventListener('focus', () => checkCacheStatus(docs));
};
}, []);
const handleSyncAll = async () => {
if (docs.length === 0) return;
setSyncStatus('syncing');
const updatedCache = new Set(cachedFiles);
for (const doc of docs) {
if (!updatedCache.has(doc.file)) {
try {
const response = await fetch(doc.file);
if (response.ok) {
await response.blob();
updatedCache.add(doc.file);
setCachedFiles(new Set(updatedCache));
}
} catch (error) {
console.error("Kunde inte ladda ner:", doc.file);
}
}
}
await checkCacheStatus(docs);
setSyncStatus('done');
setShowNotification(true);
setTimeout(() => setShowNotification(false), 4000);
};
if (!isHydrated || isLoadingData) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-slate-teal" size={40} /></div>;
return (
<div className="space-y-8 animate-fade-in w-full relative">
<PageHeader
title="Info & Dokument"
icon={Folder}
description="Allt du behöver för att guida besökarna."
actions={
<>
{isOffline && <OfflineBadge />}
{!isOffline && syncStatus !== 'done' && docs.length > 0 && (
<button
onClick={handleSyncAll}
disabled={syncStatus === 'syncing'}
className="flex items-center gap-2 bg-seafoam/10 text-slate-teal border border-seafoam/20 hover:bg-seafoam/20 px-4 py-2 rounded-lg font-bold text-sm transition-colors"
>
{syncStatus === 'syncing' ? <Loader2 size={18} className="animate-spin" /> : <CloudDownload size={18} />}
{syncStatus === 'syncing' ? 'Laddar ner filer...' : 'Gör tillgängliga offline'}
</button>
)}
</>
}
/>
<section>
<h2 className="text-sm font-black text-ebony/50 uppercase tracking-widest mb-4">Filer</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{docs.map((doc, idx) => {
const IconComponent = IconMap[doc.icon as string] || FileText;
const renderDoc = { ...doc, icon: <IconComponent size={20} /> };
return (
<DocumentCard
key={idx}
doc={renderDoc}
isOffline={isOffline}
isCached={cachedFiles.has(doc.file)}
/>
);
})}
</div>
</section>
<section>
<h2 className="text-sm font-black text-ebony/50 uppercase tracking-widest mb-4">
{isOffline ? "Användbara Länkar (Kräver anslutning)" : "Användbara Länkar"}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{links.map((link, idx) => (
<ExternalLinkCard
key={idx}
link={link}
isOffline={isOffline}
/>
))}
</div>
</section>
{showNotification && (
<div className="fixed bottom-6 right-6 z-50 animate-fade-in flex items-center gap-3 bg-white text-moss font-bold text-sm border border-moss/20 shadow-xl px-4 py-3 rounded-xl">
<div className="bg-moss/10 p-1 rounded-full">
<CheckCircle size={20} className="text-moss" />
</div>
Alla filer är redo för offline
</div>
)}
</div>
);
}
-199
View File
@@ -1,199 +0,0 @@
// app/emergency/page.tsx
"use client";
import { AlertTriangle, CheckCircle, Crosshair, FileText, HeartPulse, Loader2, MapPin, PhoneCall, XCircle, Navigation } from 'lucide-react';
import { useState } from 'react';
import { SafePhoneLink } from '../components/PhoneLinks';
import { PageHeader } from '../components/ui/PageHeader';
import { SectionCard } from '../components/ui/SectionCard';
export default function Emergency() {
const [confirmCall, setConfirmCall] = useState(false);
const [location, setLocation] = useState<{ lat: number, lng: number, acc: number } | null>(null);
const [locLoading, setLocLoading] = useState(false);
const [locError, setLocError] = useState("");
const getLocation = () => {
setLocLoading(true);
setLocError("");
if (!navigator.geolocation) {
setLocError("Din enhet saknar stöd för GPS.");
setLocLoading(false);
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => {
setLocation({
lat: pos.coords.latitude,
lng: pos.coords.longitude,
acc: Math.round(pos.coords.accuracy) // Noggrannhet i meter
});
setLocLoading(false);
},
(err) => {
setLocError("Kunde inte hämta plats. Kontrollera att GPS är aktiverat i telefonen.");
setLocLoading(false);
},
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 0 }
);
};
return (
<div className="space-y-6 animate-fade-in w-full mx-auto">
<PageHeader
title="Vid Nödsituation"
icon={AlertTriangle}
description="Agera lugnt, stanna kvar på platsen och tillkalla hjälp."
variant="emergency"
/>
<SectionCard
variant="alert"
title="Ring 112"
icon={PhoneCall}
description="Vid olycka, brand eller livshotande tillstånd. Berätta vem du är och vad som har hänt."
>
<div className="space-y-4">
{/* SÄKER 112-KNAPP */}
<div className="bg-white/40 p-2 rounded-2xl border border-emergency/20">
{!confirmCall ? (
<button
onClick={() => setConfirmCall(true)}
className="w-full flex items-center justify-center gap-3 bg-emergency text-white font-black uppercase tracking-widest text-lg py-4 rounded-xl shadow-sm hover:brightness-110 active:scale-[0.98] transition-all"
>
<PhoneCall size={24} className="animate-pulse" />
Ring 112
</button>
) : (
<div className="flex flex-col sm:flex-row gap-2 animate-fade-in">
<a
href="tel:112"
onClick={() => setTimeout(() => setConfirmCall(false), 2000)} // Återställ efter klick
className="flex-1 flex items-center justify-center gap-2 bg-emergency text-white font-black uppercase tracking-widest text-lg py-4 rounded-xl shadow-md hover:brightness-110 active:scale-[0.98] transition-all ring-4 ring-emergency/30"
>
<CheckCircle size={24} />
Ja, ring nu
</a>
<button
onClick={() => setConfirmCall(false)}
className="sm:w-1/3 flex items-center justify-center gap-2 bg-white text-ebony font-bold uppercase tracking-widest text-sm py-4 rounded-xl shadow-sm border border-emergency/20 hover:bg-eggshell transition-all"
>
<XCircle size={18} />
Avbryt
</button>
</div>
)}
</div>
{/* GPS OCH POSITION */}
<div className="bg-white/60 p-4 md:p-5 rounded-2xl border border-emergency/20">
<h3 className="font-black text-emergency flex items-center mb-2 text-xs uppercase tracking-widest">
<MapPin className="mr-2" size={16} /> Uppge din position
</h3>
<p className="text-ebony font-medium text-sm leading-relaxed mb-4">
Säg att du befinner dig i Kullabergs Naturreservat. Var specifik (t.ex. "Nära fyren" eller "Vid Josefinelust"). Minns du närmsta räddningspunkt?
</p>
<div className="bg-white p-3 rounded-xl border border-emergency/10 shadow-sm">
{location ? (
<div className="space-y-1 animate-fade-in">
<p className="text-xs font-bold text-slate-teal uppercase tracking-widest">Dina koordinater (WGS84)</p>
<p className="font-mono text-lg font-black text-ebony tracking-tight">
{location.lat.toFixed(5)}, {location.lng.toFixed(5)}
</p>
<p className="text-[10px] font-bold text-ebony/50 uppercase">
Noggrannhet: ca {location.acc} meter
</p>
<div className="flex flex-wrap items-center gap-3 mt-4 pt-3 border-t border-slate-teal/5">
<a
href={`https://maps.google.com/?q=${location.lat},${location.lng}`}
target="_blank"
rel="noreferrer"
className="flex items-center gap-1.5 bg-seafoam/10 text-slate-teal px-3 py-1.5 rounded-lg text-xs font-bold hover:bg-seafoam/20 transition-colors"
>
<MapPin size={14} />
Google Maps
</a>
<a
href={`http://maps.apple.com/?ll=${location.lat},${location.lng}&q=Min+Position`}
target="_blank"
rel="noreferrer"
className="flex items-center gap-1.5 bg-seafoam/10 text-slate-teal px-3 py-1.5 rounded-lg text-xs font-bold hover:bg-seafoam/20 transition-colors"
>
<Navigation size={14} />
Apple Maps
</a>
</div>
</div>
) : (
<button
onClick={getLocation}
disabled={locLoading}
className="w-full flex items-center justify-center gap-2 bg-emergency/10 text-emergency hover:bg-emergency/20 font-bold uppercase tracking-widest text-xs py-3 rounded-lg transition-colors disabled:opacity-50"
>
{locLoading ? <Loader2 size={16} className="animate-spin" /> : <Crosshair size={16} />}
{locLoading ? "Söker satelliter..." : "Hämta min exakta position"}
</button>
)}
{locError && (
<p className="text-xs font-bold text-emergency mt-3 animate-fade-in flex items-start gap-1.5">
<AlertTriangle size={14} className="shrink-0" />
{locError}
</p>
)}
</div>
</div>
</div>
</SectionCard>
{/* First Aid / HLR Card */}
<SectionCard
variant="default"
title="Första Hjälpen & Hjärtstartare"
icon={HeartPulse}
>
<ul className="space-y-3 text-ebony font-medium text-sm">
<li className="flex items-start">
<span className="bg-seafoam text-eggshell font-bold px-2 py-0.5 rounded mr-3 text-xs shrink-0">1</span>
<span>Säkra platsen se till att varken du eller personen utsätts för mer fara.</span>
</li>
<li className="flex items-start">
<span className="bg-seafoam text-eggshell font-bold px-2 py-0.5 rounded mr-3 text-xs shrink-0">2</span>
<span>Finns hjärtstartare? Ja, närmaste hjärtstartare finns utanför <strong className="text-ebony font-black">Naturum</strong> (vid fyren) eller vid <strong className="text-ebony font-black">golfbanans klubbhus</strong>.</span>
</li>
</ul>
</SectionCard>
{/* Internal Contact */}
<SectionCard
variant="default"
title="Intern Rapportering"
icon={FileText}
description="När situationen är under kontroll, meddela alltid arbetsledaren om vad som inträffat."
>
<div className="space-y-3">
<div className="flex flex-col md:flex-row md:justify-between md:items-center pb-3 border-b border-slate-teal/10 gap-2">
<span className="text-sm font-bold text-ebony">William Söderberg</span>
<div className='flex gap-2 flex-wrap md:justify-end text-sm'>
<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-2">
<span className="text-sm font-bold text-ebony">Oliver Nilsson</span>
<div className='flex gap-2 flex-wrap md:justify-end text-sm'>
<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>
</SectionCard>
</div >
);
}
-75
View File
@@ -1,75 +0,0 @@
// app/faq/page.tsx
"use client";
import { Loader2, MessageCircleQuestionMark } from 'lucide-react';
import { useEffect, useState } from 'react';
import { readJsonFile } from '../actions/jsonEditor';
import { FAQItem } from '../components/ui/FAQItem';
import { PageHeader } from '../components/ui/PageHeader';
import { SectionCard } from '../components/ui/SectionCard';
export default function FAQ() {
const [openIndex, setOpenIndex] = useState<string | null>(null);
const [faqData, setFaqData] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const loadFAQ = async () => {
if (navigator.onLine) {
const res = await readJsonFile('faq.json');
if (res.success && res.data) {
setFaqData(res.data);
localStorage.setItem('kullaberg_faq_cache', JSON.stringify(res.data));
}
} else {
const cachedData = localStorage.getItem('kullaberg_faq_cache');
if (cachedData) setFaqData(JSON.parse(cachedData));
}
setIsLoading(false);
};
loadFAQ();
}, []);
const toggleQuestion = (index: string) => {
setOpenIndex(openIndex === index ? null : index);
};
if (isLoading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-slate-teal" size={40} /></div>;
return (
<div className="w-full animate-fade-in space-y-6">
<PageHeader
title="Vanliga Frågor (FAQ)"
icon={MessageCircleQuestionMark}
description="Använd den här guiden för att snabbt svara på turisternas vanligaste frågor."
/>
<div className="columns-1 lg:columns-2 gap-4">
{faqData.map((section, sIndex) => (
<SectionCard
key={sIndex}
title={section.category}
className="break-inside-avoid mb-4 inline-block w-full"
>
<div className="space-y-2">
{section.questions.map((item: any, qIndex: number) => {
const id = `${sIndex}-${qIndex}`;
return (
<FAQItem
key={id}
id={id}
question={item.q}
answer={item.a}
isOpen={openIndex === id}
onToggle={toggleQuestion}
/>
);
})}
</div>
</SectionCard>
))}
</div>
</div>
);
}
-42
View File
@@ -1,42 +0,0 @@
// app/files/[...slug]/route.ts
import fs from 'fs';
import { NextRequest, NextResponse } from 'next/server';
import path from 'path';
const isProd = process.env.NODE_ENV === "production";
const DATA_DIR = isProd ? "/app/data" : path.join(process.cwd(), "app/data");
const FILES_DIR = path.join(DATA_DIR, "files");
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ slug: string[] }> }
) {
try {
const resolvedParams = await params;
const filename = decodeURIComponent(resolvedParams.slug[resolvedParams.slug.length - 1]);
const filePath = path.join(FILES_DIR, filename);
if (!fs.existsSync(filePath)) {
console.error(`404 - Filen finns inte på disk: ${filePath}`);
return new NextResponse('Filen hittades inte', { status: 404 });
}
const fileBuffer = fs.readFileSync(filePath);
let contentType = 'application/pdf';
if (filename.toLowerCase().endsWith('.png')) contentType = 'image/png';
else if (filename.toLowerCase().endsWith('.jpg') || filename.toLowerCase().endsWith('.jpeg')) contentType = 'image/jpeg';
else if (filename.toLowerCase().endsWith('.doc') || filename.toLowerCase().endsWith('.docx')) contentType = 'application/msword';
return new NextResponse(fileBuffer, {
headers: {
'Content-Type': contentType,
'Content-Disposition': `inline; filename="${filename}"`
},
});
} catch (error) {
console.error("Fel vid fildelning:", error);
return new NextResponse('Ett internt serverfel uppstod', { status: 500 });
}
}
-128
View File
@@ -1,128 +0,0 @@
// app/info/page.tsx
import { AlertTriangle, Briefcase, CloudRain, HeartHandshake, ListChecks, Navigation, ShieldAlert, Sun } from 'lucide-react';
import React from 'react';
import { PageHeader } from '../components/ui/PageHeader';
import { SectionCard } from '../components/ui/SectionCard';
// --- Local Components ---
const TaskItem = ({ title, desc }: { title: string; desc: string }) => (
<li className="flex items-start bg-white/40 p-3 rounded-xl">
<span className="text-seafoam mr-3 mt-0.5 font-black"></span>
<span><strong className="text-slate-teal">{title}:</strong> {desc}</span>
</li>
);
const PackItem = ({ label, isWeekend = false }: { label: string; isWeekend?: boolean }) => (
<li className={`flex items-center text-xs font-bold text-ebony/80 bg-white p-3 rounded-xl border border-slate-teal/5 shadow-sm ${isWeekend ? 'sm:col-span-2' : ''}`}>
<span className={`w-2.5 h-2.5 rounded-full mr-3 shrink-0 ${isWeekend ? 'bg-goldenrod' : 'bg-seafoam'}`}></span>
{label}
</li>
);
const RuleItem = ({ number, title, content }: { number: number; title: string; content: React.ReactNode }) => (
<div className="flex items-start bg-white/60 p-4 rounded-2xl">
<div className="bg-goldenrod text-white font-black w-6 h-6 rounded-full flex items-center justify-center shrink-0 mr-3 mt-0.5 shadow-sm text-xs">
{number}
</div>
<span><strong className="text-ebony">{title}:</strong> {content}</span>
</div>
);
const RiskItem = ({ title, content, colSpanClass = "" }: { title: string; content: React.ReactNode; colSpanClass?: string }) => (
<div className={`bg-white p-4 rounded-2xl shadow-sm flex items-start border border-emergency/10 ${colSpanClass}`}>
<AlertTriangle className="text-emergency shrink-0 mr-3 mt-0.5" size={20} />
<p><strong className="text-emergency">{title}:</strong> {content}</p>
</div>
);
// --- Main Page Component ---
export default function JobInfo() {
return (
<div className="space-y-8 animate-fade-in w-full mx-auto">
<PageHeader
title="Jobbinfo & Regler"
icon={Briefcase}
description="Din överlevnadsguide till världens bästa sommarjobb på Kullaberg!"
/>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* VAD GÖR VI? */}
<SectionCard
title="Snabbguiden: Uppdrag"
icon={ListChecks}
description="Arbetsuppgifterna är varierande och vi löser ofta specialuppdrag som ett team. Exempel på vad du kommer göra:"
>
<ul className="space-y-4 text-sm font-medium text-ebony/90">
<TaskItem title="Parkeringsvärd" desc="Hjälpa folk att parkera rätt." />
<TaskItem title="Naturvärd" desc="Berätta för besökare om Kullaberg och vad man kan göra." />
<TaskItem title="Naturvård" desc="Plocka skräp och bekämpa invasiva växter." />
<TaskItem title="Underhåll & Städ" desc="Städa faciliteter, olja skyltar/möbler, laga staket och röja stigar." />
</ul>
</SectionCard>
{/* PACKLISTA */}
<SectionCard
title="Daglig packlista"
icon={Navigation}
description="Vi är ute i princip hela arbetspasset. Kommunen löser t-shirt och keps, resten står du för!"
>
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-6">
<PackItem label="Sköna promenadskor" />
<PackItem label="Oömma kläder efter väder" />
<PackItem label="Bekväm ryggsäck" />
<PackItem label="Minst 1L vatten" />
<PackItem label="Matsäck (Endast helger)" isWeekend={true} />
</ul>
{/* Fixed Weather Blocks */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4 pt-6 border-t-2 border-slate-teal/10">
<div className="bg-slate-teal/10 p-4 rounded-2xl flex items-start">
<CloudRain className="text-slate-teal mr-3 shrink-0" size={24} />
<div>
<h4 className="font-black text-slate-teal text-sm uppercase tracking-wider mb-1">Vid regn</h4>
<p className="text-xs text-ebony/80 font-bold">Regnkläder och torrt ombyte.</p>
</div>
</div>
<div className="bg-gold/20 p-4 rounded-2xl flex items-start">
<Sun className="text-goldenrod mr-3 shrink-0" size={24} />
<div>
<h4 className="font-black text-goldenrod text-sm uppercase tracking-wider mb-1">Vid sol</h4>
<p className="text-xs text-ebony/80 font-bold">Extra vatten, solkräm & "Sunstopper" (tunn långärmad tröja).</p>
</div>
</div>
</div>
</SectionCard>
</div>
{/* VETT OCH ETIKETT */}
<SectionCard
title="Policy: Vett & Etikett"
icon={HeartHandshake}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-sm font-medium text-ebony/80 leading-relaxed">
<RuleItem number={1} title="Var ett proffs" content="Uppträd professionellt, var schysst mot alla och tänk på att vi har absolut nolltolerans mot diskriminering." />
<RuleItem number={2} title="Ingen dötid" content="Stillastående väntan accepteras inte. Har du inget att göra? Fråga ledaren! Du ska inte stå still när andra jobbar." />
<RuleItem number={3} title="Se folk i ögonen" content={<>Inga hörlurar under arbetstid, mobilen är bara ett arbetsverktyg, och <em className="text-slate-teal font-bold">ta av solglasögonen</em> när du pratar med besökare.</>} />
<RuleItem number={4} title="Djur & Natur" content="Respektera djur och natur. Vi rör inte djuren och vi tar hand om våra verktyg." />
</div>
</SectionCard>
{/* SÄKERHET */}
<SectionCard
variant="alert"
title="Risker & Säkerhet"
icon={ShieldAlert}
description="Säkerhet går alltid först! Därför utförs ALLT arbete minst 2 och 2."
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 text-sm text-ebony/90 font-medium">
<RiskItem title="Trafik" content="Vid parkeringar bär vi alltid gul reflexväst." />
<RiskItem title="Klippor" content="Vi håller oss alltid minst 2 meter ifrån klippkanter." />
<RiskItem title="Tunga lyft" content="Tunga föremål lyfter vi alltid med minst två personer." />
<RiskItem title="Ormbett" content="Blir du biten, ring Giftinformationscentralen (010-456 67 00 eller 112) och kontakta projektledaren direkt." colSpanClass="sm:col-span-2 lg:col-span-1" />
<RiskItem title="Djurhagar" content={<>Håll 50 meters avstånd, klappa dem inte. Ropa <em className="font-black text-ebony">"HEJ KOSSORNA/LAMMEN"</em> eller <em className="font-black text-ebony">"MOOOOOOO!"</em> tills de uppmärksammat dig.</>} colSpanClass="sm:col-span-2" />
</div>
</SectionCard>
</div>
);
}
-66
View File
@@ -1,66 +0,0 @@
// 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-3 py-6 md:py-8">
{children}
</main>
</body>
</html>
);
}
-122
View File
@@ -1,122 +0,0 @@
// app/page.tsx
"use client";
import { AlertCircle, AlertTriangle, Info, Loader2, PhoneCall } from 'lucide-react';
import { useEffect, useState } from 'react';
import { readJsonFile } from './actions/jsonEditor';
import { SafePhoneLink } from './components/PhoneLinks';
import { ActionLinkCard } from './components/ui/ActionLinkCard';
import { EmergencyButton } from './components/ui/EmergencyButton';
import { SectionCard } from './components/ui/SectionCard';
const getNoticeConfig = (type: string) => {
switch (type) {
case 'important':
return { icon: AlertCircle, title: 'Viktigt Meddelande', variant: 'alert' as const };
case 'notice':
return { icon: Info, title: 'Information', variant: undefined };
case 'warning':
default:
return { icon: AlertTriangle, title: 'Dagens Påminnelse', variant: 'highlight' as const };
}
};
export default function Home() {
const [notice, setNotice] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let isMounted = true;
const loadNotice = async () => {
const cached = localStorage.getItem('kullaberg_notice_cache');
if (cached) {
setNotice(JSON.parse(cached));
setIsLoading(false);
}
const fetchWithTimeout = new Promise<any>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Timeout')), 5000);
readJsonFile('notice.json').then(res => {
clearTimeout(timer);
resolve(res);
}).catch(err => {
clearTimeout(timer);
reject(err);
});
});
try {
if (navigator.onLine) {
const res = await fetchWithTimeout;
if (res.success && res.data && isMounted) {
setNotice(res.data);
localStorage.setItem('kullaberg_notice_cache', JSON.stringify(res.data));
}
}
} catch (error) {
console.warn("Kunde inte hämta nytt meddelande (Liar-Fi), behåller cache.");
} finally {
if (isMounted) setIsLoading(false);
}
};
loadNotice();
return () => { isMounted = false; };
}, []);
const nConfig = notice ? getNoticeConfig(notice.type) : null;
return (
<div className="space-y-6 animate-fade-in w-full mx-auto">
{/* Welcome Banner */}
<div className="bg-slate-teal rounded-3xl p-6 shadow-sm text-eggshell">
<h1 className="text-3xl font-black tracking-tight mb-1">Välkommen!</h1>
<p className="text-eggshell/90 font-bold">Naturvärdarna Kullaberg Sommar {new Date().getFullYear()}</p>
</div>
{/* Daily Notice Card */}
{isLoading ? (
<div className="flex justify-center py-4"><Loader2 className="animate-spin text-slate-teal" size={24} /></div>
) : (
notice && notice.isActive && nConfig && (
<SectionCard
title={nConfig.title}
icon={nConfig.icon}
variant={nConfig.variant}
description={notice.message}
/>
)
)}
{/* NÖDKNAPPEN */}
<EmergencyButton href="/emergency" title="Nödsituation" />
{/* Quick Action Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<ActionLinkCard href="/schedule" title="Ditt Schema" description="Kolla dina arbetspass och dagliga rundor snabbt." />
<ActionLinkCard href="/faq" title="Vanliga Frågor" description="Snabba svar på turisternas vanligaste funderingar." />
</div>
{/* Contact Card */}
<SectionCard title="Snabbkontakt" icon={PhoneCall}>
<div className="space-y-4 pt-2">
<div className="flex flex-col md:flex-row md:justify-between md:items-center pb-4 border-b border-white gap-2">
<span className="text-sm font-bold text-ebony">William Söderberg</span>
<div className='flex gap-2 flex-wrap md:justify-end text-sm'>
<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-2">
<span className="text-sm font-bold text-ebony">Oliver Nilsson</span>
<div className='flex gap-2 flex-wrap md:justify-end text-sm'>
<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>
</SectionCard>
</div>
);
}
-43
View File
@@ -1,43 +0,0 @@
// 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: new CacheFirst({
cacheName: "kullaberg-files-cache",
plugins: [new ExpirationPlugin({ maxEntries: 20, maxAgeSeconds: 2592000 })],
})
},
...defaultCache,
],
fallbacks: {
entries: [
{
url: "/",
matcher({ request }) {
return request.destination === "document";
},
},
],
},
});
serwist.addEventListeners();
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
-18
View File
@@ -1,18 +0,0 @@
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
@@ -1,82 +0,0 @@
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
+18
View File
@@ -0,0 +1,18 @@
<!-- index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Naturvärdarna | Kullaberg</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-28
View File
@@ -1,28 +0,0 @@
// 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;
}
-31
View File
@@ -1,31 +0,0 @@
// 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: "/schedule", revision: manualRevision },
{ url: "/faq", revision: manualRevision },
{ url: "/info", revision: manualRevision },
{ url: "/emergency", revision: manualRevision },
],
});
const nextConfig: NextConfig = {
output: "standalone",
allowedDevOrigins: [
'10.10.0.121',
'10.11.0.122',
'localhost',
],
};
export default withSerwist(nextConfig);
+3692 -4938
View File
File diff suppressed because it is too large Load Diff
+25 -39
View File
@@ -1,49 +1,35 @@
{ {
"name": "naturvardarna-pwa", "name": "naturvardarna-app",
"version": "0.1.0",
"private": true, "private": true,
"version": "0.0.0",
"type": "module",
"scripts": { "scripts": {
"dev": "next dev --webpack", "dev": "vite --host",
"build": "next build --webpack", "build": "tsc -b && vite build",
"start": "next start", "lint": "eslint .",
"lint": "eslint" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@hono/node-server": "^2.0.1", "@tailwindcss/vite": "^4.2.1",
"@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",
"hono": "^4.12.18",
"localforage": "^1.10.0",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"next": "^16.2.1", "react": "^19.2.4",
"react": "19.2.3", "react-dom": "^19.2.4",
"react-dom": "19.2.3", "react-router-dom": "^7.13.1",
"serwist": "^9.5.7" "tailwindcss": "^4.2.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@eslint/js": "^9.39.4",
"@types/better-sqlite3": "^7.6.13", "@types/node": "^24.12.0",
"@types/node": "^20", "@types/react": "^19.2.14",
"@types/pg": "^8.18.0", "@types/react-dom": "^19.2.3",
"@types/react": "^19", "@vitejs/plugin-react": "^6.0.0",
"@types/react-dom": "^19", "eslint": "^9.39.4",
"eslint": "9.14.0", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-config-next": "16.1.6", "eslint-plugin-react-refresh": "^0.5.2",
"prisma": "^7.5.0", "globals": "^17.4.0",
"tailwindcss": "^4", "typescript": "~5.9.3",
"tsx": "^4.21.0", "typescript-eslint": "^8.56.1",
"typescript": "^5" "vite": "^8.0.0",
}, "vite-plugin-pwa": "^1.2.0"
"overrides": {
"postcss": "^8.5.10",
"@hono/node-server": "$@hono/node-server",
"effect": "^3.20.0",
"@eslint/plugin-kit": "^0.3.4",
"brace-expansion": "^5.0.1",
"minimatch": "^10.0.0",
"lodash": "^4.17.21"
} }
} }
-7
View File
@@ -1,7 +0,0 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
-14
View File
@@ -1,14 +0,0 @@
// prisma.config.ts
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env.DATABASE_URL,
},
});
@@ -1,40 +0,0 @@
-- 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
@@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"
-62
View File
@@ -1,62 +0,0 @@
// 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])
}
model DailyLog {
id String @id @default(cuid())
date String @unique
content String
updatedAt DateTime @updatedAt
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 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.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

-38
View File
@@ -1,38 +0,0 @@
{
"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"
}
]
}
+83
View File
@@ -0,0 +1,83 @@
// src/App.tsx
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom';
import { Calendar, FileText, Home as HomeIcon, Map, Leaf, Briefcase, LifeBuoy, Lock } from 'lucide-react';
import { Home } from './pages/Home';
import { JobInfo } from './pages/JobInfo';
import { FAQ } from './pages/FAQ';
import { Documents } from './pages/Documents';
import { Schedule } from './pages/Schedule';
import { Emergency } from './pages/Emergency';
import { Admin } from './pages/admin/AdminDashboard';
const NavItem = ({ to, icon: Icon, label, className = "" }: { to: string, icon: any, label: string, className?: string }) => {
const location = useLocation();
const isActive = location.pathname === to;
return (
<Link
to={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>
);
};
function AppContent() {
return (
<div 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">
{/* App Title Bar */}
<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>
{/* Scrollable Tab Navigation */}
<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={Map} 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>
</div>
</header>
{/* Main Content Area */}
<main className="max-w-5xl mx-auto p-4 py-6 md:py-8">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/info" element={<JobInfo />} />
<Route path="/faq" element={<FAQ />} />
<Route path="/documents" element={<Documents />} />
<Route path="/schedule" element={<Schedule />} />
<Route path="/emergency" element={<Emergency />} />
<Route path="/admin" element={<Admin />} />
</Routes>
</main>
</div>
);
}
function App() {
return (
<Router>
<AppContent />
</Router>
);
}
export default App;
@@ -1,6 +1,4 @@
// app/components/PhoneLinks.tsx // src/components/PhoneLinks.tsx
"use client";
import React from 'react'; import React from 'react';
-4
View File
@@ -92,10 +92,6 @@
{ {
"q": "Var parkerar man om det är fullt?", "q": "Var parkerar man om det är fullt?",
"a": "Ransviks övre parkering." "a": "Ransviks övre parkering."
},
{
"q": "Är det tillåtet att tälta här?",
"a": "Nej, förr fanns det en tältruta här men den ligger nu uppe vid stora parkeringen"
} }
] ]
} }
@@ -66,18 +66,18 @@
}, },
{ {
"day": "Lördag", "day": "Lördag",
"tumlarna": {
"time": "08:00 - 15:10",
"title": "Tumlarna",
"notes": "Heldagspass"
}
},
{
"day": "Söndag",
"pilgrimsfalkarna": { "pilgrimsfalkarna": {
"time": "08:00 - 15:10", "time": "08:00 - 15:10",
"title": "Pilgrimsfalkarna", "title": "Pilgrimsfalkarna",
"notes": "Heldagspass" "notes": "Heldagspass"
} }
},
{
"day": "Söndag",
"tumlarna": {
"time": "08:00 - 15:10",
"title": "Tumlarna",
"notes": "Heldagspass"
}
} }
] ]
+1 -1
View File
@@ -1,4 +1,4 @@
/* app/globals.css */ /* src/index.css */
@import "tailwindcss"; @import "tailwindcss";
+15
View File
@@ -0,0 +1,15 @@
// src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
import './index.css'
import { registerSW } from 'virtual:pwa-register'
registerSW({ immediate: true })
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+75
View File
@@ -0,0 +1,75 @@
// src/pages/Documents.tsx
import React from 'react';
import { Map, WavesLadder, BookCopy, Download, ExternalLink, Folder, MapPinned, MapPlus } from 'lucide-react';
export const Documents: React.FC = () => {
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 @@
// src/pages/Emergency.tsx
import React from 'react';
import { Phone, AlertTriangle, MapPin, HeartPulse } from 'lucide-react';
import { SafePhoneLink } from '../components/PhoneLinks';
export const Emergency: React.FC = () => {
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>
);
};
+59
View File
@@ -0,0 +1,59 @@
// src/pages/FAQ.tsx
import React, { useState } from 'react';
import faqData from '../data/faq.json';
import { ChevronDown, ChevronUp, MessageCircleQuestionMark } from 'lucide-react';
export const FAQ: React.FC = () => {
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-4 pt-0 text-ebony font-medium text-sm leading-relaxed border-t border-ebony/5 mx-4 mt-2">
{item.a}
</div>
</div>
</div>
);
})}
</div>
</div>
))}
</div>
</div>
);
};
+77
View File
@@ -0,0 +1,77 @@
// src/pages/Home.tsx
import React from 'react';
import { AlertTriangle, PhoneCall, ArrowRight } from 'lucide-react';
import { Link } from 'react-router-dom';
// Import the component!
import { SafePhoneLink } from '../components/PhoneLinks';
export const Home: React.FC = () => {
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 to="/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">
<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 to="/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">
<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>
);
};
+64
View File
@@ -0,0 +1,64 @@
// src/pages/JobInfo.tsx
import React from 'react';
import { Briefcase, CheckCircle2, Navigation } from 'lucide-react';
export const JobInfo: React.FC = () => {
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>
);
};
@@ -1,59 +1,78 @@
// app/schedule/page.tsx // src/pages/Schedule.tsx
"use client" import React, { useRef } from 'react';
import scheduleData from '../data/schedule.json';
import { CalendarRange, Clock, Loader2 } from 'lucide-react'; import { Clock, CalendarRange } from 'lucide-react';
import Image from 'next/image';
import React, { useEffect, useRef, useState } from 'react';
import { readJsonFile } from '../actions/jsonEditor';
import falconIcon from '../assets/falcon.svg';
import porpoiseIcon from '../assets/porpoise.svg';
import { PageHeader } from '../components/ui/PageHeader';
// --- Helper Functions --- // --- Helper Functions ---
// Bulletproof parser: Finds the times regardless of spaces or dashes!
const parseTimeBlock = (timeStr: string) => { const parseTimeBlock = (timeStr: string) => {
if (!timeStr || timeStr === 'Ledig') return null; 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); const matches = timeStr.match(/(\d{1,2}):(\d{2})/g);
if (!matches || matches.length < 2) return null; if (!matches || matches.length < 2) return null;
const parse = (t: string) => { const [h, m] = t.split(':').map(Number); return h + (m / 60); };
const parse = (t: string) => {
const [h, m] = t.split(':').map(Number);
return h + (m / 60);
};
return { start: parse(matches[0]), end: parse(matches[1]) }; return { start: parse(matches[0]), end: parse(matches[1]) };
}; };
// Checks if the two time blocks actually collide
const checkOverlap = (time1?: string, time2?: string) => { const checkOverlap = (time1?: string, time2?: string) => {
const t1 = time1 ? parseTimeBlock(time1) : null; const t1 = time1 ? parseTimeBlock(time1) : null;
const t2 = time2 ? parseTimeBlock(time2) : null; const t2 = time2 ? parseTimeBlock(time2) : null;
if (!t1 || !t2) return false; if (!t1 || !t2) return false;
return t1.start < t2.end && t1.end > t2.start; 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; }
// --- 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 ShiftBlock: React.FC<ShiftBlockProps> = ({ team, data, pos, isOverlapping }) => {
const isPF = team === 'PF'; const isPF = team === 'PF';
const widthClasses = isOverlapping ? isPF ? "left-1 right-1/2 mr-0.5" : "left-1/2 right-1 ml-0.5" : "left-1 right-1";
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";
const iconSrc = isPF ? falconIcon : porpoiseIcon; // 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-ebony to-moss border-moss"
: "bg-gradient-to-br from-seafoam to-slate-teal border-slate-teal";
const textMain = isPF ? "text-eggshell" : "text-eggshell";
const textMuted = isPF ? "text-eggshell/80" : "text-eggshell/90";
const notesBg = isPF ? "bg-eggshell/30 border-seafoam/5" : "bg-ebony/20 border-eggshell/10";
const fallbackTitle = isPF ? "PF" : "TU";
return ( return (
<div <div
className={`absolute ${widthClasses} ${bgClasses} rounded-lg p-1.5 flex flex-col overflow-hidden shadow-sm border transition-colors hover:brightness-105`} // 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` }} style={{ top: `${pos.top}px`, height: `${pos.height}px` }}
> >
<p className={`text-[10px] font-black ${textMain} uppercase tracking-wide leading-tight truncate flex items-center gap-1`}> <p className={`text-[10px] font-black ${textMain} uppercase tracking-wide leading-tight truncate`}>
<Image
src={iconSrc}
alt={team}
width={10}
height={10}
className={`shrink-0 opacity-90 ${!isPF ? 'brightness-0 invert' : ''}`}
/>
{data.title || fallbackTitle} {data.title || fallbackTitle}
</p> </p>
<p className={`text-[9px] font-bold ${textMuted} flex items-center mt-0.5 whitespace-nowrap`}> <p className={`text-[9px] font-bold ${textMuted} flex items-center mt-0.5 whitespace-nowrap`}>
@@ -62,70 +81,60 @@ const ShiftBlock: React.FC<ShiftBlockProps> = ({ team, data, pos, isOverlapping
</p> </p>
{data.notes && pos.height > 50 && ( {data.notes && pos.height > 50 && (
<div className={`mt-1 ${notesBg} p-1 rounded-md border`}> <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> <p className={`text-[9px] font-medium ${textMain} leading-tight truncate`}>
{data.notes}
</p>
</div> </div>
)} )}
</div> </div>
); );
}; };
// --- Main Schedule View --- // --- Main Schedule View ---
export default function Schedule() {
const startHour = 8; export const Schedule: React.FC = () => {
const startHour = 7;
const endHour = 17; const endHour = 17;
const hours = Array.from({ length: endHour - startHour + 1 }, (_, i) => startHour + i); const hours = Array.from({ length: endHour - startHour + 1 }, (_, i) => startHour + i);
const PIXELS_PER_HOUR = 60; const PIXELS_PER_HOUR = 60;
const GRID_PADDING_TOP = 24; const GRID_PADDING_TOP = 24;
const TOTAL_GRID_HEIGHT = (hours.length * PIXELS_PER_HOUR) + GRID_PADDING_TOP; const TOTAL_GRID_HEIGHT = (hours.length * PIXELS_PER_HOUR) + GRID_PADDING_TOP;
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [scheduleData, setScheduleData] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const loadSchedule = async () => {
if (navigator.onLine) {
const res = await readJsonFile('schedule.json');
if (res.success && res.data) {
setScheduleData(res.data);
localStorage.setItem('kullaberg_schedule_cache', JSON.stringify(res.data));
}
} else {
const cachedData = localStorage.getItem('kullaberg_schedule_cache');
if (cachedData) setScheduleData(JSON.parse(cachedData));
}
setIsLoading(false);
};
loadSchedule();
}, []);
const calculatePosition = (timeStr: string) => { const calculatePosition = (timeStr: string) => {
const t = parseTimeBlock(timeStr); const t = parseTimeBlock(timeStr);
if (!t) return null; if (!t) return null;
const topOffset = (t.start - startHour) * PIXELS_PER_HOUR; const topOffset = (t.start - startHour) * PIXELS_PER_HOUR;
const duration = (t.end - t.start) * PIXELS_PER_HOUR; const duration = (t.end - t.start) * PIXELS_PER_HOUR;
return { top: topOffset + GRID_PADDING_TOP + 1, height: duration - 2 }; return { top: topOffset + GRID_PADDING_TOP + 1, height: duration - 2 };
}; };
if (isLoading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-slate-teal" size={40} /></div>;
return ( return (
<div className="space-y-2 animate-fade-in w-full"> <div className="space-y-4 animate-fade-in w-full">
<PageHeader <div>
title="Veckoschema" <h1 className="text-3xl font-black text-slate-teal tracking-tight flex items-center">
icon={CalendarRange} <CalendarRange className="mr-3 text-seafoam" size={32} />
description="Här hittar du när vi bemannar Kullaberg." 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-white/40 backdrop-blur-md border border-white/40 rounded-3xl shadow-sm overflow-hidden flex flex-col max-h-[60vh] h-max min-h-125 w-full"> <div className="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-1 overflow-auto scrollbar-hide" ref={containerRef}>
<div className="flex min-w-200 w-full"> <div className="flex min-w-200 w-full">
<div className="sticky left-0 z-20 w-12 flex-none bg-white/60 backdrop-blur-md border-r border-white/40 shadow-[2px_0_5px_rgba(0,0,0,0.02)]">
<div className="sticky top-0 z-30 h-10 border-b border-white/40 bg-white/40 backdrop-blur-md rounded-tl-3xl"></div> {/* 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` }}> <div className="relative w-full" style={{ height: `${TOTAL_GRID_HEIGHT}px` }}>
{hours.map((hour, i) => ( {hours.map((hour, i) => (
<div key={hour} className="absolute w-full flex justify-center" style={{ top: `${i * PIXELS_PER_HOUR + GRID_PADDING_TOP}px` }}> <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-[10px] font-bold text-slate-teal -mt-2 px-1 rounded "> <span className="text-[11px] font-bold text-slate-teal -mt-2 bg-eggshell px-1">
{hour.toString().padStart(2, '0')}:00 {hour.toString().padStart(2, '0')}:00
</span> </span>
</div> </div>
@@ -133,35 +142,45 @@ export default function Schedule() {
</div> </div>
</div> </div>
{/* Grid Area */}
<div className="flex-auto relative"> <div className="flex-auto relative">
<div className="sticky top-0 z-10 flex h-10 border-b border-white/40 bg-white/60 backdrop-blur-md"> {/* 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) => ( {scheduleData.map((d) => (
<div key={d.day} className="flex-1 flex items-center justify-center border-l border-white/20 first:border-l-0 min-w-25"> <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> <span className="text-xs font-black text-ebony uppercase tracking-widest">{d.day.substring(0, 3)}</span>
</div> </div>
))} ))}
</div> </div>
{/* Main Grid Canvas */}
<div className="relative w-full" style={{ height: `${TOTAL_GRID_HEIGHT}px` }}> <div className="relative w-full" style={{ height: `${TOTAL_GRID_HEIGHT}px` }}>
<div className="absolute inset-0 z-0"> <div className="absolute inset-0 z-0">
{hours.map((hour, i) => ( {hours.map((hour, i) => (
<div key={hour} className="absolute w-full border-t border-white/30" style={{ top: `${i * PIXELS_PER_HOUR + GRID_PADDING_TOP}px`, height: `${PIXELS_PER_HOUR}px` }} /> <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> </div>
{/* Vertical Columns & Blocks */}
<div className="absolute inset-0 flex z-0"> <div className="absolute inset-0 flex z-0">
{scheduleData.map((d, colIndex) => { {scheduleData.map((d, colIndex) => {
// Type casting here assumes scheduleData is typed loosely.
const pfData = d.pilgrimsfalkarna as ShiftData | undefined; const pfData = d.pilgrimsfalkarna as ShiftData | undefined;
const tuData = d.tumlarna as ShiftData | undefined; const tuData = d.tumlarna as ShiftData | undefined;
const hasPF = !!(pfData && pfData.time && pfData.time !== 'Ledig'); const hasPF = !!(pfData && pfData.time && pfData.time !== 'Ledig');
const hasTU = !!(tuData && tuData.time && tuData.time !== 'Ledig'); const hasTU = !!(tuData && tuData.time && tuData.time !== 'Ledig');
const isOverlapping = hasPF && hasTU && checkOverlap(pfData.time, tuData.time); const isOverlapping = hasPF && hasTU && checkOverlap(pfData.time, tuData.time);
return ( return (
<div key={colIndex} className="flex-1 border-l border-white/30 relative min-w-25"> <div key={colIndex} className="flex-1 border-l border-slate-teal/10 relative min-w-25">
{/* Pilgrimsfalkarna Component */}
{hasPF && (() => { {hasPF && (() => {
const pos = calculatePosition(pfData.time); const pos = calculatePosition(pfData.time);
return pos ? <ShiftBlock team="PF" data={pfData} pos={pos} isOverlapping={isOverlapping} /> : null; return pos ? <ShiftBlock team="PF" data={pfData} pos={pos} isOverlapping={isOverlapping} /> : null;
})()} })()}
{/* Tumlarna Component */}
{hasTU && (() => { {hasTU && (() => {
const pos = calculatePosition(tuData.time); const pos = calculatePosition(tuData.time);
return pos ? <ShiftBlock team="TU" data={tuData} pos={pos} isOverlapping={isOverlapping} /> : null; return pos ? <ShiftBlock team="TU" data={tuData} pos={pos} isOverlapping={isOverlapping} /> : null;
@@ -176,20 +195,11 @@ export default function Schedule() {
</div> </div>
</div> </div>
<div className="flex items-center justify-center space-x-6 py-2 text-xs font-bold text-ebony"> {/* Legend */}
<span className="flex items-center"> <div className="flex items-center justify-center space-x-6 py-2 text-xs font-bold text-slate-teal">
<div className="w-6 h-6 bg-linear-to-br from-gold to-goldenrod rounded-lg mr-2 shadow-sm flex items-center justify-center"> <span className="flex items-center"><div className="w-3 h-3 bg-linear-to-br from-ebony to-moss rounded-full mr-2 shadow-sm"></div> Pilgrimsfalkarna</span>
<Image src={falconIcon} alt="PF" width={14} height={14} /> <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>
Pilgrimsfalkarna
</span>
<span className="flex items-center">
<div className="w-6 h-6 bg-linear-to-br from-seafoam to-slate-teal rounded-lg mr-2 shadow-sm flex items-center justify-center">
<Image src={porpoiseIcon} alt="TU" width={14} height={14} className="brightness-0 invert" />
</div>
Tumlarna
</span>
</div> </div>
</div> </div>
); );
} };
+100
View File
@@ -0,0 +1,100 @@
// src/pages/admin/AdminDashboard.tsx
import { CalendarRange, ClipboardCheck, Loader2, Lock, Unlock, Users as UsersIcon } from 'lucide-react';
import React, { useState } from 'react';
import { MOCK_USERS } from './adminTypes';
import { AttendanceTab } from './AttendanceTab';
import { ReportTab } from './ReportTab';
import { SetupTab } from './SetupTab';
import { useAdminState } from './useAdminState';
export const Admin: React.FC = () => {
const [currentUser, setCurrentUser] = useState<typeof MOCK_USERS[0] | null>(null);
const [selectedUserId, setSelectedUserId] = useState(MOCK_USERS[0].id);
const [pin, setPin] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [activeTab, setActiveTab] = useState<'setup' | 'today' | 'report'>('report');
const adminState = useAdminState();
const handleLogin = (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setTimeout(() => {
const user = MOCK_USERS.find(u => u.id === selectedUserId && u.pin === pin);
if (user) {
setCurrentUser(user);
if (user.role === 'Viewer') {
setActiveTab('report');
} else if (user.role === 'Staff') {
setActiveTab('today');
} else {
setActiveTab(adminState.periods.length > 0 ? 'today' : 'setup');
}
} else {
setPin('');
}
setIsLoading(false);
}, 600);
};
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">
<select value={selectedUserId} onChange={(e) => setSelectedUserId(e.target.value)} 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">
{MOCK_USERS.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
<input type="password" inputMode="numeric" value={pin} onChange={(e) => setPin(e.target.value)} placeholder="••••" className="w-full bg-eggshell/50 border-2 border-slate-teal/30 text-center text-2xl tracking-[0.5em] text-ebony font-mono p-3 rounded-xl focus:outline-none focus:border-slate-teal" />
<button type="submit" disabled={isLoading} className="w-full bg-slate-teal text-eggshell font-black uppercase py-3 rounded-xl hover:bg-ebony transition-colors">
{isLoading ? <Loader2 className="animate-spin mx-auto" /> : 'Logga in'}
</button>
</form>
</div>
</div>
);
}
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">
<h1 className="text-3xl font-black text-slate-teal uppercase flex items-center"><Unlock className="mr-3 text-seafoam" size={28} /> Admin</h1>
<div className="text-right">
<p className="text-xs font-bold text-moss mb-1">Inloggad: {currentUser.name}</p>
<button onClick={() => { setCurrentUser(null); setPin(''); }} className="text-sm font-bold text-ebony/60 hover:text-goldenrod">Logga ut</button>
</div>
</div>
{availableTabs.length > 1 && (
<div className="border-b border-slate-teal/20 flex gap-2">
{availableTabs.map(tab => (
<button key={tab.id} onClick={() => setActiveTab(tab.id as any)} className={`flex items-center 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" /> {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} 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>
);
};
View File
+303
View File
@@ -0,0 +1,303 @@
// src/pages/admin/AttendanceTab.tsx
import { CheckCircle, ChevronLeft, ChevronRight, ClipboardCheck, Edit3, FileText, Undo } from 'lucide-react';
import React, { useEffect, useState } from 'react';
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;
addPendingAttendance: (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => void;
removeAttendanceEntry: (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => void;
activePeriodId: string;
setActivePeriodId: (id: string) => void;
}
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;
if (daySchedule.pilgrimsfalkarna && daySchedule.pilgrimsfalkarna.time !== 'Ledig') {
hasWork = true;
const pfYouth = period.youthList.filter(y => y.team === 'PF');
expected += pfYouth.length;
// Don't count "Pending" as completed!
pfYouth.forEach(y => {
const entry = attendance[getAttendanceKey(date, y.id, 'MORNING')];
if (entry && entry.status !== 'Pending') completed++;
});
}
if (daySchedule.tumlarna && daySchedule.tumlarna.time !== 'Ledig') {
hasWork = true;
const tuYouth = period.youthList.filter(y => y.team === 'TU');
expected += tuYouth.length;
tuYouth.forEach(y => {
const entry = attendance[getAttendanceKey(date, y.id, 'AFTERNOON')];
if (entry && entry.status !== 'Pending') completed++;
});
}
return { expected, completed, isComplete: expected > 0 && completed >= expected, hasWork };
};
export const AttendanceTab: React.FC<Props> = ({ periods, attendance, setManualAttendance, 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 shiftTime = team === 'PF' ? daySchedule?.pilgrimsfalkarna?.time : daySchedule?.tumlarna?.time;
const rawHours = calculateShiftDuration(shiftTime);
const actualHours = isWeekend(currentDate) ? Math.max(0, rawHours - 0.5) : rawHours;
if (actualHours > 0) setManualAttendance(currentDate, youthId, shiftId, 'Present', actualHours);
};
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>
) : (
['PF', 'TU'].map(team => {
const standardShift = team === 'PF' ? daySchedule.pilgrimsfalkarna : daySchedule.tumlarna;
if (!standardShift || standardShift.time === 'Ledig') return null;
const rawDuration = calculateShiftDuration(standardShift.time);
const shiftId = team === 'PF' ? 'MORNING' : 'AFTERNOON';
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)]);
const displayYouth = [...scheduledYouth, ...extraYouth];
const availableExtras = activePeriod.youthList.filter(y => !displayYouth.some(dy => dy.id === y.id));
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">
<span className={`w-3 h-3 rounded-full ${team === 'PF' ? 'bg-gold' : 'bg-seafoam'} mr-3`}></span>
{team === 'PF' ? 'Pilgrimsfalkarna' : 'Tumlarna'}
</h2>
<div className="flex flex-wrap 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">{standardShift.time} ({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 lunch) x1.5 = +{weightedDuration.toFixed(1)}t pott</span>}
</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 ${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">
{/* Status Display Area */}
{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>
)}
{/* Action Buttons */}
<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) {
// We now add them safely as Pending!
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} ({y.team})</option>
))}
</select>
</div>
)}
</div>
</div>
);
})
)}
</div>
);
};
+225
View File
@@ -0,0 +1,225 @@
// src/pages/admin/ReportTab.tsx
import { AlertTriangle, ChevronDown, ChevronUp, Clock, Download, Users } from 'lucide-react';
import React, { useState } from 'react';
import { type AttendanceDataMap, formatTimeHHMM, isWeekend, type Period, type Role } from './adminTypes';
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 shift = entry.shiftId === 'MORNING' ? 'Morgon' : 'Eftermiddag';
const worked = formatTimeHHMM(entry.hoursWorked);
const weighted = entry.weightedHours.toFixed(2).replace('.', ',');
const note = entry.note || '';
csvContent += `${entry.date};${shift};${youth.name};${youth.team};${entry.status};${worked};${weighted};${note}\n`;
});
csvContent += "\nSummering (Timpott)\nNamn;Lag;Total Viktad Pott\n";
activePeriod.youthList.forEach(youth => {
const total = getPeriodHoursTotal(youth.id).toFixed(2).replace('.', ',');
csvContent += `${youth.name};${youth.team};${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);
};
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} />
Timpott (Max {POT_HOUR_LIMIT}t)
</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">
{activePeriod.youthList.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">
<span className={`w-3 h-3 rounded-full ${youth.team === 'PF' ? 'bg-gold' : 'bg-seafoam'}`}></span>
<span className="font-bold text-ebony text-lg">{youth.name} <span className="text-xs font-bold text-moss ml-1">({youth.team})</span></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);
// FEATURE: Figure out if this shift was an extra shift for this youth!
const isExtra = (youth.team === 'PF' && entry.shiftId === 'AFTERNOON') ||
(youth.team === 'TU' && entry.shiftId === 'MORNING');
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">
{entry.shiftId === 'MORNING' ? 'Morgon' : 'Eftermiddag'}
</span>
{/* NEW: Display the extra shift badge! */}
{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>
);
};
+108
View File
@@ -0,0 +1,108 @@
// src/pages/admin/SetupTab.tsx
import { CalendarRange, Trash2, UserPlus } from 'lucide-react';
import React, { useState } from 'react';
import { type Period } 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;
}
export const SetupTab: React.FC<Props> = ({ periods, createPeriod, deletePeriod, bulkAddYouth, removeYouth }) => {
const [bulkText, setBulkText] = useState('');
const [bulkTeam, setBulkTeam] = useState<'PF' | 'TU'>('PF');
const [expandedPeriod, setExpandedPeriod] = useState<string | null>(null);
const handleCreate = () => {
const name = (document.getElementById('periodName') as HTMLInputElement).value;
const start = (document.getElementById('periodStart') as HTMLInputElement).value;
if (name && start) createPeriod(name, start);
};
// FEATURE: Confirmation Prompts
const handleDeletePeriod = (id: string) => {
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 (window.confirm(`Är du säker på att du vill ta bort ${youthName} från perioden?`)) {
removeYouth(periodId, youthId);
}
};
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" />
<input type="date" id="periodStart" className="w-full bg-white border border-slate-teal/20 p-3 rounded-lg font-bold" />
<button onClick={handleCreate} className="bg-slate-teal text-eggshell font-black uppercase px-6 py-3 rounded-lg hover:bg-ebony transition-colors">
Starta
</button>
</div>
</div>
{/* List Existing Periods */}
{periods.map(period => (
<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="text-goldenrod/80 hover:text-goldenrod p-2">
<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, TU'"
className="w-full h-24 bg-white border border-slate-teal/20 p-3 rounded-lg font-bold resize-none"
/>
<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">
<option value="PF">Standard: PF</option>
<option value="TU">Standard: TU</option>
</select>
<button onClick={() => { bulkAddYouth(period.id, bulkText, bulkTeam); setBulkText(''); }} className="bg-slate-teal text-eggshell font-black uppercase px-6 py-3 rounded-lg hover:bg-ebony transition-colors h-full">
Importera
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{period.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">
<span className={`w-3 h-3 rounded-full ${youth.team === 'PF' ? 'bg-gold' : 'bg-seafoam'}`}></span>
<span className="font-bold text-ebony">{youth.name} <span className="text-xs text-moss">({youth.team})</span></span>
</div>
<button onClick={() => handleRemoveYouth(period.id, youth.id, youth.name)} className="text-goldenrod hover:text-red-500"><Trash2 size={16} /></button>
</div>
))}
</div>
</div>
)}
</div>
))}
</div>
);
};
@@ -1,12 +1,13 @@
// app/admin/adminTypes.ts // src/pages/admin/adminTypes.ts
export type Role = 'Admin' | 'Staff' | 'Viewer'; export type Role = 'Admin' | 'Staff' | 'Viewer';
export interface AppUser { export const MOCK_USERS: { id: string, name: string, pin: string, role: Role }[] = [
id: string; { id: '1', name: 'William', pin: '1111', role: 'Admin' },
name: string; { id: '2', name: 'Oliver', pin: '2222', role: 'Admin' },
role: Role; { id: '3', name: 'Vikarie / Gäst', pin: '3333', role: 'Staff' },
} { id: '4', name: 'Ungdom (Endast visning)', pin: '0000', role: 'Viewer' }
];
export interface Youth { export interface Youth {
id: string; id: string;
+120
View File
@@ -0,0 +1,120 @@
// src/pages/admin/useAdminState.ts
import { useEffect, useState } from 'react';
import { type AttendanceDataMap, type Period, type Youth, getAttendanceKey, isWeekend, toIsoDate } from './adminTypes';
export const useAdminState = () => {
const [periods, setPeriods] = useState<Period[]>([]);
const [attendance, setAttendance] = useState<AttendanceDataMap>({});
const [activePeriodId, setActivePeriodId] = useState<string>('');
useEffect(() => {
if (!activePeriodId && periods.length > 0) {
setActivePeriodId(periods[0].id);
}
}, [periods, activePeriodId]);
const createPeriod = (name: string, startDateStr: string) => {
const start = new Date(startDateStr + 'T12:00:00');
const end = new Date(start);
end.setDate(start.getDate() + 20);
const newPeriod: Period = {
id: Date.now().toString(),
name,
startDate: toIsoDate(start),
endDate: toIsoDate(end),
youthList: []
};
setPeriods([...periods, newPeriod]);
setActivePeriodId(newPeriod.id);
};
const deletePeriod = (periodId: string) => {
const updatedPeriods = periods.filter(p => p.id !== periodId);
setPeriods(updatedPeriods);
if (activePeriodId === periodId) {
setActivePeriodId(updatedPeriods.length > 0 ? updatedPeriods[0].id : '');
}
};
const bulkAddYouth = (periodId: string, text: string, defaultTeam: 'PF' | 'TU') => {
const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0);
const newYouth: Youth[] = lines.map((line, idx) => {
const parts = line.split(/[,|-]/).map(p => p.trim());
const name = parts[0];
let team = defaultTeam;
if (parts.length > 1) {
const teamInput = parts[1].toUpperCase();
if (teamInput === 'PF' || teamInput === 'TU') team = teamInput;
}
return { id: `bulk-${Date.now()}-${idx}`, name, team };
});
setPeriods(periods.map(p => {
if (p.id === periodId) {
return { ...p, youthList: [...p.youthList, ...newYouth] };
}
return p;
}));
};
const removeYouth = (periodId: string, youthId: string) => {
setPeriods(periods.map(p => {
if (p.id === periodId) {
return { ...p, youthList: p.youthList.filter(y => y.id !== youthId) };
}
return p;
}));
};
const setManualAttendance = (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 = 0;
let hoursWorked = 0;
if (status === 'Late' || status === 'Present') {
hoursWorked = hoursManual;
weightedHours = hoursManual * weight;
}
const key = getAttendanceKey(date, youthId, shiftId);
setAttendance(prev => ({
...prev,
[key]: { date, youthId, shiftId, hoursWorked, weightedHours, status, note: note || '' }
}));
};
// NEW: Adds an entry safely as Pending with 0 hours
const addPendingAttendance = (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => {
const key = getAttendanceKey(date, youthId, shiftId);
setAttendance(prev => ({
...prev,
[key]: { date, youthId, shiftId, hoursWorked: 0, weightedHours: 0, status: 'Pending', note: '' }
}));
};
const removeAttendanceEntry = (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => {
const key = getAttendanceKey(date, youthId, shiftId);
setAttendance(prev => {
const next = { ...prev };
delete next[key];
return next;
});
};
return {
periods,
attendance,
activePeriodId,
setActivePeriodId,
createPeriod,
deletePeriod,
bulkAddYouth,
removeYouth,
setManualAttendance,
addPendingAttendance,
removeAttendanceEntry
};
};
+4
View File
@@ -0,0 +1,4 @@
// src/vite-env.d.ts
/// <reference types="vite-plugin-pwa/client" />
/// <reference types="vite/client" />
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
+5 -32
View File
@@ -1,34 +1,7 @@
{ {
"compilerOptions": { "files": [],
"target": "ES2017", "references": [
"lib": ["dom", "dom.iterable", "esnext"], { "path": "./tsconfig.app.json" },
"allowJs": true, { "path": "./tsconfig.node.json" }
"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"]
} }
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+41
View File
@@ -0,0 +1,41 @@
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite'
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
react(),
tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg'],
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,json}']
},
manifest: {
name: 'Kullaberg Sommarjobb 2025',
short_name: 'Kullaberg',
description: 'Info, schema och dokument för naturvägledare på Kullaberg.',
theme_color: '#15803d',
background_color: '#f9fafb',
display: 'standalone',
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any maskable'
}
]
}
})
]
});