commit be129af1d9a46a2e312bc65517c1b91497018af4 Author: William Söderberg Date: Mon Mar 16 23:54:36 2026 +0100 First commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..157b9b4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,34 @@ +# Dependency directories +node_modules +npm-debug.log +yarn-error.log +yarn-debug.log +.pnp.* + +# Next.js build output +.next +out +build + +# Git +.git +.gitignore + +# Environment variables +# We usually don't want local env files in the image. Pass them via Docker run/compose instead. +.env*.local +.env + +# Local Database Files (CRITICAL if using SQLite!) +*.db +*.db-journal +*.sqlite +*.sqlite3 +data/ + +# Mac/Windows system files +.DS_Store +Thumbs.db + +# Typescript cache +*.tsbuildinfo \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fbb72e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ + + +generated/ +dev.db + +# PWA / Serwist generated files +public/sw.js +public/sw.js.map +public/swe-worker-*.js +public/swe-worker-*.js.map +public/workbox-*.js +public/workbox-*.js.map + +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +/app/generated/prisma diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..f4a327d --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,18 @@ +stages: + - Build + +"Build Docker": + stage: Build + image: docker:24.0.5 + + before_script: + - echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin + script: + - echo "Building Docker-image..." + + - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG -t $CI_REGISTRY_IMAGE:latest . + - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG + - docker push $CI_REGISTRY_IMAGE:latest + + rules: + - if: $CI_COMMIT_TAG diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8ec21fd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# 1. Install dependencies +FROM node:alpine AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app +COPY package*.json ./ +RUN npm ci + +# 2. Build the app +FROM node:alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +RUN npx prisma generate + +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +# 3. Production image (Slimmad!) +FROM node:alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN mkdir -p data + +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/prisma ./prisma + +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "server.js"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6961cd5 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Naturvärdarna PWA + +Källkod för [app.naturvardarna.com](https://app.naturvardarna.com/) diff --git a/app/actions/admin.ts b/app/actions/admin.ts new file mode 100644 index 0000000..3bae90a --- /dev/null +++ b/app/actions/admin.ts @@ -0,0 +1,154 @@ +// app/actions/admin.ts + +"use server"; + +import prisma from "../../lib/prisma"; +import { unstable_noStore as noStore } from "next/cache"; + +// ========================================== +// 1. FETCH DATA +// ========================================== + +export async function getAdminData() { + noStore(); // CRITICAL: Tells Next.js to NEVER cache this response + + return await prisma.period.findMany({ + include: { + youths: { + include: { + attendance: true + } + } + } + }); +} + +// ========================================== +// 2. PERIOD ACTIONS +// ========================================== + +export async function createPeriodDb(name: string, startDate: string, endDate: string) { + const newPeriod = await prisma.period.create({ + data: { name, startDate, endDate } + }); + return newPeriod; +} + +export async function deletePeriodDb(periodId: string) { + const youths = await prisma.youth.findMany({ where: { periodId } }); + const youthIds = youths.map(y => y.id); + + if (youthIds.length > 0) { + await prisma.attendance.deleteMany({ + where: { youthId: { in: youthIds } } + }); + } + + await prisma.youth.deleteMany({ + where: { periodId } + }); + + await prisma.period.delete({ + where: { id: periodId } + }); + + return { success: true }; // Returning JSON prevents "Unexpected end of JSON input" +} + +// ========================================== +// 3. YOUTH ACTIONS +// ========================================== + +export async function bulkAddYouthDb(periodId: string, youthData: { name: string, team: string }[]) { + await prisma.youth.createMany({ + data: youthData.map(y => ({ + name: y.name, + team: y.team, + periodId: periodId + })) + }); + return { success: true }; +} + +export async function removeYouthDb(youthId: string) { + await prisma.attendance.deleteMany({ + where: { youthId } + }); + + await prisma.youth.delete({ + where: { id: youthId } + }); + + return { success: true }; +} + +// ========================================== +// 4. ATTENDANCE ACTIONS +// ========================================== + +export async function setAttendanceDb(date: string, youthId: string, shiftId: string, hoursWorked: number, weightedHours: number, status: string, note: string) { + await prisma.attendance.upsert({ + where: { date_youthId_shiftId: { date, youthId, shiftId } }, + update: { hoursWorked, weightedHours, status, note }, + create: { date, youthId, shiftId, hoursWorked, weightedHours, status, note } + }); + return { success: true }; +} + +export async function removeAttendanceDb(date: string, youthId: string, shiftId: string) { + await prisma.attendance.delete({ + where: { date_youthId_shiftId: { date, youthId, shiftId } } + }).catch(() => { /* Ignore if it doesn't exist */ }); + return { success: true }; +} + +export async function bulkSetAttendanceDb(records: { date: string; youthId: string; shiftId: string; hoursWorked: number; weightedHours: number; status: string; note: string }[]) { + // A Prisma transaction runs all these upserts in a single database round-trip! + await prisma.$transaction( + records.map(record => + prisma.attendance.upsert({ + where: { date_youthId_shiftId: { date: record.date, youthId: record.youthId, shiftId: record.shiftId } }, + update: { hoursWorked: record.hoursWorked, weightedHours: record.weightedHours, status: record.status, note: record.note }, + create: { date: record.date, youthId: record.youthId, shiftId: record.shiftId, hoursWorked: record.hoursWorked, weightedHours: record.weightedHours, status: record.status, note: record.note } + }) + ) + ); + return { success: true }; +} + +// ========================================== +// 5. AUTHENTICATION ACTIONS +// ========================================== + +export async function getLoginUsers() { + noStore(); + return await prisma.user.findMany({ + select: { id: true, name: true, role: true } + }); +} + +export async function verifyLogin(username: string, pin: string) { + noStore(); + const users = await prisma.user.findMany(); + const user = users.find(u => u.name.toLowerCase() === username.toLowerCase().trim()); + + if (user && user.pin === pin) { + return { success: true, user: { id: user.id, name: user.name, role: user.role } }; + } + return { success: false, user: null }; +} + +// ========================================== +// 6. OFFLINE SYNC ACTIONS +// ========================================== + +export async function syncOfflineQueueDb(queue: any[]) { + for (const action of queue) { + if (action.type === 'SET_ATTENDANCE') { + await setAttendanceDb(action.payload.date, action.payload.youthId, action.payload.shiftId, action.payload.hoursWorked, action.payload.weightedHours, action.payload.status, action.payload.note); + } else if (action.type === 'REMOVE_ATTENDANCE') { + await removeAttendanceDb(action.payload.date, action.payload.youthId, action.payload.shiftId); + } + } + return { success: true }; +} \ No newline at end of file diff --git a/app/admin/AttendanceTab.tsx b/app/admin/AttendanceTab.tsx new file mode 100644 index 0000000..9e04a43 --- /dev/null +++ b/app/admin/AttendanceTab.tsx @@ -0,0 +1,375 @@ +// app/admin/AttendanceTab.tsx + +"use client"; + +import { CheckCircle, ChevronLeft, ChevronRight, ClipboardCheck, Edit3, FileText, Undo, Zap } from 'lucide-react'; +import Image from 'next/image'; +import React, { useEffect, useState } from 'react'; +import falconIcon from '../assets/falcon.svg'; +import porpoiseIcon from '../assets/porpoise.svg'; +import scheduleData from '../data/schedule.json'; +import { type AttendanceDataMap, calculateShiftDuration, formatTimeHHMM, getAttendanceKey, isWeekend, parseTimeInput, type Period, toIsoDate } from './adminTypes'; + +interface Props { + periods: Period[]; + attendance: AttendanceDataMap; + setManualAttendance: (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON', status: 'Absent' | 'Late' | 'Present', hours: number, note?: string) => void; + bulkSetManualAttendance: (records: any[]) => void; + addPendingAttendance: (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => void; + removeAttendanceEntry: (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => void; + activePeriodId: string; + setActivePeriodId: (id: string) => void; +} + +// NEW: Dynamic Shift Helper +export const getTeamShiftInfo = (daySchedule: any, team: 'PF' | 'TU') => { + if (!daySchedule) return { time: 'Ledig', shiftId: 'MORNING' as 'MORNING' | 'AFTERNOON' }; + + const pfTime = daySchedule.pilgrimsfalkarna?.time || 'Ledig'; + const tuTime = daySchedule.tumlarna?.time || 'Ledig'; + + const pfStart = pfTime !== 'Ledig' ? parseInt(pfTime.match(/(\d+):/)?.[1] || '99') : 99; + const tuStart = tuTime !== 'Ledig' ? parseInt(tuTime.match(/(\d+):/)?.[1] || '99') : 99; + + if (team === 'PF') { + return { time: pfTime, shiftId: (pfStart > tuStart) ? 'AFTERNOON' : 'MORNING' as 'MORNING' | 'AFTERNOON' }; + } else { + return { time: tuTime, shiftId: (tuStart > pfStart) ? 'AFTERNOON' : (pfStart === tuStart ? 'AFTERNOON' : 'MORNING') as 'MORNING' | 'AFTERNOON' }; + } +}; + +const getDaysInPeriod = (start: string, end: string) => { + const days = []; + let curr = new Date(start + 'T12:00:00'); + const endDate = new Date(end + 'T12:00:00'); + while (curr <= endDate) { + days.push(toIsoDate(curr)); + curr.setDate(curr.getDate() + 1); + } + return days; +}; + +const getDailyCompletionStats = (date: string, period: Period, attendance: AttendanceDataMap) => { + const dayNameStr = new Date(date + 'T12:00:00').toLocaleDateString('sv-SE', { weekday: 'long' }).toLowerCase(); + const daySchedule = scheduleData.find(d => d.day.toLowerCase() === dayNameStr); + + if (!daySchedule) return { expected: 0, completed: 0, isComplete: true, hasWork: false }; + + let expected = 0; + let completed = 0; + let hasWork = false; + + // Use dynamic shift checking + if (daySchedule.pilgrimsfalkarna && daySchedule.pilgrimsfalkarna.time !== 'Ledig') { + hasWork = true; + const { shiftId } = getTeamShiftInfo(daySchedule, 'PF'); + const pfYouth = period.youthList.filter(y => y.team === 'PF'); + expected += pfYouth.length; + pfYouth.forEach(y => { + const entry = attendance[getAttendanceKey(date, y.id, shiftId)]; + if (entry && entry.status !== 'Pending') completed++; + }); + } + if (daySchedule.tumlarna && daySchedule.tumlarna.time !== 'Ledig') { + hasWork = true; + const { shiftId } = getTeamShiftInfo(daySchedule, 'TU'); + const tuYouth = period.youthList.filter(y => y.team === 'TU'); + expected += tuYouth.length; + tuYouth.forEach(y => { + const entry = attendance[getAttendanceKey(date, y.id, shiftId)]; + if (entry && entry.status !== 'Pending') completed++; + }); + } + + return { expected, completed, isComplete: expected > 0 && completed >= expected, hasWork }; +}; + +export const AttendanceTab: React.FC = ({ periods, attendance, setManualAttendance, bulkSetManualAttendance, addPendingAttendance, removeAttendanceEntry, activePeriodId, setActivePeriodId }) => { + const activePeriod = periods.find(p => p.id === activePeriodId); + const [currentDate, setCurrentDate] = useState(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

