Initial commit: Lageplan v1.0 - Next.js 15.5, React 19

This commit is contained in:
Pepe Ziberi
2026-02-21 11:57:44 +01:00
commit adf3dc8c1d
167 changed files with 34265 additions and 0 deletions

128
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,128 @@
import { SignJWT, jwtVerify } from 'jose'
import { cookies } from 'next/headers'
import { prisma } from './db'
import bcrypt from 'bcryptjs'
const secretValue = process.env.NEXTAUTH_SECRET
if (!secretValue || secretValue.length < 32) {
console.warn('[AUTH] WARNING: NEXTAUTH_SECRET is missing or too short (<32 chars). Set a strong secret in production!')
}
const JWT_SECRET = new TextEncoder().encode(
secretValue || 'dev-only-fallback-do-not-use-in-production-' + Date.now()
)
export interface UserPayload {
id: string
email: string
name: string
role: 'SERVER_ADMIN' | 'TENANT_ADMIN' | 'OPERATOR' | 'VIEWER'
tenantId?: string
tenantSlug?: string
}
export async function createToken(user: UserPayload): Promise<string> {
return await new SignJWT({ user })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('24h')
.sign(JWT_SECRET)
}
export async function verifyToken(token: string): Promise<UserPayload | null> {
try {
const { payload } = await jwtVerify(token, JWT_SECRET)
return payload.user as UserPayload
} catch {
return null
}
}
export async function getSession(): Promise<UserPayload | null> {
const cookieStore = await cookies()
const token = cookieStore.get('auth-token')?.value
if (!token) return null
return await verifyToken(token)
}
export async function login(
email: string,
password: string
): Promise<{ success: boolean; user?: UserPayload; error?: string }> {
const user = await (prisma.user.findUnique({
where: { email },
select: {
id: true,
email: true,
name: true,
password: true,
role: true,
emailVerified: true,
},
}) as any)
if (!user) {
return { success: false, error: 'Benutzer nicht gefunden' }
}
const isValidPassword = await bcrypt.compare(password, user.password)
if (!isValidPassword) {
return { success: false, error: 'Ungültiges Passwort' }
}
// Check email verification (skip for SERVER_ADMIN and users created before verification was added)
if ((user as any).emailVerified === false && (user.role as string) !== 'SERVER_ADMIN') {
return { success: false, error: 'Bitte bestätigen Sie zuerst Ihre E-Mail-Adresse. Prüfen Sie Ihren Posteingang.' }
}
// Get first tenant membership for non-server-admins
let tenantId: string | undefined
let tenantSlug: string | undefined
if ((user.role as string) !== 'SERVER_ADMIN') {
const membership = await (prisma as any).tenantMembership.findFirst({
where: { userId: user.id },
include: { tenant: true },
orderBy: { createdAt: 'asc' },
})
if (membership) {
// Check if tenant is active
if (!membership.tenant.isActive) {
return { success: false, error: 'Ihr Mandant wurde gesperrt. Bitte kontaktieren Sie den Administrator.' }
}
tenantId = membership.tenantId
tenantSlug = membership.tenant.slug
}
}
const userPayload: UserPayload = {
id: user.id,
email: user.email,
name: user.name,
role: (user.role === 'ADMIN' ? 'SERVER_ADMIN' : user.role) as UserPayload['role'],
tenantId,
tenantSlug,
}
return { success: true, user: userPayload }
}
export async function hashPassword(password: string): Promise<string> {
return await bcrypt.hash(password, 12)
}
export function canEdit(role: string): boolean {
return role === 'SERVER_ADMIN' || role === 'TENANT_ADMIN' || role === 'OPERATOR'
}
export function isAdmin(role: string): boolean {
return role === 'SERVER_ADMIN' || role === 'TENANT_ADMIN'
}
export function isServerAdmin(role: string): boolean {
return role === 'SERVER_ADMIN'
}
export function isTenantAdmin(role: string): boolean {
return role === 'TENANT_ADMIN'
}

11
src/lib/db.ts Normal file
View File

@@ -0,0 +1,11 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
export default prisma

111
src/lib/email.ts Normal file
View File

