Compare commits
@@ -0,0 +1,38 @@
|
|||||||
|
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
name: Publish Docker image
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
push_to_registry:
|
||||||
|
name: Build and Push Docker image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out the repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.stws.cc
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels)
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: git.stws.cc/${{ github.repository }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=tag
|
||||||
|
type=raw,value=latest
|
||||||
|
|
||||||
|
- name: Build and push Docker images
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
+55
-20
@@ -1,26 +1,61 @@
|
|||||||
*.csv
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
logs
|
generated/
|
||||||
*.log
|
# SQLite Database & Docker Volume files
|
||||||
|
*.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*
|
|
||||||
|
|
||||||
node_modules
|
# env files (can opt-in for committing if needed)
|
||||||
dist
|
.env*
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
|
|
||||||
# Editor directories and files
|
# vercel
|
||||||
.vscode/*
|
.vercel
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
# typescript
|
||||||
.DS_Store
|
*.tsbuildinfo
|
||||||
*.suo
|
next-env.d.ts
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
/app/generated/prisma
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
|
|||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
# 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"]
|
||||||
@@ -1,73 +1,3 @@
|
|||||||
# React + TypeScript + Vite
|
# Naturvärdarna PWA
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
Källkod för [app.naturvardarna.com](https://app.naturvardarna.com/)
|
||||||
|
|
||||||
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...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
// 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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// app/actions/files.ts
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import fs from 'fs';
|
||||||
|
import fsPromises from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const isProd = process.env.NODE_ENV === "production";
|
||||||
|
const DATA_DIR = isProd ? "/app/data" : path.join(process.cwd(), "app/data");
|
||||||
|
const FILES_DIR = path.join(DATA_DIR, "files");
|
||||||
|
|
||||||
|
const generateFileHash = (filePath: string): Promise<string> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const hash = crypto.createHash('md5');
|
||||||
|
const stream = fs.createReadStream(filePath);
|
||||||
|
|
||||||
|
stream.on('error', (err) => reject(err));
|
||||||
|
stream.on('data', (chunk) => hash.update(chunk));
|
||||||
|
stream.on('end', () => resolve(hash.digest('hex').substring(0, 8)));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getLocalFileMeta(fileUrl: string) {
|
||||||
|
try {
|
||||||
|
const cleanName = decodeURIComponent(fileUrl.split('/').pop() || '');
|
||||||
|
const fullPath = path.join(FILES_DIR, cleanName);
|
||||||
|
const stats = await fsPromises.stat(fullPath);
|
||||||
|
const sizeMb = (stats.size / (1024 * 1024)).toFixed(2) + ' MB';
|
||||||
|
const fileHash = await generateFileHash(fullPath);
|
||||||
|
|
||||||
|
return { size: sizeMb, version: fileHash };
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Kunde inte läsa filen (actions): ${fileUrl}`, error);
|
||||||
|
return { size: "Okänd", version: "v1" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// app/actions/jsonEditor.ts
|
||||||
|
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const isProd = process.env.NODE_ENV === "production";
|
||||||
|
const DATA_DIR = isProd ? "/app/data" : path.join(process.cwd(), "app/data");
|
||||||
|
|
||||||
|
export async function readJsonFile(filename: string) {
|
||||||
|
try {
|
||||||
|
const filePath = path.join(DATA_DIR, filename);
|
||||||
|
const fileContent = await fs.readFile(filePath, 'utf-8');
|
||||||
|
return { success: true, data: JSON.parse(fileContent) };
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Kunde inte läsa ${filename}:`, error);
|
||||||
|
return { success: false, data: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeJsonFile(filename: string, data: any) {
|
||||||
|
try {
|
||||||
|
const filePath = path.join(DATA_DIR, filename);
|
||||||
|
await fs.mkdir(DATA_DIR, { recursive: true });
|
||||||
|
|
||||||
|
await fs.writeFile(filePath, JSON.stringify(data, null, 4), 'utf-8');
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Kunde inte spara ${filename}:`, error);
|
||||||
|
return { success: false, error: "Kunde inte spara filen." };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// app/admin/LogTab.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { NotebookText, CalendarRange, CheckCircle, ChevronLeft, ChevronRight, Save, AlertTriangle, FileText, Download } from 'lucide-react';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { type Period, toIsoDate } from './adminTypes';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
periods: Period[];
|
||||||
|
dailyLogs: Record<string, string>;
|
||||||
|
setDailyLog: (date: string, content: string) => void;
|
||||||
|
activePeriodId: string;
|
||||||
|
setActivePeriodId: (id: string) => void;
|
||||||
|
scheduleData: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDaysInPeriod = (start: string, end: string) => {
|
||||||
|
const days = []; let curr = new Date(start + 'T12:00:00'); const endDate = new Date(end + 'T12:00:00');
|
||||||
|
while (curr <= endDate) { days.push(toIsoDate(curr)); curr.setDate(curr.getDate() + 1); }
|
||||||
|
return days;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LogTab: React.FC<Props> = ({ periods, dailyLogs, setDailyLog, activePeriodId, setActivePeriodId, scheduleData }) => {
|
||||||
|
const activePeriod = periods.find(p => p.id === activePeriodId);
|
||||||
|
|
||||||
|
const [currentDate, setCurrentDate] = useState<string>(() => {
|
||||||
|
const today = toIsoDate(new Date());
|
||||||
|
if (activePeriod && today >= activePeriod.startDate && today <= activePeriod.endDate) return today;
|
||||||
|
return activePeriod ? activePeriod.startDate : today;
|
||||||
|
});
|
||||||
|
|
||||||
|
const [currentText, setCurrentText] = useState("");
|
||||||
|
const [saveStatus, setSaveStatus] = useState<'idle' | 'saved'>('idle');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentText(dailyLogs[currentDate] || "");
|
||||||
|
setSaveStatus('idle');
|
||||||
|
}, [currentDate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (saveStatus === 'idle' && currentText === (dailyLogs[currentDate] || "")) {
|
||||||
|
setCurrentText(dailyLogs[currentDate] || "");
|
||||||
|
}
|
||||||
|
}, [dailyLogs, currentDate]);
|
||||||
|
|
||||||
|
if (!activePeriod) return <p className="text-center font-bold text-slate-teal mt-10">Ingen period aktiv.</p>;
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
setDailyLog(currentDate, currentText);
|
||||||
|
setSaveStatus('saved');
|
||||||
|
setTimeout(() => setSaveStatus('idle'), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeDate = (days: number) => {
|
||||||
|
const newDateObj = new Date(currentDate + 'T12:00:00'); newDateObj.setDate(newDateObj.getDate() + days);
|
||||||
|
const startObj = new Date(activePeriod.startDate + 'T12:00:00'); const endObj = new Date(activePeriod.endDate + 'T12:00:00');
|
||||||
|
if (newDateObj >= startObj && newDateObj <= endObj) setCurrentDate(toIsoDate(newDateObj));
|
||||||
|
};
|
||||||
|
|
||||||
|
const dayNameStr = new Date(currentDate + 'T12:00:00').toLocaleDateString('sv-SE', { weekday: 'long' });
|
||||||
|
const timelineDays = getDaysInPeriod(activePeriod.startDate, activePeriod.endDate);
|
||||||
|
const todayIso = toIsoDate(new Date());
|
||||||
|
|
||||||
|
const exportToCsv = () => {
|
||||||
|
if (!activePeriod) return;
|
||||||
|
let csvContent = "Datum;Logg\n";
|
||||||
|
const sortedDays = Object.keys(dailyLogs).sort((a, b) => a.localeCompare(b));
|
||||||
|
sortedDays.forEach(date => {
|
||||||
|
if (date >= activePeriod.startDate && date <= activePeriod.endDate) {
|
||||||
|
const logContent = dailyLogs[date] || "";
|
||||||
|
const cleanContent = logContent
|
||||||
|
.replace(/\n/g, " ")
|
||||||
|
.replace(/;/g, ",")
|
||||||
|
.replace(/"/g, '""');
|
||||||
|
|
||||||
|
csvContent += `${date};"${cleanContent}"\n`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = new Blob(["\uFEFF" + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.setAttribute("href", url);
|
||||||
|
link.setAttribute("download", `Logg_${activePeriod.name.replace(/ /g, '_')}.csv`);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
{/* Datumskrollare likt AttendanceTab */}
|
||||||
|
<div className="rounded-3xl shadow-sm transition-all duration-300 bg-white/40 backdrop-blur-md border border-white/40 p-3 sm:p-5 hover:shadow-md">
|
||||||
|
<div className="flex justify-between items-center mb-3">
|
||||||
|
<h2 className="text-sm font-black text-ebony uppercase tracking-widest flex items-center mb-2">
|
||||||
|
<CalendarRange className="mr-2 text-slate-teal" size={20} /> Journalöversikt
|
||||||
|
</h2>
|
||||||
|
<select value={activePeriodId} onChange={(e) => setActivePeriodId(e.target.value)} className="bg-white border border-slate-teal/10 py-1.5 px-3 rounded-xl font-bold text-slate-teal text-xs cursor-pointer focus:outline-none shadow-sm">
|
||||||
|
{periods.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex overflow-x-auto gap-2.5 pb-3 pt-1 px-2 scrollbar-hide">
|
||||||
|
{timelineDays.map(day => {
|
||||||
|
const dayName = new Date(day + 'T12:00:00').toLocaleDateString('sv-SE', { weekday: 'long' }).toLowerCase();
|
||||||
|
const daySchedule = scheduleData.find(d => d.day.toLowerCase() === dayName);
|
||||||
|
const hasWork = daySchedule && ((daySchedule.pilgrimsfalkarna?.time && daySchedule.pilgrimsfalkarna.time !== 'Ledig') || (daySchedule.tumlarna?.time && daySchedule.tumlarna.time !== 'Ledig'));
|
||||||
|
|
||||||
|
const hasLog = !!dailyLogs[day] && dailyLogs[day].trim() !== "";
|
||||||
|
const isSelected = day === currentDate;
|
||||||
|
|
||||||
|
let bgClass = "bg-white text-ebony border-slate-teal/10";
|
||||||
|
if (!hasWork) bgClass = "bg-slate-teal/5 text-ebony/40 border-transparent";
|
||||||
|
else if (hasLog) bgClass = "bg-moss text-white border-moss shadow-inner";
|
||||||
|
else if (day <= todayIso) bgClass = "bg-goldenrod text-white border-goldenrod shadow-inner";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button key={day} onClick={() => setCurrentDate(day)} className={`flex flex-col items-center justify-center min-w-14 p-2 rounded-xl border transition-colors ${bgClass} ${isSelected ? 'ring-2 ring-slate-teal ring-offset-2 ring-offset-eggshell' : 'hover:brightness-95 shadow-sm'}`}>
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-wider">{new Date(day + 'T12:00:00').toLocaleDateString('sv-SE', { weekday: 'short' })}</span>
|
||||||
|
<span className="text-sm font-black">{new Date(day + 'T12:00:00').getDate()}/{new Date(day + 'T12:00:00').getMonth() + 1}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-row justify-between items-center rounded-3xl shadow-sm transition-all duration-300 bg-white/40 backdrop-blur-md border border-white/40 p-3 sm:p-5 hover:shadow-md gap-4">
|
||||||
|
<button onClick={() => changeDate(-1)} className="p-3 text-slate-teal bg-slate-teal/10 hover:bg-slate-teal/30 rounded-2xl transition-colors"><ChevronLeft size={24} /></button>
|
||||||
|
<div className="text-center flex-1">
|
||||||
|
<h2 className="text-lg font-black text-ebony capitalize mb-0.5">{dayNameStr}</h2>
|
||||||
|
<p className="text-[11px] font-black uppercase tracking-widest text-moss">{currentDate}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => changeDate(1)} className="p-3 text-slate-teal bg-slate-teal/10 hover:bg-slate-teal/30 rounded-2xl transition-colors"><ChevronRight size={24} /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skrivyta */}
|
||||||
|
<div className="rounded-3xl shadow-sm transition-all duration-300 bg-white/40 backdrop-blur-md border border-white/40 p-3 sm:p-5 hover:shadow-md">
|
||||||
|
<h2 className="text-xl font-black text-ebony uppercase tracking-widest flex items-center mb-6">
|
||||||
|
<div className="bg-white p-2.5 rounded-xl mr-3 text-slate-teal shadow-sm">
|
||||||
|
<NotebookText size={24} />
|
||||||
|
</div>
|
||||||
|
Daglig Journal
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={currentText}
|
||||||
|
onChange={(e) => setCurrentText(e.target.value)}
|
||||||
|
placeholder="Vad har hänt idag? Något trasigt staket? Spännande djurobservation? Sur turist?"
|
||||||
|
className="w-full h-48 bg-white border border-slate-teal/10 p-4 rounded-2xl text-sm font-medium resize-none focus:outline-none focus:border-slate-teal shadow-sm mb-6"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
{saveStatus === 'saved' && <span className="text-xs font-bold text-moss flex items-center"><CheckCircle size={14} className="mr-1" /> Sparat!</span>}
|
||||||
|
{dailyLogs[currentDate] && currentText !== dailyLogs[currentDate] && <span className="text-xs font-bold text-goldenrod flex items-center"><AlertTriangle size={14} className="mr-1" /> Osparade ändringar</span>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="bg-slate-teal text-eggshell font-black uppercase tracking-widest text-xs px-6 py-3 rounded-xl hover:bg-ebony transition-colors shadow-sm flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Save size={16} /> Spara
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="flex flex-row justify-center items-center p-3 sm:p-5">
|
||||||
|
<button onClick={exportToCsv} className="flex items-center justify-center gap-2 bg-seafoam text-eggshell font-black uppercase tracking-widest text-[10px] px-5 py-3 rounded-xl hover:bg-slate-teal transition-colors shadow-sm">
|
||||||
|
<Download size={16} /> Exportera Journal till Excel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
// 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 på 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// 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. 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
// src/pages/admin/adminTypes.ts
|
// app/admin/adminTypes.ts
|
||||||
|
|
||||||
export type Role = 'Admin' | 'Staff' | 'Viewer';
|
export type Role = 'Admin' | 'Staff' | 'Viewer';
|
||||||
|
|
||||||
export const MOCK_USERS: { id: string, name: string, pin: string, role: Role }[] = [
|
export interface AppUser {
|
||||||
{ id: '1', name: 'William', pin: '1111', role: 'Admin' },
|
id: string;
|
||||||
{ id: '2', name: 'Oliver', pin: '2222', role: 'Admin' },
|
name: string;
|
||||||
{ id: '3', name: 'Vikarie / Gäst', pin: '3333', role: 'Staff' },
|
role: Role;
|
||||||
{ id: '4', name: 'Ungdom (Endast visning)', pin: '0000', role: 'Viewer' }
|
}
|
||||||
];
|
|
||||||
|
|
||||||
export interface Youth {
|
export interface Youth {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
// 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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 64 64">
|
||||||
|
<!-- Generator: Adobe Illustrator 30.1.0, SVG Export Plug-In . SVG Version: 2.1.1 Build 136) -->
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.st0 {
|
||||||
|
fill: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<path class="st0" d="M51.7,9.8c.5,0,.9.2,1.2.6.2.3.2.7.3,1.1.2.8.6,1.5,1.2,2,1,.9,2.3,1.2,3.5.8.6-.3,1.1-.7,1.4-1.3,0-.3-.2-.6-.2-1,0-.5.2-1,.6-1.3.5-.6,1.4-.8,1.8-1.5.4-.9.4-1.9-.1-2.7-.7-1-1.6-1.7-2.7-2.2-1.6-.8-3.3-1.1-5.1-1.1-1.6,0-3.1.6-4.3,1.5-1.5,1.1-2.7,2.7-3.4,4.4-.6,1.5-1.3,2.9-2.2,4.2-1.4,1.6-3,3-4.9,4.1-2.7,1.9-5.1,4.1-7.3,6.6-2.4,2.8-4.7,5.8-6.9,8.7-4.1,5.3-8.6,10.4-13.4,15.1-2.2,2.2-4.5,4.3-6.9,6.3-.8.6-1.6,1.3-2.2,2.1-.3.5-.6,1-.8,1.5.7,0,1.5,0,2.2-.2,1.5-.4,3-1.1,4.3-2,1.7-1,3.3-1.9,5-2.9,3-1.8,6-3.7,9-5.5.7-.4,1.5-.7,2.3-.9,2.5-.8,5-1.6,7.5-2.4,1.6-.5,3.4-.4,4.9.3,1.2.7,2.4,1.6,3.5,2.5,1.4,1.1,3,2,4.5,2.9,1.1.6,1.9,1.5,2.4,2.6-2,.3-3.8,1-5.6,1.9-1.4.7-2.6,1.5-3.8,2.5-1,.8-1.7,1.8-2.2,3,.6.3,1.2.7,1.8,1,.4-.4.8-.8,1.3-1.1,1.8-1.3,3.9-2.4,6-3.1,2.8-1.1,5.8-1.7,8.8-2,1,0,1.9-.4,2.6-1.1,0,0,0,0,0,0-1.3-.7-2.8-1-4.2-1,.5-.1,1-.2,1.5-.2,0-.4-.4-.7-.7-.8-.6-.2-1.2-.3-1.8-.2-.6,0-1.2-.3-1.7-.6-1.3-.8-2.5-1.8-3.7-2.8-.7-.5-1.2-1.2-1.6-2-.2-.7,0-1.5.4-2.1.5-.7,1.2-1.4,1.9-1.9,2-1.5,3.9-3.1,5.7-4.9,2.5-2.5,4.5-5.3,6.1-8.5.8-1.6,1.3-3.3,1.6-5.1.3-1.9.2-3.8-.3-5.6,0-.3-.2-.7-.3-1,0-.3,0-.6.1-.9-.5.3-1,.5-1.6.5-1-.1-2.1-.4-3-.9-1.3-.3-2.6-.4-3.9-.3-.9,0-1.8,0-2.7.2-.8.2-1.6.5-2.4.9.9-1.2,2.3-2.1,3.8-2.4.5,0,1-.2,1.5-.3.3-.1.5-.5.4-.9,0,0,0,0,0,0-.2-.4-.3-.9-.1-1.4.2-.5.6-.9,1.1-.9ZM54.7,9c0-.9.7-1.5,1.6-1.5.2,0,.3,0,.5,0,.7.2,1.1.9,1,1.6,0,.9-.8,1.5-1.7,1.4,0,0,0,0-.1,0-.8-.1-1.3-.8-1.3-1.6ZM45.4,31c1.3-1.2,2.6-2.5,3.8-3.9,1.2-1.3,2.3-2.8,3.2-4.3.7-1.1,1.3-2.4,1.5-3.8.3,0,.4.4.5.6.2.9.3,1.8,0,2.7-.4,1.7-1.1,3.2-2.1,4.6-2,2.6-4.3,4.8-6.9,6.7-3.2,2.3-6.5,4.4-10,6.2-2,1-4,2-6,2.9,5.7-3.3,11.1-7.2,15.9-11.7Z"/>
|
||||||
|
<path class="st0" d="M56.7,9.9c.4-.2.6-.7.4-1.2-.2-.5-.8-.7-1.2-.5-.5.2-.7.8-.5,1.2,0,0,0,0,0,0,.2.5.8.7,1.3.5,0,0,0,0,0,0Z"/>
|
||||||
|
<path class="st0" d="M61.7,9.6c0,.8-.6,1.6-1.4,1.9-.3,0-.6.2-.8.3.4.2.9.4,1.3.5.7.2,1.4.7,1.9,1.3.4-1.4,0-2.9-.9-3.9Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 64 64">
|
||||||
|
<!-- Generator: Adobe Illustrator 30.1.0, SVG Export Plug-In . SVG Version: 2.1.1 Build 136) -->
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.st0 {
|
||||||
|
fill: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<path class="st0" d="M63.2,49.1c-.3-1.1-.9-2.1-1.8-2.9-1.3-1.1-2.8-2-4.4-2.5-.2-2.6-.7-5.1-1.5-7.6-1.4-4.2-3.7-8-6.9-11-2-1.9-4.2-3.5-6.6-4.7-.2-1.4-.2-2.8,0-4.1.2-1.2.8-2.3,1.7-3.1.8-.8,1.8-1.4,2.8-1.9.3-.1.6-.3.7-.6,0-.3-.2-.5-.4-.5-.7,0-1.4,0-2,0-2.7.4-5.3,1.3-7.7,2.8-2.1,1.3-4,2.9-5.6,4.8-3.3-.1-6.6.2-9.8,1-3.4.8-6.8,2-9.9,3.6-2.1,1.1-3.9,2.5-5.6,4.1-1.1,1.1-2.1,2.4-2.8,3.8-.3.6-.5,1.2-.5,1.8,0,.5.3.9.7,1,.4,0,.7.1,1.1.1,1.7-.1,3.3-.5,4.8-1.1.3-.1.6-.2.9-.2.2,0,.4.2.3.4,0,0,0,0,0,0,0,.2-.3.3-.4.4-1.7.7-3.5,1.1-5.3,1.1-.7,0-1.3,0-1.9-.3-.8.2-1.5.7-2,1.2-.3.3-.4.7-.2,1,.3.3.6.6,1,.7.9.4,1.7.6,2.7.8,3.2.5,6.4.6,9.6.3,1.7,0,3.4-.3,5.1-.4-.3-.5-.6-1.1-.7-1.8-.8.1-1.6.2-2.4.3-3.4.5-6.8.6-10.2.4-1.4,0-2.9-.3-4.2-.8,2.6.4,5.2.5,7.9.3,3-.1,5.9-.6,8.9-1,0-.7.3-1.3.8-1.8.8-.8,1.9-1.2,3-1.1,2.2.3,4.3,1.1,6.1,2.5,0,0,.2.1.3.1,4.5-.3,9,.7,13,2.7.9.4,1.7.9,2.5,1.5-.6-.3-1.2-.6-1.8-.8-4.1-1.7-8.4-2.6-12.9-2.5.5.5.9,1.1,1.4,1.7,0,0,0,.2.2.2,1.1,0,2.3,0,3.4.2,1.8.2,3.6.6,5.3,1.1,2.5.7,4.9,1.8,7.1,3.1,1.6,1,3,2.2,4.2,3.7-1.3,1.2-2.4,2.7-3.1,4.4-.4.9-.6,1.9-.7,2.9,0,.4.2.7.5.9.4.1.8,0,1.2-.2,1.6-.9,2.9-2.2,4.5-3.1,1-.5,2-.8,3.1-.7,1.8,0,3.5.5,5.3.7.4,0,.8,0,1.1-.3.2-.3.3-.6.2-.9ZM13.6,30.9c0-.2-.1-.3-.2-.5-.4-.3-.9-.3-1.3,0-.1.1-.2.3-.3.5-.1-.3-.1-.6,0-.8.3-.5.9-.6,1.3-.4,0,0,0,0,.1,0,.3.3.4.7.3,1.1ZM51.1,33.4c-1.5-1.9-3.2-3.6-5.1-5.1-2.3-1.7-4.9-3.1-7.6-3.9-1.4-.4-2.9-.7-4.3-.8-2.9-.2-5.8.2-8.6,1.3-1.4.5-2.8,1.2-4.3,1.8-1.5.6-3.2,1-4.9,1.2-1.5,0-3,.2-4.4.5-1.9.4-3.7,1.3-5.2,2.4.9-1.1,1.9-2,3.1-2.8,1.5-1,3.1-1.6,4.8-2,1.5-.2,3-.6,4.5-1.1,2.7-.9,5.2-2.3,8-3.1,1.7-.5,3.5-.7,5.3-.7,2.7,0,5.4.7,7.9,1.8,2.7,1.2,5.1,2.9,7.1,5,1.5,1.6,2.8,3.4,3.8,5.4.1.2.2.5.3.7-.2-.2-.3-.5-.5-.7Z"/>
|
||||||
|
<path class="st0" d="M30.9,39.2c-.6-1.1-1.4-2.2-2.3-3.1-1.7-1.7-3.9-2.7-6.2-2.9-.4,0-.8,0-1.1,0-.9.2-1.6.8-1.8,1.7,0,.7,0,1.4.4,2.1.4.9,1,1.7,1.6,2.5.7.9,1.4,1.7,2.2,2.5,1,.9,2.2,1.6,3.4,2.1,1.5.6,3,1,4.6,1.2.3,0,.7,0,1-.1.3-.1.5-.4.6-.7,0-.2,0-.5,0-.7-.6-1.5-1.3-3-2.2-4.4Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,48 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
// src/components/PhoneLinks.tsx
|
// app/components/PhoneLinks.tsx
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// app/components/ui/EmergencyButton.tsx
|
||||||
|
|
||||||
|
import { ChevronRight, Siren } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface EmergencyButtonProps {
|
||||||
|
href: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmergencyButton({ href, title }: EmergencyButtonProps) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="group flex items-center justify-between w-full bg-emergency text-eggshell px-5 py-4 rounded-2xl shadow-md hover:shadow-lg hover:brightness-110 active:scale-[0.98] transition-all duration-300 border border-emergency/50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="bg-white/20 p-2 rounded-xl group-hover:bg-white/30 transition-colors">
|
||||||
|
{/* Ikonen pulserar mjukt för att direkt fånga uppmärksamheten */}
|
||||||
|
<Siren size={24} className="animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-base sm:text-lg font-black uppercase tracking-widest drop-shadow-sm">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<ChevronRight
|
||||||
|
size={24}
|
||||||
|
className="opacity-70 group-hover:opacity-100 group-hover:translate-x-1 transition-all"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
// app/css.d.ts
|
||||||
|
|
||||||
|
declare module '*.css';
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -92,6 +92,10 @@
|
|||||||
{
|
{
|
||||||
"q": "Var parkerar man om det är fullt?",
|
"q": "Var parkerar man om det är fullt?",
|
||||||
"a": "Ransviks övre parkering."
|
"a": "Ransviks övre parkering."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "Är det tillåtet att tälta här?",
|
||||||
|
"a": "Nej, förr fanns det en tältruta här men den ligger nu uppe vid stora parkeringen"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"isActive": true,
|
||||||
|
"type": "warning",
|
||||||
|
"message": "Glöm inte minst 1 liter vatten, solkräm och myggmedel. Det förväntas bli mycket varmt idag!"
|
||||||
|
}
|
||||||
@@ -66,18 +66,18 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"day": "Lördag",
|
"day": "Lördag",
|
||||||
"pilgrimsfalkarna": {
|
|
||||||
"time": "08:00 - 15:10",
|
|
||||||
"title": "Pilgrimsfalkarna",
|
|
||||||
"notes": "Heldagspass"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"day": "Söndag",
|
|
||||||
"tumlarna": {
|
"tumlarna": {
|
||||||
"time": "08:00 - 15:10",
|
"time": "08:00 - 15:10",
|
||||||
"title": "Tumlarna",
|
"title": "Tumlarna",
|
||||||
"notes": "Heldagspass"
|
"notes": "Heldagspass"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"day": "Söndag",
|
||||||
|
"pilgrimsfalkarna": {
|
||||||
|
"time": "08:00 - 15:10",
|
||||||
|
"title": "Pilgrimsfalkarna",
|
||||||
|
"notes": "Heldagspass"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
// 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 >
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// app/files/[...slug]/route.ts
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const isProd = process.env.NODE_ENV === "production";
|
||||||
|
const DATA_DIR = isProd ? "/app/data" : path.join(process.cwd(), "app/data");
|
||||||
|
const FILES_DIR = path.join(DATA_DIR, "files");
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ slug: string[] }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const resolvedParams = await params;
|
||||||
|
const filename = decodeURIComponent(resolvedParams.slug[resolvedParams.slug.length - 1]);
|
||||||
|
const filePath = path.join(FILES_DIR, filename);
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
console.error(`404 - Filen finns inte på disk: ${filePath}`);
|
||||||
|
return new NextResponse('Filen hittades inte', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileBuffer = fs.readFileSync(filePath);
|
||||||
|
|
||||||
|
let contentType = 'application/pdf';
|
||||||
|
if (filename.toLowerCase().endsWith('.png')) contentType = 'image/png';
|
||||||
|
else if (filename.toLowerCase().endsWith('.jpg') || filename.toLowerCase().endsWith('.jpeg')) contentType = 'image/jpeg';
|
||||||
|
else if (filename.toLowerCase().endsWith('.doc') || filename.toLowerCase().endsWith('.docx')) contentType = 'application/msword';
|
||||||
|
|
||||||
|
return new NextResponse(fileBuffer, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Content-Disposition': `inline; filename="${filename}"`
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Fel vid fildelning:", error);
|
||||||
|
return new NextResponse('Ett internt serverfel uppstod', { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
/* src/index.css */
|
/* app/globals.css */
|
||||||
|
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// app/layout.tsx
|
||||||
|
|
||||||
|
import { Leaf } from "lucide-react";
|
||||||
|
import type { Metadata, Viewport } from "next";
|
||||||
|
import { Navigation } from "./components/Navigation";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
themeColor: "#0E292E",
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
maximumScale: 1,
|
||||||
|
userScalable: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Naturvärdarna Kullaberg",
|
||||||
|
description: "Intern app för naturvärdarna på Kullaberg",
|
||||||
|
manifest: "/manifest.json",
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
statusBarStyle: "default",
|
||||||
|
title: "Naturvärdarna",
|
||||||
|
startupImage: [
|
||||||
|
{
|
||||||
|
url: "/apple-splash.png",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
icons: {
|
||||||
|
icon: '/favicon.svg',
|
||||||
|
shortcut: '/favicon.svg',
|
||||||
|
apple: [
|
||||||
|
{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
formatDetection: {
|
||||||
|
telephone: false,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="sv" suppressHydrationWarning>
|
||||||
|
<body className="min-h-screen bg-eggshell font-sans text-ebony selection:bg-gold selection:text-ebony pb-10">
|
||||||
|
{/* Header & Navigation */}
|
||||||
|
<header className="sticky top-0 z-50 bg-ebony shadow-md">
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-black text-eggshell flex items-center tracking-tight uppercase drop-shadow-sm">
|
||||||
|
<Leaf className="mr-2 text-seafoam" size={20} />
|
||||||
|
Naturvärdarna
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<Navigation />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<main className="max-w-5xl mx-auto p-3 py-6 md:py-8">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
// 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 på 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,78 +1,59 @@
|
|||||||
// src/pages/Schedule.tsx
|
// app/schedule/page.tsx
|
||||||
|
|
||||||
import React, { useRef } from 'react';
|
"use client"
|
||||||
import scheduleData from '../data/schedule.json';
|
|
||||||
import { Clock, CalendarRange } from 'lucide-react';
|
import { CalendarRange, Clock, Loader2 } 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 ---
|
// --- Types & Components ---
|
||||||
|
interface ShiftData { time: string; title?: string; notes?: string; }
|
||||||
|
interface ShiftBlockProps { team: 'PF' | 'TU'; data: ShiftData; pos: { top: number; height: number }; isOverlapping: boolean; }
|
||||||
|
|
||||||
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";
|
||||||
// Calculate layout based on overlap
|
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 widthClasses = isOverlapping
|
const textMain = isPF ? "text-ebony" : "text-eggshell";
|
||||||
? isPF ? "left-1 right-1/2 mr-0.5" : "left-1/2 right-1 ml-0.5"
|
const textMuted = isPF ? "text-ebony/90" : "text-eggshell/90";
|
||||||
: "left-1 right-1";
|
const notesBg = isPF ? "bg-eggshell/30 border-seafoam/5" : "bg-gold/20 border-eggshell/10";
|
||||||
|
|
||||||
// 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";
|
const fallbackTitle = isPF ? "PF" : "TU";
|
||||||
|
|
||||||
|
const iconSrc = isPF ? falconIcon : porpoiseIcon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
// Removed scale on hover! Added brightness-105 for a safe, non-clipping hover effect.
|
className={`absolute ${widthClasses} ${bgClasses} rounded-lg p-1.5 flex flex-col overflow-hidden shadow-sm border transition-colors hover:brightness-105`}
|
||||||
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`}>
|
<p className={`text-[10px] font-black ${textMain} uppercase tracking-wide leading-tight truncate flex items-center gap-1`}>
|
||||||
|
<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`}>
|
||||||
@@ -81,60 +62,70 @@ 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`}>
|
<p className={`text-[9px] font-medium ${textMain} leading-tight truncate pb-1`}>{data.notes}</p>
|
||||||
{data.notes}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// --- Main Schedule View ---
|
// --- Main Schedule View ---
|
||||||
|
export default function Schedule() {
|
||||||
export const Schedule: React.FC = () => {
|
const startHour = 8;
|
||||||
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 };
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
if (isLoading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-slate-teal" size={40} /></div>;
|
||||||
<div className="space-y-4 animate-fade-in w-full">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-black text-slate-teal tracking-tight flex items-center">
|
|
||||||
<CalendarRange className="mr-3 text-seafoam" size={32} />
|
|
||||||
Veckoschema
|
|
||||||
</h1>
|
|
||||||
<p className="text-moss font-medium mt-2 ml-11">Här hittar du när vi bemannar Kullaberg.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-eggshell border-2 border-slate-teal/20 rounded-2xl shadow-sm overflow-hidden flex flex-col max-h-[65vh] h-max min-h-125">
|
return (
|
||||||
|
<div className="space-y-2 animate-fade-in w-full">
|
||||||
|
<PageHeader
|
||||||
|
title="Veckoschema"
|
||||||
|
icon={CalendarRange}
|
||||||
|
description="Här hittar du när vi bemannar Kullaberg."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="bg-white/40 backdrop-blur-md border border-white/40 rounded-3xl shadow-sm overflow-hidden flex flex-col max-h-[60vh] h-max min-h-125 w-full">
|
||||||
<div className="flex-1 overflow-auto scrollbar-hide" ref={containerRef}>
|
<div className="flex-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)]">
|
||||||
{/* Sticky Time Column */}
|
<div className="sticky top-0 z-30 h-10 border-b border-white/40 bg-white/40 backdrop-blur-md rounded-tl-3xl"></div>
|
||||||
<div className="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-end pr-3" style={{ top: `${i * PIXELS_PER_HOUR + GRID_PADDING_TOP}px` }}>
|
<div key={hour} className="absolute w-full flex justify-center" style={{ top: `${i * PIXELS_PER_HOUR + GRID_PADDING_TOP}px` }}>
|
||||||
<span className="text-[11px] font-bold text-slate-teal -mt-2 bg-eggshell px-1">
|
<span className="text-[10px] font-bold text-slate-teal -mt-2 px-1 rounded ">
|
||||||
{hour.toString().padStart(2, '0')}:00
|
{hour.toString().padStart(2, '0')}:00
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,45 +133,35 @@ export const Schedule: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grid Area */}
|
|
||||||
<div className="flex-auto relative">
|
<div className="flex-auto relative">
|
||||||
{/* Sticky Day Headers */}
|
<div className="sticky top-0 z-10 flex h-10 border-b border-white/40 bg-white/60 backdrop-blur-md">
|
||||||
<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-2 border-slate-teal/5 first:border-l-0 min-w-25">
|
<div key={d.day} className="flex-1 flex items-center justify-center border-l border-white/20 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-slate-teal/10" 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-white/30" 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-slate-teal/10 relative min-w-25">
|
<div key={colIndex} className="flex-1 border-l border-white/30 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;
|
||||||
@@ -195,11 +176,20 @@ export const Schedule: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Legend */}
|
<div className="flex items-center justify-center space-x-6 py-2 text-xs font-bold text-ebony">
|
||||||
<div className="flex items-center justify-center space-x-6 py-2 text-xs font-bold text-slate-teal">
|
<span className="flex items-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>
|
<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-seafoam to-slate-teal rounded-full mr-2 shadow-sm"></div> Tumlarna</span>
|
<Image src={falconIcon} alt="PF" width={14} height={14} />
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// 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();
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\.env
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\.git
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\.gitignore
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\.next
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\app
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\eslint.config.mjs
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\files.txt
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\next-env.d.ts
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\next.config.ts
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\node_modules
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\package-lock.json
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\package.json
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\postcss.config.mjs
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\prisma
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\prisma.config.ts
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\public
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\README.md
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\src
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\tsconfig.json
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\app\actions
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\app\admin
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\app\components
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\app\css.d.ts
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\app\data
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\app\documents
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\app\emergency
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\app\faq
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\app\globals.css
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\app\info
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\app\layout.tsx
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\app\page.tsx
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\app\schedule
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\app\sw.ts
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\app\actions\admin.ts
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\app\admin\adminTypes.ts
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\app\admin\AttendanceTab.tsx
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\app\admin\page.tsx
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\app\admin\ReportTab.tsx
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\app\admin\SetupTab.tsx
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\app\admin\useAdminState.ts
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\app\components\Navigation.tsx
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\app\components\PhoneLinks.tsx
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\app\data\faq.json
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\app\data\schedule.json
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\app\documents\page.tsx
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\app\emergency\page.tsx
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\app\faq\page.tsx
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\app\info\page.tsx
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\app\schedule\page.tsx
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\prisma\dev.db
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\prisma\migrations
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\prisma\schema.prisma
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\prisma\migrations\20260316123212_init
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\prisma\migrations\migration_lock.toml
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\prisma\migrations\20260316123212_init\migration.sql
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\public\favicon.svg
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\public\files
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\public\manifest.json
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\public\files\Badplatser på Kullaberg.pdf
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\public\files\Destinationskunskap 2026.pptx
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\public\files\Orienteringskarta - Kullaberg.pdf
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\public\files\Turistkarta - Kullaberg.pdf
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\public\files\Underlag för guidediplomering.pptx
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\public\files\Vandringsrutter.pdf
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\src\generated
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\src\lib
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\src\generated\prisma
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\src\generated\prisma\browser.ts
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\src\generated\prisma\client.ts
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\src\generated\prisma\commonInputTypes.ts
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\src\generated\prisma\enums.ts
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\src\generated\prisma\internal
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\src\generated\prisma\models
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\src\generated\prisma\models.ts
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\src\generated\prisma\internal\class.ts
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\src\generated\prisma\internal\prismaNamespace.ts
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\src\generated\prisma\internal\prismaNamespaceBrowser.ts
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\src\generated\prisma\models\Attendance.ts
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\src\generated\prisma\models\Period.ts
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\src\generated\prisma\models\User.ts
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\src\generated\prisma\models\Youth.ts
|
||||||
|
C:\Users\Wiking\Code\naturvardarna-pwa\src\lib\prisma.ts
|
||||||
-18
@@ -1,18 +0,0 @@
|
|||||||
<!-- 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>
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// lib/prisma.ts
|
||||||
|
|
||||||
|
import "dotenv/config";
|
||||||
|
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
|
||||||
|
import { PrismaClient } from "../generated/prisma/client";
|
||||||
|
|
||||||
|
const isProd = process.env.NODE_ENV === "production";
|
||||||
|
const defaultUrl = isProd ? "file:/app/data/kullaberg.db" : "file:./prisma/dev.db";
|
||||||
|
const dbUrl = process.env.DATABASE_URL || defaultUrl;
|
||||||
|
const adapter = new PrismaBetterSqlite3({
|
||||||
|
url: dbUrl
|
||||||
|
});
|
||||||
|
|
||||||
|
const prismaClientSingleton = () => {
|
||||||
|
return new PrismaClient({ adapter });
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
var prismaGlobal: undefined | ReturnType<typeof prismaClientSingleton>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prisma = globalThis.prismaGlobal ?? prismaClientSingleton();
|
||||||
|
|
||||||
|
export default prisma;
|
||||||
|
|
||||||
|
if (!isProd) {
|
||||||
|
globalThis.prismaGlobal = prisma;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// 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);
|
||||||
Generated
+4808
-3562
File diff suppressed because it is too large
Load Diff
+39
-25
@@ -1,35 +1,49 @@
|
|||||||
{
|
{
|
||||||
"name": "naturvardarna-app",
|
"name": "naturvardarna-pwa",
|
||||||
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host",
|
"dev": "next dev --webpack",
|
||||||
"build": "tsc -b && vite build",
|
"build": "next build --webpack",
|
||||||
"lint": "eslint .",
|
"start": "next start",
|
||||||
"preview": "vite preview"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@hono/node-server": "^2.0.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",
|
||||||
"react": "^19.2.4",
|
"next": "^16.2.1",
|
||||||
"react-dom": "^19.2.4",
|
"react": "19.2.3",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-dom": "19.2.3",
|
||||||
"tailwindcss": "^4.2.1"
|
"serwist": "^9.5.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^24.12.0",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/react": "^19.2.14",
|
"@types/node": "^20",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/pg": "^8.18.0",
|
||||||
"@vitejs/plugin-react": "^6.0.0",
|
"@types/react": "^19",
|
||||||
"eslint": "^9.39.4",
|
"@types/react-dom": "^19",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint": "9.14.0",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-config-next": "16.1.6",
|
||||||
"globals": "^17.4.0",
|
"prisma": "^7.5.0",
|
||||||
"typescript": "~5.9.3",
|
"tailwindcss": "^4",
|
||||||
"typescript-eslint": "^8.56.1",
|
"tsx": "^4.21.0",
|
||||||
"vite": "^8.0.0",
|
"typescript": "^5"
|
||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"pin" TEXT NOT NULL,
|
||||||
|
"role" TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Period" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"startDate" TEXT NOT NULL,
|
||||||
|
"endDate" TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Youth" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"team" TEXT NOT NULL,
|
||||||
|
"periodId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "Youth_periodId_fkey" FOREIGN KEY ("periodId") REFERENCES "Period" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Attendance" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"date" TEXT NOT NULL,
|
||||||
|
"shiftId" TEXT NOT NULL,
|
||||||
|
"hoursWorked" REAL NOT NULL,
|
||||||
|
"weightedHours" REAL NOT NULL,
|
||||||
|
"status" TEXT NOT NULL,
|
||||||
|
"note" TEXT,
|
||||||
|
"youthId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "Attendance_youthId_fkey" FOREIGN KEY ("youthId") REFERENCES "Youth" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Attendance_date_youthId_shiftId_key" ON "Attendance"("date", "youthId", "shiftId");
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "sqlite"
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// 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.
|
After Width: | Height: | Size: 61 KiB |
Binary file not shown.
|
After 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.
|
After Width: | Height: | Size: 8.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"id": "/",
|
||||||
|
"name": "Naturvärdarna Kullaberg",
|
||||||
|
"short_name": "Naturvärdarna",
|
||||||
|
"description": "Intern app för naturvärdarna på Kullaberg",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#5e6545",
|
||||||
|
"theme_color": "#5e6545",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
-83
@@ -1,83 +0,0 @@
|
|||||||
// 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,15 +0,0 @@
|
|||||||
// 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>,
|
|
||||||
)
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
// 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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
// 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 på 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 på <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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
// 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 på 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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
// 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 på 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 på 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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
// 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 på 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,100 +0,0 @@
|
|||||||
// 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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,303 +0,0 @@
|
|||||||
// 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 på 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 på detta pass...</option>
|
|
||||||
{availableExtras.map(y => (
|
|
||||||
<option key={y.id} value={y.id}>{y.name} ({y.team})</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
// 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 på 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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
// 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. 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,120 +0,0 @@
|
|||||||
// 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
|
|
||||||
};
|
|
||||||
};
|
|
||||||
Vendored
-4
@@ -1,4 +0,0 @@
|
|||||||
// src/vite-env.d.ts
|
|
||||||
|
|
||||||
/// <reference types="vite-plugin-pwa/client" />
|
|
||||||
/// <reference types="vite/client" />
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
+32
-5
@@ -1,7 +1,34 @@
|
|||||||
{
|
{
|
||||||
"files": [],
|
"compilerOptions": {
|
||||||
"references": [
|
"target": "ES2017",
|
||||||
{ "path": "./tsconfig.app.json" },
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
{ "path": "./tsconfig.node.json" }
|
"allowJs": true,
|
||||||
]
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
// 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'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
]
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user