Ingen period aktiv.

; + + const changeDate = (days: number) => { + const newDateObj = new Date(currentDate + 'T12:00:00'); + newDateObj.setDate(newDateObj.getDate() + days); + const startObj = new Date(activePeriod.startDate + 'T12:00:00'); + const endObj = new Date(activePeriod.endDate + 'T12:00:00'); + + if (newDateObj >= startObj && newDateObj <= endObj) { + setCurrentDate(toIsoDate(newDateObj)); + } + }; + + const dayNameStr = new Date(currentDate + 'T12:00:00').toLocaleDateString('sv-SE', { weekday: 'long' }); + const daySchedule = scheduleData.find(d => d.day.toLowerCase() === dayNameStr.toLowerCase()); + const timelineDays = getDaysInPeriod(activePeriod.startDate, activePeriod.endDate); + const todayIso = toIsoDate(new Date()); + const currentDayStats = getDailyCompletionStats(currentDate, activePeriod, attendance); + + const markStandardAttendance = (youthId: string, team: 'PF' | 'TU', shiftId: 'MORNING' | 'AFTERNOON') => { + const { time: shiftTime } = getTeamShiftInfo(daySchedule, team); + const rawHours = calculateShiftDuration(shiftTime); + const actualHours = isWeekend(currentDate) ? Math.max(0, rawHours - 0.5) : rawHours; + if (actualHours > 0) setManualAttendance(currentDate, youthId, shiftId, 'Present', actualHours); + }; + + // Sort teams chronologically + const teamsToRender = ['PF', 'TU'].sort((a, b) => { + const timeA = getTeamShiftInfo(daySchedule, a as 'PF' | 'TU').time; + const timeB = getTeamShiftInfo(daySchedule, b as 'PF' | 'TU').time; + const startA = timeA !== 'Ledig' ? parseInt(timeA.match(/(\d+):/)?.[1] || '99') : 99; + const startB = timeB !== 'Ledig' ? parseInt(timeB.match(/(\d+):/)?.[1] || '99') : 99; + return startA - startB; + }); + + return ( +
+ {/* Timeline Overview */} +
+
+