@@ -0,0 +1,111 @@
import nodemailer from 'nodemailer'
import { prisma } from './db'
export interface SmtpConfig {
host: string
port: number
secure: boolean
user: string
pass: string
fromName: string
fromEmail: string
}
// Get SMTP settings from DB (SystemSetting table)
export async function getSmtpConfig(): Promise<SmtpConfig | null> {
try {
const settings = await (prisma as any).systemSetting.findMany({
where: { category: 'smtp' },
})
if (!settings || settings.length === 0) return null
const map: Record<string, string> = {}
for (const s of settings) {
map[s.key] = s.value
}
if (!map['smtp_host'] || !map['smtp_user']) return null
return {
host: map['smtp_host'],
port: parseInt(map['smtp_port'] || '587'),
secure: map['smtp_secure'] === 'true',
user: map['smtp_user'],
pass: map['smtp_pass'] || '',
fromName: map['smtp_from_name'] || 'Lageplan',
fromEmail: map['smtp_from_email'] || map['smtp_user'],
}
} catch {
return null
}
}
// Save SMTP settings to DB
export async function saveSmtpConfig(config: Partial<SmtpConfig>): Promise<void> {
const entries: { key: string; value: string; isSecret: boolean }[] = []
if (config.host !== undefined) entries.push({ key: 'smtp_host', value: config.host, isSecret: false })
if (config.port !== undefined) entries.push({ key: 'smtp_port', value: String(config.port), isSecret: false })
if (config.secure !== undefined) entries.push({ key: 'smtp_secure', value: String(config.secure), isSecret: false })
if (config.user !== undefined) entries.push({ key: 'smtp_user', value: config.user, isSecret: false })
if (config.pass !== undefined) entries.push({ key: 'smtp_pass', value: config.pass, isSecret: true })
if (config.fromName !== undefined) entries.push({ key: 'smtp_from_name', value: config.fromName, isSecret: false })
if (config.fromEmail !== undefined) entries.push({ key: 'smtp_from_email', value: config.fromEmail, isSecret: false })
for (const entry of entries) {
await (prisma as any).systemSetting.upsert({
where: { key: entry.key },
update: { value: entry.value, isSecret: entry.isSecret },
create: { key: entry.key, value: entry.value, isSecret: entry.isSecret, category: 'smtp' },
})
}
}
// Create nodemailer transport from DB settings
async function createTransport() {
const config = await getSmtpConfig()
if (!config) throw new Error('SMTP nicht konfiguriert')
return nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: {
user: config.user,
pass: config.pass,
},
tls: {
rejectUnauthorized: false,
},
})
}
// Send email
export async function sendEmail(to: string, subject: string, html: string): Promise<boolean> {
try {
const config = await getSmtpConfig()
if (!config) throw new Error('SMTP nicht konfiguriert')
const transport = await createTransport()
await transport.sendMail({
from: `"${config.fromName}" <${config.fromEmail}>`,
to,
subject,
html,
})
return true
} catch (error) {
console.error('Email send error:', error)
throw error
}
}
// Test SMTP connection
export async function testSmtpConnection(): Promise<{ success: boolean; error?: string }> {
try {
const transport = await createTransport()
await transport.verify()
return { success: true }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Verbindung fehlgeschlagen' }
}
}

184
src/lib/export.ts Normal file
View File