Periodöversikt

+ +
+ +
+ {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 ( + + ); + })} +
+
+ + {/* Date Navigation */} +
+
+ +
+

{dayNameStr}

+

{currentDate}

+
+ +
+ + {currentDayStats.hasWork && ( +
+ {currentDayStats.isComplete ? <> Dagen är komplett! : `Ifyllt: ${currentDayStats.completed} av ${currentDayStats.expected} pers`} +
+ )} +
+ + {/* Attendance Lists */} + {!daySchedule || !currentDayStats.hasWork ? ( +

Inga schemalagda pass denna dag.

+ ) : ( + teamsToRender.map(teamStr => { + const team = teamStr as 'PF' | 'TU'; + const { time: standardTime, shiftId } = getTeamShiftInfo(daySchedule, team); + + if (standardTime === 'Ledig') return null; + + const rawDuration = calculateShiftDuration(standardTime); + const isWknd = isWeekend(currentDate); + const actualDuration = isWknd ? Math.max(0, rawDuration - 0.5) : rawDuration; + const weight = isWknd ? 1.5 : 1.0; + const weightedDuration = actualDuration * weight; + + const scheduledYouth = activePeriod.youthList.filter(y => y.team === team); + const extraYouth = activePeriod.youthList.filter(y => y.team !== team && attendance[getAttendanceKey(currentDate, y.id, shiftId)]); + + // Display youth sorted by name alphabetically + const displayYouth = [...scheduledYouth, ...extraYouth].sort((a, b) => a.name.localeCompare(b.name)); + const availableExtras = activePeriod.youthList.filter(y => !displayYouth.some(dy => dy.id === y.id)).sort((a, b) => a.name.localeCompare(b.name)); + + const handleQuickLogAll = () => { + const recordsToUpdate: any[] = []; + + displayYouth.forEach(y => { + const entry = attendance[getAttendanceKey(currentDate, y.id, shiftId)]; + if (!entry || entry.status === 'Pending') { + recordsToUpdate.push({ + date: currentDate, + youthId: y.id, + shiftId: shiftId, + status: 'Present', + hoursWorked: actualDuration, + weightedHours: weightedDuration, + note: '' + }); + } + }); + if (recordsToUpdate.length > 0) { + bulkSetManualAttendance(recordsToUpdate); + } + }; + + return ( +
+
+

+
+ {team +
+ {team === 'PF' ? 'Pilgrimsfalkarna' : 'Tumlarna'} +

+ +
+
+ + {standardTime} ({formatTimeHHMM(rawDuration)}) + {isWknd && Helg (-30m)} +
+ + +
+
+ +
+ {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 ( +
+ +
+ {isCompleted ? :
} + + {youth.name} + {isExtra && Extra pass} + +
+ +
+ {isPending && ( + + Väntar på tid... + + )} + {isCompleted && ( + + {entry.status === 'Absent' ? entry.note : `${formatTimeHHMM(entry.hoursWorked)} arbetat (+${entry.weightedHours.toFixed(1)}t pott)`} + + )} + +
+ {(!entry || isPending) && ( + <> + + + + + )} + + {entry && ( + + )} +
+
+
+ ); + })} + + {availableExtras.length > 0 && ( +
+ +
+ )} +
+
+ ); + }) + )} +
+ ); +}; \ No newline at end of file diff --git a/app/admin/ReportTab.tsx b/app/admin/ReportTab.tsx new file mode 100644 index 0000000..f70ba1b --- /dev/null +++ b/app/admin/ReportTab.tsx @@ -0,0 +1,254 @@ +// app/admin/ReportTab.tsx + +"use client"; + +import { AlertTriangle, ChevronDown, ChevronUp, Clock, Download, Users } from 'lucide-react'; +import Image from 'next/image'; +import React, { useState } from 'react'; +import scheduleData from '../data/schedule.json'; +import falconIcon from '../assets/falcon.svg'; +import porpoiseIcon from '../assets/porpoise.svg'; +import { type AttendanceDataMap, formatTimeHHMM, isWeekend, type Period, type Role } from './adminTypes'; +import { getTeamShiftInfo } from './AttendanceTab'; + +interface Props { + periods: Period[]; + attendance: AttendanceDataMap; + activePeriodId: string; + setActivePeriodId: (id: string) => void; + currentUserRole: Role; +} + +const POT_HOUR_LIMIT = 90; +const formatHours = (h: number) => Number(h.toFixed(1)).toString(); + +export const ReportTab: React.FC = ({ periods, attendance, activePeriodId, setActivePeriodId, currentUserRole }) => { + const activePeriod = periods.find(p => p.id === activePeriodId); + const [expandedYouthId, setExpandedYouthId] = useState(null); + + if (!activePeriod) return