@@ -0,0 +1,184 @@
import html2canvas from 'html2canvas'
import jsPDF from 'jspdf'
import type { Project, DrawFeature } from '@/app/app/page'
import { formatDateTime } from './utils'
export interface ExportOptions {
includeTitle?: boolean
includeLegend?: boolean
includeTimestamp?: boolean
author?: string
}
export async function exportToPNG(
mapElement: HTMLElement,
project: Project,
options: ExportOptions = {}
): Promise<void> {
try {
const canvas = await html2canvas(mapElement, {
useCORS: true,
allowTaint: true,
scale: 2,
logging: false,
backgroundColor: '#ffffff',
})
const link = document.createElement('a')
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
link.download = `Lageplan_${project.title.replace(/[^a-z0-9äöüß]/gi, '_')}_${timestamp}.png`
link.href = canvas.toDataURL('image/png', 1.0)
link.click()
} catch (error) {
console.error('PNG export failed:', error)
throw new Error('PNG Export fehlgeschlagen')
}
}
export interface LegendEntry {
name: string
count: number
iconUrl?: string
unNummer?: string
gefahrennummer?: string
}
export async function exportToPDF(
mapElement: HTMLElement,
project: Project,
features: DrawFeature[],
legendEntries: LegendEntry[] = [],
options: ExportOptions = {}
): Promise<void> {
try {
const canvas = await html2canvas(mapElement, {
useCORS: true,
allowTaint: true,
scale: 2,
logging: false,
backgroundColor: '#ffffff',
})
const imgWidth = 190
const imgHeight = (canvas.height * imgWidth) / canvas.width
const pdf = new jsPDF('p', 'mm', 'a4')
// Title Block
pdf.setFillColor(220, 38, 38) // Red header
pdf.rect(0, 0, 210, 8, 'F')
pdf.setTextColor(255, 255, 255)
pdf.setFontSize(12)
pdf.setFont('helvetica', 'bold')
pdf.text('LAGEPLAN - FEUERWEHR', 10, 5.5)
pdf.setTextColor(0, 0, 0)
// Project info box
pdf.setFillColor(245, 245, 245)
pdf.rect(10, 12, 190, 28, 'F')
pdf.setDrawColor(200, 200, 200)
pdf.rect(10, 12, 190, 28, 'S')
pdf.setFontSize(14)
pdf.setFont('helvetica', 'bold')
pdf.text(project.title, 15, 22)
pdf.setFontSize(10)
pdf.setFont('helvetica', 'normal')
let infoY = 28
if (project.location) {
pdf.text(`Einsatzort: ${project.location}`, 15, infoY)
infoY += 5
}
const now = new Date()
pdf.text(`Datum: ${now.toLocaleDateString('de-DE')}`, 15, infoY)
pdf.text(`Uhrzeit: ${now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`, 80, infoY)
if (options.author) {
pdf.text(`Ersteller: ${options.author}`, 130, infoY)
}
// Map image
const mapY = 45
const maxMapHeight = legendEntries.length > 0 ? 180 : 220
const actualMapHeight = Math.min(imgHeight, maxMapHeight)
pdf.addImage(canvas.toDataURL('image/png'), 'PNG', 10, mapY, imgWidth, actualMapHeight)
pdf.setDrawColor(100, 100, 100)
pdf.rect(10, mapY, imgWidth, actualMapHeight, 'S')
// Legend
if (legendEntries.length > 0) {
const legendY = mapY + actualMapHeight + 8
pdf.setFillColor(245, 245, 245)
pdf.rect(10, legendY, 190, 6, 'F')
pdf.setFontSize(10)
pdf.setFont('helvetica', 'bold')
pdf.text('Legende', 15, legendY + 4.5)
pdf.setFontSize(8)
pdf.setFont('helvetica', 'normal')
let y = legendY + 12
const colWidth = 63
let col = 0
legendEntries.forEach((entry, i) => {
if (y < 280) {
const x = 15 + (col * colWidth)
let text = `${entry.name}`
if (entry.count > 1) text += ` (${entry.count})`
if (entry.unNummer) text += ` [UN ${entry.unNummer}]`
pdf.text(text, x, y)
col++
if (col >= 3) {
col = 0
y += 5
}
}
})
}
// Footer
pdf.setFontSize(7)
pdf.setTextColor(128, 128, 128)
pdf.text(
`Erstellt mit Lageplan-App | ${now.toLocaleDateString('de-DE')} ${now.toLocaleTimeString('de-DE')}`,
10,
292
)
pdf.text('Seite 1 von 1', 180, 292)
const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 19)
pdf.save(`Lageplan_${project.title.replace(/[^a-z0-9äöüß]/gi, '_')}_${timestamp}.pdf`)
} catch (error) {
console.error('PDF export failed:', error)
throw new Error('PDF Export fehlgeschlagen')
}
}
export async function exportToGeoJSON(projectId: string): Promise<void> {
try {
const res = await fetch(`/api/projects/${projectId}/export?format=geojson`)
if (!res.ok) throw new Error('Export fehlgeschlagen')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `project_${projectId}.geojson`
link.click()
URL.revokeObjectURL(url)
} catch (error) {
console.error('GeoJSON export failed:', error)
throw new Error('GeoJSON Export fehlgeschlagen')
}
}

35
src/lib/fw-symbols.ts Normal file
View File

@@ -0,0 +1,35 @@
// Feuerwehr-Signaturen nach Schweizer Standard (FKS/BABS)
// Icons are now loaded from public/signaturen/ via the database (seed.js)
// This file is kept for backward compatibility with old saved features
export interface FWSymbol {
id: string
name: string
category: string
svg: string
}
export interface FWCategory {
id: string
name: string
symbols: FWSymbol[]
}
const svgToDataUri = (svg: string): string => {
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
}
// All symbols are now served from the database / public/signaturen/
export const fwCategories: FWCategory[] = []
export function getSymbolById(id: string): FWSymbol | undefined {
for (const cat of fwCategories) {
const found = cat.symbols.find((s) => s.id === id)
if (found) return found
}
return undefined
}
export function getSymbolDataUri(symbol: FWSymbol): string {
return svgToDataUri(symbol.svg)
}

54
src/lib/minio.ts Normal file
View File

@@ -0,0 +1,54 @@
import * as Minio from 'minio'
const minioClient = new Minio.Client({
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
port: parseInt(process.env.MINIO_PORT || '9000'),
useSSL: process.env.MINIO_USE_SSL === 'true',
accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin',
secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin123',
})
const BUCKET = process.env.MINIO_BUCKET || 'lageplan-icons'
export async function ensureBucket(): Promise<void> {
const exists = await minioClient.bucketExists(BUCKET)
if (!exists) {
await minioClient.makeBucket(BUCKET)
}
}
export async function uploadFile(
fileKey: string,
buffer: Buffer,
mimeType: string
): Promise<string> {
await ensureBucket()
await minioClient.putObject(BUCKET, fileKey, buffer, buffer.length, {
'Content-Type': mimeType,
})
return fileKey
}
export async function getFileUrl(fileKey: string): Promise<string> {
const publicUrl = process.env.MINIO_PUBLIC_URL || 'http://localhost:9000'
return `${publicUrl}/${BUCKET}/${fileKey}`
}
export async function getFileStream(fileKey: string): Promise<{ stream: NodeJS.ReadableStream; contentType: string }> {
const stat = await minioClient.statObject(BUCKET, fileKey)
const stream = await minioClient.getObject(BUCKET, fileKey)
return { stream, contentType: stat.metaData['content-type'] || 'application/octet-stream' }
}
export async function deleteFile(fileKey: string): Promise<void> {
await minioClient.removeObject(BUCKET, fileKey)
}
export async function getPresignedUrl(
fileKey: string,
expirySeconds: number = 3600
): Promise<string> {
return await minioClient.presignedGetObject(BUCKET, fileKey, expirySeconds)
}
export { minioClient, BUCKET }

319
src/lib/rapport-pdf.tsx Normal file
View File