Ingen period tillgänglig.

; + + const getPeriodHoursTotal = (youthId: string): number => { + let total = 0; + for (const key in attendance) { + const entry = attendance[key]; + if (entry.youthId === youthId && entry.date >= activePeriod.startDate && entry.date <= activePeriod.endDate) { + total += entry.weightedHours; + } + } + return total; + }; + + const getTimelineForYouth = (youthId: string) => { + return Object.values(attendance) + .filter(a => a.youthId === youthId && a.date >= activePeriod.startDate && a.date <= activePeriod.endDate) + .sort((a, b) => { + if (a.date !== b.date) return a.date.localeCompare(b.date); + return a.shiftId === 'MORNING' ? -1 : 1; + }); + }; + + const exportToCSV = () => { + if (!activePeriod) return; + + let csvContent = "Datum;Pass;Namn;Lag;Status;Arbetad Tid (HH:MM);Viktad Pott;Anteckning\n"; + const entries = Object.values(attendance).filter(a => a.date >= activePeriod.startDate && a.date <= activePeriod.endDate); + + entries.sort((a, b) => { + if (a.date !== b.date) return a.date.localeCompare(b.date); + if (a.shiftId !== b.shiftId) return a.shiftId.localeCompare(b.shiftId); + const nameA = activePeriod.youthList.find(y => y.id === a.youthId)?.name || ''; + const nameB = activePeriod.youthList.find(y => y.id === b.youthId)?.name || ''; + return nameA.localeCompare(nameB); + }); + + entries.forEach(entry => { + const youth = activePeriod.youthList.find(y => y.id === entry.youthId); + if (!youth) return; + + const isWknd = isWeekend(entry.date); + const shift = isWknd ? 'Hela dagen' : (entry.shiftId === 'MORNING' ? 'Morgon' : 'Eftermiddag'); + + const teamName = youth.team === 'PF' ? 'Pilgrimsfalk' : 'Tumlare'; + const worked = formatTimeHHMM(entry.hoursWorked); + const weighted = entry.weightedHours.toFixed(2).replace('.', ','); + const note = entry.note || ''; + + csvContent += `${entry.date};${shift};${youth.name};${teamName};${entry.status};${worked};${weighted};${note}\n`; + }); + + csvContent += "\nSummering (Timpott)\nNamn;Lag;Total Viktad Pott\n"; + + const sortedYouth = [...activePeriod.youthList].sort((a, b) => a.name.localeCompare(b.name)); + + sortedYouth.forEach(youth => { + const total = getPeriodHoursTotal(youth.id).toFixed(2).replace('.', ','); + const teamName = youth.team === 'PF' ? 'Pilgrimsfalk' : 'Tumlare'; + csvContent += `${youth.name};${teamName};${total}\n`; + }); + + const blob = new Blob(["\uFEFF" + csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.setAttribute("href", url); + link.setAttribute("download", `Narvaro_${activePeriod.name.replace(/ /g, '_')}.csv`); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const sortedActiveYouth = [...activePeriod.youthList].sort((a, b) => a.name.localeCompare(b.name)); + + return ( +
+ {/* Header / Period Selector */} +
+ +
+

{activePeriod.name}

+

{activePeriod.startDate} — {activePeriod.endDate}

+
+
+ + {/* PERIOD HOUR POT REPORT */} +
+
+

+ + Timrapport +

+ + {currentUserRole !== 'Viewer' && ( + + )} +
+ +
+ {sortedActiveYouth.map(youth => { + const total = getPeriodHoursTotal(youth.id); + const warningStatus: 'none' | 'yellow' | 'red' = total > POT_HOUR_LIMIT ? 'red' : (total >= POT_HOUR_LIMIT - 10 ? 'yellow' : 'none'); + const isExpanded = expandedYouthId === youth.id; + const timeline = getTimelineForYouth(youth.id); + + return ( +
+
+
+
+
+ {youth.team +
+ {youth.name} +
+ +
+ +
+ {warningStatus === 'red' && } +
+
+ {formatHours(total)} / {POT_HOUR_LIMIT}t +
+

Viktade timmar

+
+
+
+ + {isExpanded && ( +
+

+ Arbetspass & Frånvaro +

+ {timeline.length === 0 ? ( +

Ingen närvaro loggad ännu.

+ ) : ( +
+ {timeline.map((entry, idx) => { + const isWknd = isWeekend(entry.date); + + // Grab schedule for this exact day to check for 'Extra' shifts accurately! + const dayNameStr = new Date(entry.date + 'T12:00:00').toLocaleDateString('sv-SE', { weekday: 'long' }).toLowerCase(); + const daySchedule = scheduleData.find(d => d.day.toLowerCase() === dayNameStr); + + let isExtra = false; + if (!isWknd && daySchedule) { + const expectedShiftId = getTeamShiftInfo(daySchedule, youth.team).shiftId; + isExtra = entry.shiftId !== expectedShiftId; + } + + // Correct display logic: "Hela dagen" for weekends + const shiftLabel = isWknd ? 'Hela dagen' : (entry.shiftId === 'MORNING' ? 'Morgon' : 'Eftermiddag'); + + return ( +
+
+ {entry.date} + + + {shiftLabel} + + + {isExtra && ( + + Extra pass + + )} + + {isWknd && Helg} +
+ +
+ {entry.status === 'Pending' ? ( + Väntar på registrering + ) : entry.status === 'Absent' ? ( + {entry.note || 'Frånvarande'} + ) : ( + {entry.status === 'Late' ? 'Manuell tid' : 'Närvarande'} + )} + +
+ {formatTimeHHMM(entry.hoursWorked)} arbetat + → +{entry.weightedHours.toFixed(1)} pott +
+
+
+ ); + })} +
+ )} +
+ )} +
+ ); + })} + {activePeriod.youthList.length === 0 && ( +

Inga ungdomar i denna period.

+ )} +
+
+
+ ); +}; \ No newline at end of file diff --git a/app/admin/SetupTab.tsx b/app/admin/SetupTab.tsx new file mode 100644 index 0000000..2111820 --- /dev/null +++ b/app/admin/SetupTab.tsx @@ -0,0 +1,184 @@ +// app/admin/SetupTab.tsx + +"use client"; + +import { CalendarRange, Trash2, UserPlus } from 'lucide-react'; +import Image from 'next/image'; +import React, { useState } from 'react'; +import falconIcon from '../assets/falcon.svg'; +import porpoiseIcon from '../assets/porpoise.svg'; +import { type Period, type Youth } from './adminTypes'; + +interface Props { + periods: Period[]; + createPeriod: (name: string, start: string) => void; + deletePeriod: (id: string) => void; + bulkAddYouth: (periodId: string, text: string, team: 'PF' | 'TU') => void; + removeYouth: (periodId: string, youthId: string) => void; + isOffline: boolean; +} + +export const SetupTab: React.FC = ({ periods, createPeriod, deletePeriod, bulkAddYouth, removeYouth, isOffline }) => { + const [bulkText, setBulkText] = useState(''); + const [bulkTeam, setBulkTeam] = useState<'PF' | 'TU'>('PF'); + const [expandedPeriod, setExpandedPeriod] = useState(null); + + const handleCreate = () => { + if (isOffline) { + alert("Du måste vara ansluten till internet för att skapa en ny period."); + return; + } + const name = (document.getElementById('periodName') as HTMLInputElement).value; + const start = (document.getElementById('periodStart') as HTMLInputElement).value; + if (name && start) createPeriod(name, start); + }; + + const handleDeletePeriod = (id: string) => { + if (isOffline) { + alert("Åtgärd nekad: Du måste vara ansluten till internet för att ta bort en period."); + return; + } + if (window.confirm('Är du säker på att du vill ta bort hela perioden? All närvarodata kopplad till perioden kommer försvinna!')) { + deletePeriod(id); + } + }; + + const handleRemoveYouth = (periodId: string, youthId: string, youthName: string) => { + if (isOffline) { + alert("Åtgärd nekad: Du måste vara ansluten till internet för att ta bort en ungdom."); + return; + } + if (window.confirm(`Är du säker på att du vill ta bort ${youthName} från perioden?`)) { + removeYouth(periodId, youthId); + } + }; + + const handleBulkAdd = () => { + if (isOffline) { + alert("Åtgärd nekad: Du måste vara ansluten till internet för att importera ungdomar."); + return; + } + if (expandedPeriod) { + const processedText = bulkText.split('\n').map(line => { + const parts = line.split(/[,|-]/).map(p => p.trim()); + if (parts.length > 1) { + const t = parts[1].toUpperCase(); + if (t === 'P' || t === 'PILGRIMSFALK' || t === 'PILGRIMSFALKARNA') parts[1] = 'PF'; + if (t === 'T' || t === 'TUMLARE' || t === 'TUMLARNA') parts[1] = 'TU'; + return parts.join(', '); + } + return line; + }).join('\n'); + + bulkAddYouth(expandedPeriod, processedText, bulkTeam); + setBulkText(''); + } + }; + + // Helper to render a group of youth + const renderYouthGroup = (youthList: Youth[], periodId: string) => { + if (youthList.length === 0) return null; + + return ( +
+ {youthList.map(youth => ( +
+
+
+ {youth.team +
+ {youth.name} +
+ +
+ ))} +
+ ); + }; + + return ( +
+ {/* Create New Period */} +
+

+ + Skapa Ny Period +

+
+ + + +
+
+ + {/* List Existing Periods */} + {periods.map(period => { + // Sort by name alphabetically + const pfYouth = period.youthList.filter(y => y.team === 'PF').sort((a, b) => a.name.localeCompare(b.name)); + const tuYouth = period.youthList.filter(y => y.team === 'TU').sort((a, b) => a.name.localeCompare(b.name)); + + return ( +
+
setExpandedPeriod(expandedPeriod === period.id ? null : period.id)}> +
+

{period.name}

+

{period.startDate} till {period.endDate} • {period.youthList.length} ungdomar

+
+ +
+ + {/* Expandable Youth Management */} + {expandedPeriod === period.id && ( +
+

Bulk-lägg till ungdomar

+
+