@@ -0,0 +1,319 @@
import React from 'react'
import { Document, Page, Text, View, StyleSheet, Font, Image } from '@react-pdf/renderer'
// Register default font (Helvetica is built-in)
const styles = StyleSheet.create({
page: {
padding: '12mm 15mm 15mm',
fontFamily: 'Helvetica',
fontSize: 10,
lineHeight: 1.4,
color: '#1a1a1a',
},
// Header
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
paddingBottom: 8,
borderBottomWidth: 2.5,
borderBottomColor: '#1a1a1a',
marginBottom: 4,
},
headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 12 },
headerTitle: { fontSize: 18, fontFamily: 'Helvetica-Bold', letterSpacing: -0.5 },
headerSubtitle: { fontSize: 8, color: '#555', marginTop: 2, fontFamily: 'Helvetica-Bold' },
headerRight: { textAlign: 'right' },
eventId: { fontSize: 16, fontFamily: 'Helvetica-Bold', color: '#c0392b' },
eventDate: { fontSize: 8, color: '#555', marginTop: 2 },
headerStripe: { height: 3, marginBottom: 10, backgroundColor: '#c0392b' },
// Sections
section: { marginBottom: 10 },
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 5 },
sectionNumber: {
width: 18, height: 18, backgroundColor: '#1a1a1a', borderRadius: 3,
color: '#fff', fontSize: 8, fontFamily: 'Helvetica-Bold',
textAlign: 'center', paddingTop: 3,
},
sectionTitle: { fontSize: 9, fontFamily: 'Helvetica-Bold', textTransform: 'uppercase', letterSpacing: 0.8 },
sectionLine: { flex: 1, height: 1, backgroundColor: '#d0d0d0', marginLeft: 6 },
// Field grid
fieldGrid: { borderWidth: 1, borderColor: '#d0d0d0', borderRadius: 4 },
fieldRow: { flexDirection: 'row' },
field: { padding: '5 8', borderBottomWidth: 1, borderBottomColor: '#e8e8e8', borderRightWidth: 1, borderRightColor: '#e8e8e8', minHeight: 32 },
fieldLabel: { fontSize: 6.5, fontFamily: 'Helvetica-Bold', textTransform: 'uppercase', letterSpacing: 0.5, color: '#888', marginBottom: 1 },
fieldValue: { fontSize: 9, fontFamily: 'Helvetica-Bold', color: '#1a1a1a', minHeight: 14 },
fieldValueMono: { fontSize: 8.5, fontFamily: 'Courier', color: '#1a1a1a', minHeight: 14 },
fieldHighlight: { backgroundColor: '#f5f5f5' },
// Resource table
tableHeader: { flexDirection: 'row', backgroundColor: '#1a1a1a' },
tableHeaderCell: { padding: '4 8', fontSize: 7, fontFamily: 'Helvetica-Bold', textTransform: 'uppercase', letterSpacing: 0.5, color: '#fff' },
tableRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#e8e8e8' },
tableRowEven: { backgroundColor: '#f5f5f5' },
tableCell: { padding: '4 8', fontSize: 8.5 },
// Notes
notesBox: { borderWidth: 1, borderColor: '#d0d0d0', borderRadius: 4, padding: 8, minHeight: 50 },
notesText: { fontSize: 9, color: '#1a1a1a' },
// Signatures
signatureBlock: { flexDirection: 'row', gap: 30, marginTop: 8, marginBottom: 20 },
signatureField: { flex: 1 },
signatureLine: { borderBottomWidth: 1, borderBottomColor: '#1a1a1a', height: 25, marginBottom: 3 },
signatureLabel: { fontSize: 7, color: '#888' },
// Footer
footer: {
position: 'absolute', bottom: '10mm', left: '15mm', right: '15mm',
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-end',
paddingTop: 6, borderTopWidth: 1, borderTopColor: '#d0d0d0', fontSize: 7, color: '#888',
},
// Map placeholder
mapContainer: {
borderWidth: 1, borderColor: '#d0d0d0', borderRadius: 4,
height: '80mm', backgroundColor: '#f5f5f5',
justifyContent: 'center', alignItems: 'center',
},
mapPlaceholder: { fontSize: 9, color: '#888', textAlign: 'center' },
// Priority badges
priorityHigh: { backgroundColor: '#fde8e8', color: '#991b1b', padding: '2 8', borderRadius: 3, fontSize: 7.5, fontFamily: 'Helvetica-Bold' },
priorityMedium: { backgroundColor: '#fef3cd', color: '#856404', padding: '2 8', borderRadius: 3, fontSize: 7.5, fontFamily: 'Helvetica-Bold' },
priorityLow: { backgroundColor: '#d4edda', color: '#155724', padding: '2 8', borderRadius: 3, fontSize: 7.5, fontFamily: 'Helvetica-Bold' },
})
export interface RapportData {
reportNumber: string
organisation: string
abteilung: string
datum: string
uhrzeit: string
einsatzNr: string
alarmzeit: string
prioritaet: string
einsatzort: string
koordinaten: string
objekt: string
alarmierungsart: string
stichwort: string
zeitAlarm: string
zeitAusruecken: string
zeitEintreffen: string
zeitBereit: string
zeitKontrolle: string
zeitAus: string
zeitEinruecken: string
zeitEnde: string
lageEintreffen: string
massnahmen: string | string[]
fahrzeuge: { name: string; pers: string; ausruecken: string; eintreffen: string; auftrag: string }[]
bemerkungen: string
einsatzleiter: string
rapporteur: string
qrCodeUrl?: string
mapScreenshot?: string
logoUrl?: string
}
function PriorityBadge({ priority }: { priority: string }) {
const p = priority.toLowerCase()
const style = p === 'hoch' || p === 'high' ? styles.priorityHigh
: p === 'mittel' || p === 'medium' ? styles.priorityMedium
: styles.priorityLow
return <Text style={style}>{priority.toUpperCase()}</Text>
}
function FieldCell({ label, value, mono, highlight, width }: { label: string; value: string; mono?: boolean; highlight?: boolean; width: string }) {
return (
<View style={[styles.field, highlight ? styles.fieldHighlight : {}, { width }]}>
<Text style={styles.fieldLabel}>{label}</Text>
<Text style={mono ? styles.fieldValueMono : styles.fieldValue}>{value || '—'}</Text>
</View>
)
}
export function RapportDocument({ data }: { data: RapportData }) {
return (
<Document>
<Page size="A4" style={styles.page}>
{/* Header */}
<View style={styles.header}>
<View style={styles.headerLeft}>
{data.logoUrl ? (
<Image src={data.logoUrl} style={{ width: 40, height: 40, marginRight: 10, objectFit: 'contain' }} />
) : null}
<View>
<Text style={styles.headerTitle}>Einsatzrapport</Text>
<Text style={styles.headerSubtitle}>{data.organisation} · {data.abteilung}</Text>
</View>
</View>
<View style={styles.headerRight}>
<Text style={styles.eventId}>{data.reportNumber}</Text>
<Text style={styles.eventDate}>{data.datum} · {data.uhrzeit}</Text>
</View>
</View>
<View style={styles.headerStripe} />
{/* 1. Einsatzdaten */}
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionNumber}>1</Text>
<Text style={styles.sectionTitle}>Einsatzdaten</Text>
<View style={styles.sectionLine} />
</View>
<View style={styles.fieldGrid}>
<View style={styles.fieldRow}>
<FieldCell label="Einsatz-Nr." value={data.einsatzNr} mono width="25%" />
<FieldCell label="Datum" value={data.datum} width="25%" />
<FieldCell label="Alarmzeit" value={data.alarmzeit} mono width="25%" />
<FieldCell label="Priorität" value={data.prioritaet} width="25%" />
</View>
<View style={styles.fieldRow}>
<FieldCell label="Einsatzort / Adresse" value={data.einsatzort} width="50%" />
<FieldCell label="Koordinaten" value={data.koordinaten} mono width="25%" />
<FieldCell label="Objekt / Gebäude" value={data.objekt} width="25%" />
</View>
<View style={styles.fieldRow}>
<FieldCell label="Alarmierungsart" value={data.alarmierungsart} width="50%" />
<FieldCell label="Stichwort / Meldebild" value={data.stichwort} width="50%" />
</View>
</View>
</View>
{/* 2. Zeitverlauf */}
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionNumber}>2</Text>
<Text style={styles.sectionTitle}>Zeitverlauf</Text>
<View style={styles.sectionLine} />
</View>
<View style={styles.fieldGrid}>
<View style={styles.fieldRow}>
<FieldCell label="Alarmierung" value={data.zeitAlarm} mono highlight width="25%" />
<FieldCell label="Ausrücken" value={data.zeitAusruecken} mono highlight width="25%" />
<FieldCell label="Eintreffen" value={data.zeitEintreffen} mono highlight width="25%" />
<FieldCell label="Einsatzbereit" value={data.zeitBereit} mono highlight width="25%" />
</View>
<View style={styles.fieldRow}>
<FieldCell label="Feuer unter Kontrolle" value={data.zeitKontrolle} mono highlight width="25%" />
<FieldCell label="Feuer aus" value={data.zeitAus} mono highlight width="25%" />
<FieldCell label="Einrücken" value={data.zeitEinruecken} mono highlight width="25%" />
<FieldCell label="Einsatzende" value={data.zeitEnde} mono highlight width="25%" />
</View>
</View>
</View>
{/* 3. Lagebild */}
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionNumber}>3</Text>
<Text style={styles.sectionTitle}>Lagebild</Text>
<View style={styles.sectionLine} />
</View>
<View style={styles.fieldGrid}>
<View style={styles.fieldRow}>
<FieldCell label="Lage bei Eintreffen" value={data.lageEintreffen} width="100%" />
</View>
<View style={styles.fieldRow}>
<View style={[styles.field, { width: '100%' }]}>
<Text style={styles.fieldLabel}>Getroffene Massnahmen</Text>
{Array.isArray(data.massnahmen) ? (
data.massnahmen.map((m, i) => (
<Text key={i} style={[styles.fieldValue, { fontSize: 8, marginBottom: 1.5 }]}> {m}</Text>
))
) : (
<Text style={styles.fieldValue}>{data.massnahmen || '—'}</Text>
)}
</View>
</View>
</View>
</View>
{/* 4. Lageplan-Karte */}
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionNumber}>4</Text>
<Text style={styles.sectionTitle}>Lageplan / Skizze</Text>
<View style={styles.sectionLine} />
</View>
{data.mapScreenshot ? (
<Image src={data.mapScreenshot} style={{ width: '100%', height: 'auto', maxHeight: '80mm', borderWidth: 1, borderColor: '#d0d0d0', borderRadius: 4 }} />
) : (
<View style={styles.mapContainer}>
<Text style={styles.mapPlaceholder}>Lageplan aus app.lageplan.ch{'\n'}({data.einsatzort})</Text>
</View>
)}
</View>
{/* 5. Eingesetzte Mittel */}
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionNumber}>5</Text>
<Text style={styles.sectionTitle}>Eingesetzte Mittel</Text>
<View style={styles.sectionLine} />
</View>
<View style={styles.fieldGrid}>
<View style={styles.tableHeader}>
<Text style={[styles.tableHeaderCell, { width: '25%' }]}>Fahrzeug / Mittel</Text>
<Text style={[styles.tableHeaderCell, { width: '10%' }]}>Pers.</Text>
<Text style={[styles.tableHeaderCell, { width: '15%' }]}>Ausrücken</Text>
<Text style={[styles.tableHeaderCell, { width: '15%' }]}>Eintreffen</Text>
<Text style={[styles.tableHeaderCell, { width: '35%' }]}>Auftrag / Bemerkung</Text>
</View>
{(data.fahrzeuge.length > 0 ? data.fahrzeuge : [{ name: '', pers: '', ausruecken: '', eintreffen: '', auftrag: '' }]).map((fz, i) => (
<View key={i} style={[styles.tableRow, i % 2 === 1 ? styles.tableRowEven : {}]}>
<Text style={[styles.tableCell, { width: '25%' }]}>{fz.name}</Text>
<Text style={[styles.tableCell, { width: '10%' }]}>{fz.pers}</Text>
<Text style={[styles.tableCell, { width: '15%' }]}>{fz.ausruecken}</Text>
<Text style={[styles.tableCell, { width: '15%' }]}>{fz.eintreffen}</Text>
<Text style={[styles.tableCell, { width: '35%' }]}>{fz.auftrag}</Text>
</View>
))}
</View>
</View>
{/* 6. Bemerkungen */}
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionNumber}>6</Text>
<Text style={styles.sectionTitle}>Bemerkungen / Besondere Vorkommnisse</Text>
<View style={styles.sectionLine} />
</View>
<View style={styles.notesBox}>
<Text style={styles.notesText}>{data.bemerkungen || ''}</Text>
</View>
</View>
{/* Unterschriften */}
<View style={styles.signatureBlock}>
<View style={styles.signatureField}>
<View style={styles.signatureLine} />
<Text style={styles.signatureLabel}>Einsatzleiter/in · {data.einsatzleiter}</Text>
</View>
<View style={styles.signatureField}>
<View style={styles.signatureLine} />
<Text style={styles.signatureLabel}>Rapport erstellt durch · {data.rapporteur}</Text>
</View>
<View style={styles.signatureField}>
<View style={styles.signatureLine} />
<Text style={styles.signatureLabel}>Datum / Visum Kdt</Text>
</View>
</View>
{/* Footer with QR code */}
<View style={styles.footer} fixed>
<View>
<Text>Erstellt: {data.datum} {data.uhrzeit} · {data.organisation}</Text>
<Text>Projekt: {data.reportNumber} · Standort: {data.einsatzort}</Text>
</View>
{data.qrCodeUrl ? (
<View style={{ alignItems: 'center' }}>
<Image src={data.qrCodeUrl} style={{ width: 45, height: 45 }} />
<Text style={{ fontSize: 5, color: '#888', marginTop: 1 }}>Online-Rapport</Text>
</View>
) : null}
<View style={{ textAlign: 'right' }}>
<Text>app.lageplan.ch</Text>
<Text render={({ pageNumber, totalPages }: { pageNumber: number; totalPages: number }) => `Seite ${pageNumber}/${totalPages}`} />
</View>
</View>
</Page>
</Document>
)
}

25
src/lib/socket.ts Normal file
View File

@@ -0,0 +1,25 @@
'use client'
import { io, Socket } from 'socket.io-client'
let socket: Socket | null = null
export function getSocket(): Socket {
if (!socket) {
socket = io({
path: '/socket.io',
transports: ['polling', 'websocket'],
upgrade: true,
reconnectionAttempts: 10,
reconnectionDelay: 2000,
timeout: 10000,
})
socket.on('connect', () => {
console.log('[Socket.io] Connected:', socket?.id)
})
socket.on('connect_error', (err) => {
console.warn('[Socket.io] Connection error:', err.message)
})
}
return socket
}

61
src/lib/stripe.ts Normal file
View File

@@ -0,0 +1,61 @@
import Stripe from 'stripe'
import { prisma } from '@/lib/db'
let stripeInstance: Stripe | null = null
export async function getStripeConfig(): Promise<{ secretKey: string; publicKey: string; webhookSecret: string } | null> {
try {
const keys = await (prisma as any).systemSetting.findMany({
where: {
key: { in: ['stripe_secret_key', 'stripe_public_key', 'stripe_webhook_secret'] },
},
})
const secretKey = keys.find((k: any) => k.key === 'stripe_secret_key')?.value
const publicKey = keys.find((k: any) => k.key === 'stripe_public_key')?.value
const webhookSecret = keys.find((k: any) => k.key === 'stripe_webhook_secret')?.value
if (!secretKey || !publicKey) {
console.log('[Stripe] Config missing:', { hasSecret: !!secretKey, hasPublic: !!publicKey, keysFound: keys.length })
return null
}
return { secretKey, publicKey, webhookSecret: webhookSecret || '' }
} catch (error) {
console.error('[Stripe] Error loading config:', error)
return null
}
}
export async function getStripe(): Promise<Stripe | null> {
const config = await getStripeConfig()
if (!config) return null
if (!stripeInstance || (stripeInstance as any)._api?.auth !== config.secretKey) {
stripeInstance = new Stripe(config.secretKey, {
apiVersion: '2024-06-20' as any,
})
}
return stripeInstance
}
export async function saveStripeConfig(config: {
secretKey?: string
publicKey?: string
webhookSecret?: string
}) {
const entries = [
{ key: 'stripe_secret_key', value: config.secretKey, isSecret: true },
{ key: 'stripe_public_key', value: config.publicKey, isSecret: false },
{ key: 'stripe_webhook_secret', value: config.webhookSecret, isSecret: true },
].filter(e => e.value !== undefined && e.value !== '••••••••')
for (const entry of entries) {
await (prisma as any).systemSetting.upsert({
where: { key: entry.key },
update: { value: entry.value },
create: { key: entry.key, value: entry.value, isSecret: entry.isSecret, category: 'stripe' },
})
}
}

44
src/lib/tenant.ts Normal file
View File

@@ -0,0 +1,44 @@
import { prisma } from './db'
import { UserPayload } from './auth'
/**
* Get the tenantId filter for data queries.
* - SERVER_ADMIN: no filter (sees everything)
* - All others: filter by their tenantId
*/
export function getTenantFilter(user: UserPayload): Record<string, any> {
if (user.role === 'SERVER_ADMIN') return {}
if (!user.tenantId) return { ownerId: user.id } // no tenant → only own projects
// Tenant user: see tenant projects + own legacy projects (tenantId=null)
return {
OR: [
{ tenantId: user.tenantId },
{ ownerId: user.id, tenantId: null },
],
}
}
/**
* Check if a user has access to a specific project.
* Returns the project if access is granted, null otherwise.
*/
export async function getProjectWithTenantCheck(projectId: string, user: UserPayload) {
const project = await (prisma as any).project.findUnique({
where: { id: projectId },
})
if (!project) return null
// SERVER_ADMIN can access all projects
if (user.role === 'SERVER_ADMIN') return project
// Projects without tenantId (legacy): allow if user is the owner
if (!project.tenantId) {
if (project.ownerId === user.id) return project
return null
}
// All others: must belong to same tenant
if (project.tenantId !== user.tenantId) return null
return project
}

38
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,38 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatDate(date: Date | string): string {
const d = new Date(date)
return d.toLocaleDateString('de-CH', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
export function formatDateTime(date: Date | string): string {
const d = new Date(date)
return d.toLocaleDateString('de-CH', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
export function formatTime(date: Date | string): string {
const d = new Date(date)
return d.toLocaleTimeString('de-CH', {
hour: '2-digit',
minute: '2-digit',
})
}
export function generateId(): string {
return Math.random().toString(36).substring(2, 15)
}

38
src/lib/validations.ts Normal file
View File

@@ -0,0 +1,38 @@
import { z } from 'zod'
export const loginSchema = z.object({
email: z.string().email('Ungültige E-Mail-Adresse'),
password: z.string().min(1, 'Passwort erforderlich'),
})
export const projectSchema = z.object({
title: z.string().min(1, 'Titel erforderlich').max(200, 'Titel zu lang'),
location: z.string().optional(),
description: z.string().optional(),
einsatzleiter: z.string().optional(),
journalfuehrer: z.string().optional(),
mapCenter: z.object({
lng: z.number(),
lat: z.number(),
}).optional(),
mapZoom: z.number().min(1).max(22).optional(),
})
export const featureSchema = z.object({
type: z.string(),
geometry: z.object({
type: z.string(),
coordinates: z.any(),
}),
properties: z.record(z.any()).optional(),
})
export const iconUploadSchema = z.object({
name: z.string().min(1, 'Name erforderlich').max(100, 'Name zu lang'),
categoryId: z.string().uuid('Ungültige Kategorie'),
})
export type LoginInput = z.infer<typeof loginSchema>
export type ProjectInput = z.infer<typeof projectSchema>
export type FeatureInput = z.infer<typeof featureSchema>
export type IconUploadInput = z.infer<typeof iconUploadSchema>