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

96
src/app/[slug]/page.tsx Normal file
View File

@@ -0,0 +1,96 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useAuth } from '@/components/providers/auth-provider'
import { Loader2 } from 'lucide-react'
import { LogoRound } from '@/components/ui/logo'
interface TenantInfo {
name: string
slug: string
logoUrl: string | null
}
export default function TenantPortalPage() {
const { slug } = useParams<{ slug: string }>()
const { user, loading } = useAuth()
const router = useRouter()
const [error, setError] = useState<string | null>(null)
const [tenant, setTenant] = useState<TenantInfo | null>(null)
useEffect(() => {
// Skip known non-tenant routes
const reserved = ['app', 'admin', 'login', 'register', 'api', 'forgot-password', 'reset-password', 'spenden', 'demo']
if (reserved.includes(slug)) {
return
}
// Fetch tenant info (public)
fetch(`/api/tenants/by-slug/${slug}`)
.then(async res => {
const data = await res.json()
if (res.ok && data?.tenant) {
setTenant(data.tenant)
} else if (res.status === 403 && data?.reason === 'suspended') {
// Tenant exists but is suspended
if (data.tenant) setTenant(data.tenant)
setError('Dieser Mandant wurde gesperrt. Bitte kontaktieren Sie den Administrator für weitere Informationen.')
} else {
setError('Mandant nicht gefunden.')
}
})
.catch(() => setError('Fehler beim Laden.'))
}, [slug])
useEffect(() => {
const reserved = ['app', 'admin', 'login', 'register', 'api', 'forgot-password', 'reset-password', 'spenden', 'demo']
if (reserved.includes(slug)) return
if (loading || !tenant) return
if (!user) {
// Not logged in → redirect to login with slug context
router.push(`/login?redirect=/${slug}&tenant=${slug}`)
return
}
// Check if user belongs to this tenant
if (user.tenantSlug === slug) {
router.push('/app')
} else {
setError('Sie haben keinen Zugang zu diesem Mandanten.')
}
}, [slug, user, loading, router, tenant])
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 px-4">
<div className="text-center">
{tenant?.logoUrl ? (
<img src={tenant.logoUrl} alt={tenant.name} className="w-20 h-20 object-contain mx-auto mb-4 rounded-lg" />
) : (
<LogoRound size={56} className="mx-auto mb-4" />
)}
<h1 className="text-xl font-bold text-white mb-2">Zugang verweigert</h1>
<p className="text-gray-400 mb-6">{error}</p>
<button onClick={() => router.push(`/login?tenant=${slug}`)} className="text-red-400 hover:text-red-300 text-sm underline">
Zur Anmeldung
</button>
</div>
</div>
)
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
{tenant?.logoUrl ? (
<div className="flex flex-col items-center gap-4">
<img src={tenant.logoUrl} alt={tenant.name} className="w-20 h-20 object-contain rounded-lg animate-pulse" />
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
</div>
) : (
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
)}
</div>
)
}

1854
src/app/admin/page.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from 'next/server'
import db from '@/lib/db'
import { getSession } from '@/lib/auth'
import { z } from 'zod'
const updateSchema = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().max(500).nullable().optional(),
sortOrder: z.number().int().optional(),
})
export async function PATCH(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
// Check permission: TENANT_ADMIN can only edit their own tenant categories
const category = await db.iconCategory.findUnique({ where: { id: params.id } }) as any
if (!category) return NextResponse.json({ error: 'Kategorie nicht gefunden' }, { status: 404 })
if (user.role !== 'SERVER_ADMIN') {
if (!category.tenantId || category.tenantId !== user.tenantId) {
return NextResponse.json({ error: 'Keine Berechtigung für diese Kategorie' }, { status: 403 })
}
}
const body = await req.json()
const data = updateSchema.parse(body)
const updated = await db.iconCategory.update({
where: { id: params.id },
data,
})
return NextResponse.json({ category: updated })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Ungültige Daten' }, { status: 400 })
}
console.error('Error updating category:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
export async function DELETE(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
// Check permission: TENANT_ADMIN can only delete their own tenant categories
const category = await db.iconCategory.findUnique({ where: { id: params.id } }) as any
if (!category) return NextResponse.json({ error: 'Kategorie nicht gefunden' }, { status: 404 })
// Global categories (isGlobal=true, tenantId=null) cannot be deleted
if (category.isGlobal || (!category.tenantId && category.isSystem !== false)) {
return NextResponse.json({ error: 'Globale Kategorien können nicht gelöscht werden. Blenden Sie sie stattdessen aus.' }, { status: 403 })
}
if (user.role !== 'SERVER_ADMIN') {
if (!category.tenantId || category.tenantId !== user.tenantId) {
return NextResponse.json({ error: 'Keine Berechtigung für diese Kategorie' }, { status: 403 })
}
}
// Disconnect all icons from this category (set categoryId to null) so they are NOT deleted
await db.$executeRawUnsafe(
`UPDATE icon_assets SET "categoryId" = NULL WHERE "categoryId" = $1`,
params.id
)
await db.iconCategory.delete({
where: { id: params.id },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error deleting category:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,77 @@
import { NextRequest, NextResponse } from 'next/server'
import db from '@/lib/db'
import { getSession } from '@/lib/auth'
import { z } from 'zod'
const categorySchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).nullable().optional(),
})
export async function GET() {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
let where: any = {}
if (user.role !== 'SERVER_ADMIN') {
// TENANT_ADMIN: show global categories (tenantId=null) + their own tenant categories
where = {
OR: [
{ tenantId: null },
...(user.tenantId ? [{ tenantId: user.tenantId }] : []),
],
}
}
// SERVER_ADMIN: show all categories (no filter)
const categories = await db.iconCategory.findMany({
where,
orderBy: { sortOrder: 'asc' },
include: {
_count: { select: { icons: true } },
},
})
return NextResponse.json({ categories })
} catch (error) {
console.error('Error fetching categories:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
export async function POST(req: NextRequest) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const body = await req.json()
const data = categorySchema.parse(body)
const maxOrder = await db.iconCategory.aggregate({
_max: { sortOrder: true },
})
// SERVER_ADMIN creates global categories; TENANT_ADMIN creates tenant-specific ones
const isGlobal = user.role === 'SERVER_ADMIN'
const tenantId = user.role === 'SERVER_ADMIN' ? null : (user.tenantId || null)
const category = await db.iconCategory.create({
data: {
name: data.name,
description: data.description,
sortOrder: (maxOrder._max.sortOrder || 0) + 1,
isGlobal,
tenantId,
} as any,
})
return NextResponse.json({ category }, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Ungültige Daten' }, { status: 400 })
}
console.error('Error creating category:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,103 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { deleteFile } from '@/lib/minio'
import { getSession, isAdmin, isServerAdmin } from '@/lib/auth'
import { z } from 'zod'
const updateSchema = z.object({
name: z.string().min(1).max(100).optional(),
categoryId: z.string().uuid().optional(),
iconType: z.enum(['STANDARD', 'RETTUNG', 'GEFAHRSTOFF', 'FEUER', 'WASSER', 'FAHRZEUG']).optional(),
tags: z.array(z.string()).optional(),
isActive: z.boolean().optional(),
})
export async function PATCH(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user || !isAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const body = await req.json()
const data = updateSchema.parse(body)
// TENANT_ADMIN can only edit their own icons
if (!isServerAdmin(user.role)) {
const existing = await (prisma as any).iconAsset.findUnique({ where: { id: params.id } })
if (existing && existing.tenantId !== user.tenantId) {
return NextResponse.json({ error: 'Keine Berechtigung für dieses Symbol' }, { status: 403 })
}
}
const icon = await (prisma as any).iconAsset.update({
where: { id: params.id },
data: {
...(data.name && { name: data.name }),
...(data.categoryId && { categoryId: data.categoryId }),
...(data.iconType && { iconType: data.iconType }),
...(data.tags && { tags: data.tags }),
...(data.isActive !== undefined && { isActive: data.isActive }),
},
include: {
category: true,
},
})
return NextResponse.json({ icon })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Ungültige Daten' }, { status: 400 })
}
console.error('Error updating icon:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
export async function DELETE(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user || !isAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const icon = await (prisma as any).iconAsset.findUnique({
where: { id: params.id },
})
if (!icon) {
return NextResponse.json({ error: 'Icon nicht gefunden' }, { status: 404 })
}
// TENANT_ADMIN can only delete their own tenant icons, not global/system ones
if (!isServerAdmin(user.role)) {
if (!icon.tenantId || icon.tenantId !== user.tenantId) {
return NextResponse.json({ error: 'Globale Symbole können nicht gelöscht werden. Sie können nur Ihre eigenen Symbole löschen.' }, { status: 403 })
}
}
// Delete from MinIO if not a system icon
if (icon.fileKey && !icon.fileKey.startsWith('system:') && !icon.isSystem) {
try {
await deleteFile(icon.fileKey)
} catch (e) {
console.error('Error deleting file from MinIO:', e)
}
}
await (prisma as any).iconAsset.delete({
where: { id: params.id },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error deleting icon:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server'
import db from '@/lib/db'
export async function GET() {
try {
const icons = await db.iconAsset.findMany({
orderBy: [{ category: { sortOrder: 'asc' } }, { name: 'asc' }],
include: {
category: true,
},
})
return NextResponse.json({ icons })
} catch (error) {
console.error('Error fetching icons:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,91 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { uploadFile } from '@/lib/minio'
import { getSession, isAdmin } from '@/lib/auth'
import { v4 as uuidv4 } from 'uuid'
const ALLOWED_TYPES = ['image/png', 'image/svg+xml', 'image/jpeg', 'image/webp']
const MAX_SIZE = 5 * 1024 * 1024 // 5MB
export async function POST(req: NextRequest) {
try {
const user = await getSession()
if (!user || !isAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const formData = await req.formData()
const file = formData.get('file') as File | null
const categoryId = formData.get('categoryId') as string
const iconType = (formData.get('iconType') as string) || 'STANDARD'
const name = formData.get('name') as string
if (!file || !categoryId || !name) {
return NextResponse.json(
{ error: 'Datei, Kategorie und Name sind erforderlich' },
{ status: 400 }
)
}
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json(
{ error: 'Nur PNG, SVG, JPEG und WebP Dateien erlaubt' },
{ status: 400 }
)
}
if (file.size > MAX_SIZE) {
return NextResponse.json(
{ error: 'Datei zu groß (max. 5MB)' },
{ status: 400 }
)
}
// Check category exists
const category = await (prisma as any).iconCategory.findUnique({
where: { id: categoryId },
})
if (!category) {
return NextResponse.json(
{ error: 'Kategorie nicht gefunden' },
{ status: 404 }
)
}
// Generate safe filename
const ext = file.name.split('.').pop()?.toLowerCase() || 'png'
const safeFileName = `${uuidv4()}.${ext}`
const fileKey = `icons/${safeFileName}`
// Upload to MinIO
const buffer = Buffer.from(await file.arrayBuffer())
await uploadFile(fileKey, buffer, file.type)
// TENANT_ADMIN: icons get tenantId. SERVER_ADMIN: global icons (tenantId=null)
const tenantId = user.role === 'SERVER_ADMIN' ? null : user.tenantId || null
// Create database entry
const icon = await (prisma as any).iconAsset.create({
data: {
name: name.trim(),
fileKey,
mimeType: file.type,
categoryId,
iconType: iconType as any,
isSystem: false,
isActive: true,
tenantId,
ownerId: user.id,
},
include: {
category: true,
},
})
return NextResponse.json({ icon }, { status: 201 })
} catch (error) {
console.error('Error uploading icon:', error)
return NextResponse.json({ error: 'Upload fehlgeschlagen' }, { status: 500 })
}
}

View File

@@ -0,0 +1,156 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isServerAdmin } from '@/lib/auth'
import { getSmtpConfig, saveSmtpConfig, testSmtpConnection, sendEmail } from '@/lib/email'
import { getStripeConfig, saveStripeConfig } from '@/lib/stripe'
// GET SMTP settings (mask password)
export async function GET() {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const smtp = await getSmtpConfig()
// Load contact email
let contactEmail = 'app@lageplan.ch'
try {
const setting = await (prisma as any).systemSetting.findUnique({ where: { key: 'contact_email' } })
if (setting) contactEmail = setting.value
} catch {}
// Load Stripe settings
const stripeConfig = await getStripeConfig()
// Load demo project ID
let demoProjectId = ''
try {
const demoSetting = await (prisma as any).systemSetting.findUnique({ where: { key: 'demo_project_id' } })
if (demoSetting) demoProjectId = demoSetting.value
} catch {}
// Load registration notification email
let notifyRegistrationEmail = ''
try {
const nrSetting = await (prisma as any).systemSetting.findUnique({ where: { key: 'notify_registration_email' } })
if (nrSetting) notifyRegistrationEmail = nrSetting.value
} catch {}
// Load default symbol scale
let defaultSymbolScale = '1.5'
try {
const scaleSetting = await (prisma as any).systemSetting.findUnique({ where: { key: 'default_symbol_scale' } })
if (scaleSetting) defaultSymbolScale = scaleSetting.value
} catch {}
return NextResponse.json({
smtp: smtp ? { ...smtp, pass: smtp.pass ? '••••••••' : '' } : null,
contactEmail,
notifyRegistrationEmail,
demoProjectId,
defaultSymbolScale,
stripe: stripeConfig ? {
publicKey: stripeConfig.publicKey || '',
secretKey: stripeConfig.secretKey ? '••••••••' : '',
webhookSecret: stripeConfig.webhookSecret ? '••••••••' : '',
} : null,
})
} catch (error) {
console.error('Error fetching settings:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
// Save SMTP settings
export async function PUT(req: NextRequest) {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const body = await req.json()
const { action, smtp, testEmail } = body
if (action === 'save_smtp') {
// Don't overwrite password if masked
const config: any = { ...smtp }
if (config.pass === '••••••••') {
delete config.pass
}
await saveSmtpConfig(config)
return NextResponse.json({ success: true, message: 'SMTP-Einstellungen gespeichert' })
}
if (action === 'test_smtp') {
const result = await testSmtpConnection()
return NextResponse.json(result)
}
if (action === 'send_test_email') {
if (!testEmail) {
return NextResponse.json({ error: 'E-Mail-Adresse erforderlich' }, { status: 400 })
}
try {
await sendEmail(
testEmail,
'Lageplan - Test E-Mail',
`<h2>Test E-Mail</h2><p>Diese E-Mail wurde von der Lageplan-Applikation gesendet.</p><p>SMTP-Konfiguration funktioniert korrekt.</p><p><small>Gesendet am ${new Date().toLocaleString('de-CH')}</small></p>`
)
return NextResponse.json({ success: true, message: `Test-E-Mail an ${testEmail} gesendet` })
} catch (error) {
return NextResponse.json({ success: false, error: error instanceof Error ? error.message : 'Senden fehlgeschlagen' })
}
}
if (action === 'save_stripe') {
const { stripe: stripeData } = body
if (!stripeData) return NextResponse.json({ error: 'Stripe-Daten fehlen' }, { status: 400 })
await saveStripeConfig({
secretKey: stripeData.secretKey,
publicKey: stripeData.publicKey,
webhookSecret: stripeData.webhookSecret,
})
return NextResponse.json({ success: true, message: 'Stripe-Einstellungen gespeichert' })
}
if (action === 'save_demo_project') {
const { demoProjectId } = body
await (prisma as any).systemSetting.upsert({
where: { key: 'demo_project_id' },
update: { value: demoProjectId || '' },
create: { key: 'demo_project_id', value: demoProjectId || '', isSecret: false, category: 'general' },
})
return NextResponse.json({ success: true, message: 'Demo-Projekt gespeichert' })
}
if (action === 'save_contact_email') {
const { contactEmail } = body
if (!contactEmail) return NextResponse.json({ error: 'E-Mail erforderlich' }, { status: 400 })
await (prisma as any).systemSetting.upsert({
where: { key: 'contact_email' },
update: { value: contactEmail },
create: { key: 'contact_email', value: contactEmail, isSecret: false, category: 'general' },
})
return NextResponse.json({ success: true })
}
if (action === 'save_setting') {
const { key, value } = body
if (!key) return NextResponse.json({ error: 'Key erforderlich' }, { status: 400 })
await (prisma as any).systemSetting.upsert({
where: { key },
update: { value: value || '' },
create: { key, value: value || '', isSecret: false, category: 'general' },
})
return NextResponse.json({ success: true })
}
return NextResponse.json({ error: 'Unbekannte Aktion' }, { status: 400 })
} catch (error) {
console.error('Error updating settings:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,77 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isServerAdmin } from '@/lib/auth'
import { uploadFile, getFileUrl, deleteFile } from '@/lib/minio'
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const formData = await req.formData()
const file = formData.get('logo') as File
if (!file) {
return NextResponse.json({ error: 'Keine Datei hochgeladen' }, { status: 400 })
}
// Validate file type
const validTypes = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp']
if (!validTypes.includes(file.type)) {
return NextResponse.json({ error: 'Ungültiges Dateiformat. Erlaubt: PNG, JPEG, SVG, WebP' }, { status: 400 })
}
// Max 2MB
if (file.size > 2 * 1024 * 1024) {
return NextResponse.json({ error: 'Datei zu gross (max. 2 MB)' }, { status: 400 })
}
const buffer = Buffer.from(await file.arrayBuffer())
const ext = file.name.split('.').pop() || 'png'
const fileKey = `logos/tenant-${params.id}.${ext}`
await uploadFile(fileKey, buffer, file.type)
// Store fileKey for proxy-based serving, logoUrl for backward compat
const logoServeUrl = `/api/admin/tenants/${params.id}/logo/serve`
await (prisma as any).tenant.update({
where: { id: params.id },
data: { logoFileKey: fileKey, logoUrl: logoServeUrl },
})
return NextResponse.json({ logoUrl: logoServeUrl })
} catch (error) {
console.error('Logo upload error:', error)
return NextResponse.json({ error: 'Upload fehlgeschlagen' }, { status: 500 })
}
}
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
// Get current logo
const tenant = await (prisma as any).tenant.findUnique({ where: { id: params.id } })
if (tenant?.logoUrl) {
try {
const urlParts = tenant.logoUrl.split('/')
const fileKey = `logos/${urlParts[urlParts.length - 1]}`
await deleteFile(fileKey)
} catch {}
}
await (prisma as any).tenant.update({
where: { id: params.id },
data: { logoUrl: null },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Logo delete error:', error)
return NextResponse.json({ error: 'Löschen fehlgeschlagen' }, { status: 500 })
}
}

View File

@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getFileStream } from '@/lib/minio'
// Serve tenant logo via proxy (no direct MinIO URL needed)
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
try {
const tenant = await (prisma as any).tenant.findUnique({
where: { id: params.id },
select: { logoFileKey: true, logoUrl: true },
})
// Try logoFileKey first, fallback to extracting from logoUrl
let fileKey = tenant?.logoFileKey
if (!fileKey && tenant?.logoUrl) {
const match = tenant.logoUrl.match(/logos\/[^?]+/)
if (match) fileKey = match[0]
}
if (!fileKey) {
return NextResponse.json({ error: 'Kein Logo vorhanden' }, { status: 404 })
}
const { stream, contentType } = await getFileStream(fileKey)
// Collect stream into buffer
const chunks: Buffer[] = []
for await (const chunk of stream as AsyncIterable<Buffer>) {
chunks.push(chunk)
}
const buffer = Buffer.concat(chunks)
return new NextResponse(buffer, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=3600',
},
})
} catch (error) {
console.error('Error serving logo:', error)
return NextResponse.json({ error: 'Logo konnte nicht geladen werden' }, { status: 500 })
}
}

View File

@@ -0,0 +1,178 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isServerAdmin } from '@/lib/auth'
import bcrypt from 'bcryptjs'
import crypto from 'crypto'
import { sendEmail, getSmtpConfig } from '@/lib/email'
function generateTempPassword(): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789'
let pw = ''
for (let i = 0; i < 10; i++) pw += chars[crypto.randomInt(chars.length)]
return pw
}
// Create a NEW user and add as member to tenant
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const { name, email, role } = await req.json()
if (!name || !email) {
return NextResponse.json({ error: 'Name und E-Mail sind erforderlich' }, { status: 400 })
}
// Check tenant user limit
const tenant = await (prisma as any).tenant.findUnique({
where: { id: params.id },
include: { _count: { select: { memberships: true } } },
})
if (!tenant) {
return NextResponse.json({ error: 'Mandant nicht gefunden' }, { status: 404 })
}
// User limit removed — no restriction on number of users
// Check if user with this email already exists
const existingUser = await (prisma as any).user.findUnique({ where: { email: email.toLowerCase() } })
if (existingUser) {
// Check if already member of this tenant
const existingMembership = await (prisma as any).tenantMembership.findUnique({
where: { userId_tenantId: { userId: existingUser.id, tenantId: params.id } },
})
if (existingMembership) {
return NextResponse.json({ error: 'Ein Benutzer mit dieser E-Mail ist bereits Mitglied dieses Mandanten.' }, { status: 400 })
}
return NextResponse.json({ error: 'Ein Benutzer mit dieser E-Mail existiert bereits im System. Bitte verwenden Sie eine andere E-Mail-Adresse.' }, { status: 400 })
}
// Generate temp password
const tempPassword = generateTempPassword()
const hashedPassword = await bcrypt.hash(tempPassword, 12)
// Create user + membership in transaction
const result = await (prisma as any).$transaction(async (tx: any) => {
const newUser = await tx.user.create({
data: {
email: email.toLowerCase(),
name,
password: hashedPassword,
role: role || 'OPERATOR',
},
})
const membership = await tx.tenantMembership.create({
data: {
userId: newUser.id,
tenantId: params.id,
role: role || 'OPERATOR',
},
include: { user: { select: { id: true, email: true, name: true, role: true } } },
})
return { user: newUser, membership }
})
// Send welcome email (best effort)
let emailSent = false
try {
const smtpConfig = await getSmtpConfig()
if (smtpConfig) {
const appUrl = process.env.NEXT_PUBLIC_APP_URL || process.env.NEXTAUTH_URL || 'https://app.lageplan.ch'
const html = `
<div style="font-family:sans-serif;max-width:600px;margin:0 auto;">
<div style="background:#dc2626;color:white;padding:20px 24px;border-radius:12px 12px 0 0;">
<h1 style="margin:0;font-size:22px;">Willkommen bei Lageplan</h1>
</div>
<div style="border:1px solid #e5e7eb;border-top:none;padding:24px;border-radius:0 0 12px 12px;">
<p>Hallo <strong>${name}</strong>,</p>
<p>Sie wurden als Benutzer für <strong>${tenant.name}</strong> eingerichtet.</p>
<div style="background:#f3f4f6;border-radius:8px;padding:16px;margin:20px 0;">
<p style="margin:0 0 8px;font-size:14px;color:#6b7280;">Ihre Zugangsdaten:</p>
<table style="width:100%;">
<tr><td style="padding:4px 0;font-weight:bold;width:100px;">E-Mail:</td><td>${email.toLowerCase()}</td></tr>
<tr><td style="padding:4px 0;font-weight:bold;">Passwort:</td><td style="font-family:monospace;font-size:16px;letter-spacing:1px;">${tempPassword}</td></tr>
</table>
</div>
<p style="color:#dc2626;font-weight:bold;font-size:14px;">Bitte ändern Sie Ihr Passwort nach der ersten Anmeldung.</p>
<a href="${appUrl}/login" style="display:inline-block;background:#dc2626;color:white;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:bold;margin-top:12px;">
Jetzt anmelden
</a>
<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;" />
<p style="font-size:12px;color:#9ca3af;">Diese E-Mail wurde automatisch von Lageplan gesendet. Gehostet in der Schweiz.</p>
</div>
</div>
`
await sendEmail(email.toLowerCase(), `Willkommen bei Lageplan — Ihre Zugangsdaten für ${tenant.name}`, html)
emailSent = true
}
} catch (e) {
console.error('Welcome email failed:', e)
}
return NextResponse.json({
membership: result.membership,
tempPassword,
emailSent,
}, { status: 201 })
} catch (error) {
console.error('Error creating member:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
// Remove a user from a tenant (and delete the user)
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const { searchParams } = new URL(req.url)
const membershipId = searchParams.get('membershipId')
const deleteUser = searchParams.get('deleteUser') === 'true'
if (!membershipId) {
return NextResponse.json({ error: 'membershipId erforderlich' }, { status: 400 })
}
// Get the membership to find the userId
const membership = await (prisma as any).tenantMembership.findUnique({
where: { id: membershipId },
})
// Delete membership
await (prisma as any).tenantMembership.delete({ where: { id: membershipId } })
// Optionally delete the user too (if they have no other memberships)
if (deleteUser && membership) {
const otherMemberships = await (prisma as any).tenantMembership.count({
where: { userId: membership.userId },
})
if (otherMemberships === 0) {
// Reassign projects to tenant admin before deleting
const adminMembership = await (prisma as any).tenantMembership.findFirst({
where: {
tenantId: params.id,
userId: { not: membership.userId },
role: 'TENANT_ADMIN',
},
})
const newOwnerId = adminMembership?.userId || user.id
await (prisma as any).project.updateMany({
where: { ownerId: membership.userId },
data: { ownerId: newOwnerId },
})
await (prisma as any).user.delete({ where: { id: membership.userId } })
}
}
return NextResponse.json({ success: true })
} catch (error: any) {
console.error('Error removing member:', error)
const msg = error?.code === 'P2003'
? 'Benutzer kann nicht entfernt werden — es gibt noch abhängige Daten.'
: 'Interner Fehler'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@@ -0,0 +1,88 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isServerAdmin } from '@/lib/auth'
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const tenant = await (prisma as any).tenant.findUnique({
where: { id: params.id },
include: {
memberships: {
include: { user: { select: { id: true, email: true, name: true, role: true, createdAt: true, lastLoginAt: true } } },
orderBy: { createdAt: 'asc' },
},
_count: { select: { projects: true, memberships: true } },
},
})
if (!tenant) {
return NextResponse.json({ error: 'Mandant nicht gefunden' }, { status: 404 })
}
return NextResponse.json({ tenant })
} catch (error) {
console.error('Error fetching tenant:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const body = await req.json()
const {
name, description, isActive, contactEmail, contactPhone, address,
plan, subscriptionStatus, trialEndsAt, subscriptionEndsAt,
maxUsers, maxProjects, notes, logoUrl,
} = body
const tenant = await (prisma as any).tenant.update({
where: { id: params.id },
data: {
...(name !== undefined && { name }),
...(description !== undefined && { description }),
...(isActive !== undefined && { isActive }),
...(contactEmail !== undefined && { contactEmail }),
...(contactPhone !== undefined && { contactPhone }),
...(address !== undefined && { address }),
...(logoUrl !== undefined && { logoUrl }),
...(plan !== undefined && { plan }),
...(subscriptionStatus !== undefined && { subscriptionStatus }),
...(trialEndsAt !== undefined && { trialEndsAt: trialEndsAt ? new Date(trialEndsAt) : null }),
...(subscriptionEndsAt !== undefined && { subscriptionEndsAt: subscriptionEndsAt ? new Date(subscriptionEndsAt) : null }),
...(maxUsers !== undefined && { maxUsers }),
...(maxProjects !== undefined && { maxProjects }),
...(notes !== undefined && { notes }),
},
})
return NextResponse.json({ tenant })
} catch (error) {
console.error('Error updating tenant:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
await (prisma as any).tenant.delete({ where: { id: params.id } })
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error deleting tenant:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isServerAdmin } from '@/lib/auth'
import { z } from 'zod'
const createTenantSchema = z.object({
name: z.string().min(1).max(200),
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
description: z.string().optional(),
})
export async function GET() {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const tenants = await (prisma as any).tenant.findMany({
orderBy: { createdAt: 'desc' },
include: {
_count: {
select: { memberships: true, projects: true },
},
},
})
return NextResponse.json({ tenants })
} catch (error) {
console.error('Error fetching tenants:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
export async function POST(req: NextRequest) {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const body = await req.json()
const data = createTenantSchema.parse(body)
const existing = await (prisma as any).tenant.findUnique({ where: { slug: data.slug } })
if (existing) {
return NextResponse.json({ error: 'Slug bereits vergeben' }, { status: 400 })
}
const tenant = await (prisma as any).tenant.create({
data: {
name: data.name,
slug: data.slug,
description: data.description,
},
})
return NextResponse.json({ tenant }, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Ungültige Daten' }, { status: 400 })
}
console.error('Error creating tenant:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,205 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isServerAdmin } from '@/lib/auth'
import { sendEmail, getSmtpConfig } from '@/lib/email'
// POST: Check all tenants in TRIAL status and send reminder emails
// Can be called manually by SERVER_ADMIN or via cron
export async function POST(req: NextRequest) {
try {
// Auth: only SERVER_ADMIN or internal cron (via secret header)
const cronSecret = req.headers.get('x-cron-secret')
const expectedSecret = process.env.CRON_SECRET
const isCron = expectedSecret && cronSecret === expectedSecret
if (!isCron) {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
}
const smtpConfig = await getSmtpConfig()
if (!smtpConfig) {
return NextResponse.json({ error: 'SMTP nicht konfiguriert', sent: 0 }, { status: 200 })
}
const now = new Date()
// Find all tenants in TRIAL status with trialEndsAt set
const trialTenants = await (prisma as any).tenant.findMany({
where: {
subscriptionStatus: 'TRIAL',
trialEndsAt: { not: null },
isActive: true,
},
include: {
memberships: {
where: { role: 'TENANT_ADMIN' },
include: { user: { select: { email: true, name: true } } },
},
},
})
const results: { tenant: string; daysLeft: number; emailsSent: number; action: string }[] = []
for (const tenant of trialTenants) {
const trialEnd = new Date(tenant.trialEndsAt)
const diffMs = trialEnd.getTime() - now.getTime()
const daysLeft = Math.ceil(diffMs / (1000 * 60 * 60 * 24))
// Determine which reminder to send (only specific days to avoid spam)
// Send at: 14 days, 7 days, 3 days, 1 day before, and on expiry day
const reminderDays = [14, 7, 3, 1, 0]
if (!reminderDays.includes(daysLeft)) {
results.push({ tenant: tenant.name, daysLeft, emailsSent: 0, action: 'skipped' })
continue
}
// Check if we already sent a reminder for this day (using SystemSetting as log)
const reminderKey = `trial_reminder_${tenant.id}_${daysLeft}`
const existing = await (prisma as any).systemSetting.findUnique({
where: { key: reminderKey },
})
if (existing) {
results.push({ tenant: tenant.name, daysLeft, emailsSent: 0, action: 'already_sent' })
continue
}
// Get admin emails for this tenant
const adminEmails = tenant.memberships
.filter((m: any) => m.user?.email)
.map((m: any) => ({ email: m.user.email, name: m.user.name }))
if (adminEmails.length === 0) {
results.push({ tenant: tenant.name, daysLeft, emailsSent: 0, action: 'no_admins' })
continue
}
// Build email content based on days left
let subject: string
let heading: string
let bodyText: string
let urgencyColor: string
if (daysLeft <= 0) {
subject = `Ihre Testphase bei Lageplan ist abgelaufen`
heading = 'Testphase abgelaufen'
bodyText = `Ihre 45-tägige Testphase für <strong>${tenant.name}</strong> ist heute abgelaufen. Ihr Konto wurde automatisch auf den <strong>Free-Plan</strong> umgestellt. Einige Funktionen sind nun eingeschränkt.`
urgencyColor = '#dc2626'
} else if (daysLeft === 1) {
subject = `Lageplan: Ihre Testphase endet morgen`
heading = 'Noch 1 Tag'
bodyText = `Ihre Testphase für <strong>${tenant.name}</strong> endet <strong>morgen</strong>. Danach wechselt Ihr Konto automatisch zum Free-Plan.`
urgencyColor = '#ea580c'
} else if (daysLeft <= 3) {
subject = `Lageplan: Noch ${daysLeft} Tage in Ihrer Testphase`
heading = `Noch ${daysLeft} Tage`
bodyText = `Ihre Testphase für <strong>${tenant.name}</strong> endet in <strong>${daysLeft} Tagen</strong>. Upgraden Sie rechtzeitig, um alle Funktionen zu behalten.`
urgencyColor = '#ea580c'
} else if (daysLeft <= 7) {
subject = `Lageplan: Noch ${daysLeft} Tage in Ihrer Testphase`
heading = `Noch ${daysLeft} Tage`
bodyText = `Ihre Testphase für <strong>${tenant.name}</strong> endet in ${daysLeft} Tagen. Nutzen Sie die verbleibende Zeit, um alle Funktionen auszuprobieren.`
urgencyColor = '#ca8a04'
} else {
subject = `Lageplan: Noch ${daysLeft} Tage in Ihrer Testphase`
heading = `Noch ${daysLeft} Tage`
bodyText = `Ihre Testphase für <strong>${tenant.name}</strong> läuft noch ${daysLeft} Tage. Viel Spass beim Testen!`
urgencyColor = '#2563eb'
}
const trialEndStr = trialEnd.toLocaleDateString('de-CH', {
day: '2-digit', month: '2-digit', year: 'numeric',
})
const html = `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 520px; margin: 0 auto;">
<div style="background: ${urgencyColor}; color: white; padding: 16px 24px; border-radius: 8px 8px 0 0;">
<h2 style="margin: 0; font-size: 18px;">${heading}</h2>
</div>
<div style="border: 1px solid #e5e7eb; border-top: none; padding: 24px; border-radius: 0 0 8px 8px;">
<p style="margin: 0 0 16px; line-height: 1.6; color: #374151;">${bodyText}</p>
<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
<tr>
<td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Organisation</td>
<td style="padding: 8px 0; font-weight: 600; text-align: right;">${tenant.name}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Ablaufdatum</td>
<td style="padding: 8px 0; font-weight: 600; text-align: right;">${trialEndStr}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Aktueller Plan</td>
<td style="padding: 8px 0; font-weight: 600; text-align: right;">Testversion (Trial)</td>
</tr>
</table>
${daysLeft > 0 ? `
<p style="margin: 16px 0 0; font-size: 13px; color: #6b7280;">
Sie müssen nichts tun — nach Ablauf der Testphase wechselt Ihr Konto automatisch zum kostenlosen Free-Plan.
Für erweiterte Funktionen können Sie jederzeit upgraden.
</p>
` : `
<p style="margin: 16px 0 0; font-size: 13px; color: #6b7280;">
Ihre Daten bleiben erhalten. Sie können jederzeit auf einen kostenpflichtigen Plan upgraden, um alle Funktionen wieder freizuschalten.
</p>
`}
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 20px 0;" />
<p style="margin: 0; font-size: 12px; color: #9ca3af;">
Lageplan — Digitale Lagepläne für die Feuerwehr<br/>
Diese E-Mail wurde automatisch gesendet.
</p>
</div>
</div>
`
let emailsSent = 0
for (const admin of adminEmails) {
try {
await sendEmail(admin.email, subject, html)
emailsSent++
} catch (e) {
console.error(`Failed to send trial reminder to ${admin.email}:`, e)
}
}
// Log that we sent this reminder (prevent duplicates)
await (prisma as any).systemSetting.create({
data: {
key: reminderKey,
value: JSON.stringify({
sentAt: now.toISOString(),
daysLeft,
emailsSent,
recipients: adminEmails.map((a: any) => a.email),
}),
isSecret: false,
category: 'trial_reminders',
},
})
results.push({ tenant: tenant.name, daysLeft, emailsSent, action: 'sent' })
// If trial has expired, downgrade to FREE
if (daysLeft <= 0) {
await (prisma as any).tenant.update({
where: { id: tenant.id },
data: {
subscriptionStatus: 'EXPIRED',
plan: 'FREE',
},
})
results.push({ tenant: tenant.name, daysLeft, emailsSent: 0, action: 'downgraded_to_free' })
}
}
return NextResponse.json({
success: true,
processed: trialTenants.length,
results,
})
} catch (error) {
console.error('Trial reminder error:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isAdmin } from '@/lib/auth'
import { randomBytes } from 'crypto'
import { sendEmail, getSmtpConfig } from '@/lib/email'
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getSession()
if (!session || !isAdmin(session.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const target = await (prisma as any).user.findUnique({
where: { id: params.id },
include: { memberships: true },
})
if (!target) {
return NextResponse.json({ error: 'Benutzer nicht gefunden' }, { status: 404 })
}
// TENANT_ADMIN: only reset users in same tenant
if (session.role !== 'SERVER_ADMIN') {
const inSameTenant = target.memberships?.some((m: any) => m.tenantId === session.tenantId)
if (!inSameTenant) {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
}
// Generate reset token
const resetToken = randomBytes(32).toString('hex')
const resetTokenExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours for admin-initiated
await (prisma as any).user.update({
where: { id: params.id },
data: { resetToken, resetTokenExpiry },
})
const host = req.headers.get('host') || 'localhost:3000'
const protocol = host.includes('localhost') ? 'http' : 'https'
const resetUrl = `${protocol}://${host}/reset-password?token=${resetToken}`
// Try to send email
const smtpConfig = await getSmtpConfig()
let emailSent = false
if (smtpConfig) {
try {
await sendEmail(
target.email,
'Passwort zurücksetzen Lageplan',
`
<div style="font-family: sans-serif; max-width: 500px; margin: 0 auto;">
<h2 style="color: #dc2626;">Passwort zurücksetzen</h2>
<p>Hallo ${target.name},</p>
<p>Ein Administrator hat eine Passwort-Zurücksetzung für Ihr Konto angefordert.</p>
<p style="margin: 24px 0;">
<a href="${resetUrl}" style="background: #dc2626; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: bold;">
Neues Passwort setzen
</a>
</p>
<p style="color: #666; font-size: 14px;">Dieser Link ist 24 Stunden gültig.</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 24px 0;" />
<p style="color: #999; font-size: 12px;">Lageplan Feuerwehr Krokier-App</p>
</div>
`
)
emailSent = true
} catch (emailErr) {
console.error('Failed to send reset email:', emailErr)
}
}
return NextResponse.json({
success: true,
emailSent,
resetUrl: emailSent ? undefined : resetUrl,
message: emailSent
? `Reset-Link wurde an ${target.email} gesendet.`
: 'SMTP nicht konfiguriert. Reset-Link wurde generiert.',
})
} catch (error) {
console.error('Admin reset password error:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,135 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, hashPassword, isAdmin } from '@/lib/auth'
import { z } from 'zod'
const updateUserSchema = z.object({
email: z.string().email().optional(),
name: z.string().min(1).max(100).optional(),
password: z.string().min(6).optional(),
role: z.enum(['SERVER_ADMIN', 'TENANT_ADMIN', 'OPERATOR', 'VIEWER']).optional(),
emailVerified: z.boolean().optional(),
})
export async function PATCH(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getSession()
if (!session || !isAdmin(session.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
// TENANT_ADMIN: can only edit users in their own tenant
if (session.role !== 'SERVER_ADMIN') {
const target = await (prisma as any).user.findUnique({
where: { id: params.id },
include: { memberships: true },
})
if (!target) return NextResponse.json({ error: 'Benutzer nicht gefunden' }, { status: 404 })
const inSameTenant = target.memberships?.some((m: any) => m.tenantId === session.tenantId)
if (!inSameTenant) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const body = await req.json()
const data = updateUserSchema.parse(body)
// TENANT_ADMIN cannot promote to SERVER_ADMIN
if (data.role === 'SERVER_ADMIN' && session.role !== 'SERVER_ADMIN') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const updateData: any = {}
if (data.email) updateData.email = data.email
if (data.name) updateData.name = data.name
if (data.role) updateData.role = data.role
if (data.password) updateData.password = await hashPassword(data.password)
if (data.emailVerified !== undefined) updateData.emailVerified = data.emailVerified
const user = await (prisma as any).user.update({
where: { id: params.id },
data: updateData,
select: {
id: true,
email: true,
name: true,
role: true,
emailVerified: true,
createdAt: true,
updatedAt: true,
},
})
return NextResponse.json({ user })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Ungültige Daten' }, { status: 400 })
}
console.error('Error updating user:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
export async function DELETE(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getSession()
if (!session || !isAdmin(session.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const target = await (prisma as any).user.findUnique({
where: { id: params.id },
include: { memberships: true },
})
if (!target) {
return NextResponse.json({ error: 'Benutzer nicht gefunden' }, { status: 404 })
}
// TENANT_ADMIN: can only delete users in their own tenant
if (session.role !== 'SERVER_ADMIN') {
const inSameTenant = target.memberships?.some((m: any) => m.tenantId === session.tenantId)
if (!inSameTenant) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
// Prevent deleting yourself
if (session.id === params.id) {
return NextResponse.json({ error: 'Eigenen Account kann nicht gelöscht werden' }, { status: 400 })
}
// Reassign projects to tenant admin before deleting
for (const membership of (target.memberships || [])) {
// Find a TENANT_ADMIN in the same tenant (not the user being deleted)
const adminMembership = await (prisma as any).tenantMembership.findFirst({
where: {
tenantId: membership.tenantId,
userId: { not: params.id },
role: 'TENANT_ADMIN',
},
})
const newOwnerId = adminMembership?.userId || session.id
// Reassign all projects owned by this user in this tenant
await (prisma as any).project.updateMany({
where: { ownerId: params.id, tenantId: membership.tenantId },
data: { ownerId: newOwnerId },
})
}
// Also reassign any projects without a tenant
await (prisma as any).project.updateMany({
where: { ownerId: params.id },
data: { ownerId: session.id },
})
await (prisma as any).user.delete({ where: { id: params.id } })
return NextResponse.json({ success: true })
} catch (error: any) {
console.error('Error deleting user:', error)
const msg = error?.code === 'P2003'
? 'Benutzer kann nicht gelöscht werden — es gibt noch abhängige Daten.'
: 'Interner Fehler beim Löschen des Benutzers'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@@ -0,0 +1,101 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, hashPassword, isAdmin } from '@/lib/auth'
import { z } from 'zod'
const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
password: z.string().min(6),
role: z.enum(['SERVER_ADMIN', 'TENANT_ADMIN', 'OPERATOR', 'VIEWER']),
tenantId: z.string().optional(),
})
export async function GET() {
try {
const user = await getSession()
if (!user || !isAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const where = user.role === 'SERVER_ADMIN' ? {} : {
memberships: { some: { tenantId: user.tenantId } },
}
const users = await (prisma as any).user.findMany({
where,
orderBy: { createdAt: 'desc' },
select: {
id: true,
email: true,
name: true,
role: true,
emailVerified: true,
createdAt: true,
updatedAt: true,
memberships: {
include: { tenant: { select: { id: true, name: true, slug: true } } },
},
_count: { select: { projects: true } },
},
})
return NextResponse.json({ users })
} catch (error) {
console.error('Error fetching users:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
export async function POST(req: NextRequest) {
try {
const session = await getSession()
if (!session || !isAdmin(session.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const body = await req.json()
const data = createUserSchema.parse(body)
// Only SERVER_ADMIN can create SERVER_ADMIN users
if (data.role === 'SERVER_ADMIN' && session.role !== 'SERVER_ADMIN') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const existing = await (prisma as any).user.findUnique({ where: { email: data.email } })
if (existing) {
return NextResponse.json({ error: 'E-Mail bereits vergeben' }, { status: 400 })
}
const hashedPassword = await hashPassword(data.password)
const tenantId = data.tenantId || session.tenantId
const user = await (prisma as any).user.create({
data: {
email: data.email,
name: data.name,
password: hashedPassword,
role: data.role as any,
...(tenantId && data.role !== 'SERVER_ADMIN' ? {
memberships: {
create: { tenantId, role: data.role as any },
},
} : {}),
},
select: {
id: true,
email: true,
name: true,
role: true,
createdAt: true,
},
})
return NextResponse.json({ user }, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Ungültige Daten' }, { status: 400 })
}
console.error('Error creating user:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { prisma } from '@/lib/db'
import bcrypt from 'bcryptjs'
export async function POST(req: NextRequest) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const { currentPassword, newPassword } = await req.json()
if (!currentPassword || !newPassword) {
return NextResponse.json({ error: 'Beide Felder sind erforderlich' }, { status: 400 })
}
if (newPassword.length < 6) {
return NextResponse.json({ error: 'Neues Kennwort muss mindestens 6 Zeichen lang sein' }, { status: 400 })
}
const dbUser = await (prisma as any).user.findUnique({
where: { id: user.id },
select: { password: true },
})
if (!dbUser) {
return NextResponse.json({ error: 'Benutzer nicht gefunden' }, { status: 404 })
}
const isValid = await bcrypt.compare(currentPassword, dbUser.password)
if (!isValid) {
return NextResponse.json({ error: 'Aktuelles Kennwort ist falsch' }, { status: 400 })
}
const hashedPassword = await bcrypt.hash(newPassword, 12)
await (prisma as any).user.update({
where: { id: user.id },
data: { password: hashedPassword },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Change password error:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { randomBytes } from 'crypto'
import { sendEmail, getSmtpConfig } from '@/lib/email'
export async function POST(req: NextRequest) {
try {
const { email } = await req.json()
if (!email) {
return NextResponse.json({ error: 'E-Mail erforderlich' }, { status: 400 })
}
const user = await (prisma as any).user.findUnique({ where: { email } })
// Always return success to prevent email enumeration
if (!user) {
return NextResponse.json({ success: true, message: 'Falls ein Konto mit dieser E-Mail existiert, wurde ein Link gesendet.' })
}
// Generate reset token (32 bytes hex = 64 chars)
const resetToken = randomBytes(32).toString('hex')
const resetTokenExpiry = new Date(Date.now() + 60 * 60 * 1000) // 1 hour
await (prisma as any).user.update({
where: { id: user.id },
data: { resetToken, resetTokenExpiry },
})
// Try to send email
const smtpConfig = await getSmtpConfig()
const host = req.headers.get('host') || 'localhost:3000'
const protocol = host.includes('localhost') ? 'http' : 'https'
const resetUrl = `${protocol}://${host}/reset-password?token=${resetToken}`
if (smtpConfig) {
try {
await sendEmail(
user.email,
'Passwort zurücksetzen Lageplan',
`
<div style="font-family: sans-serif; max-width: 500px; margin: 0 auto;">
<h2 style="color: #dc2626;">Passwort zurücksetzen</h2>
<p>Hallo ${user.name},</p>
<p>Sie haben eine Passwort-Zurücksetzung angefordert. Klicken Sie auf den folgenden Link:</p>
<p style="margin: 24px 0;">
<a href="${resetUrl}" style="background: #dc2626; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: bold;">
Passwort zurücksetzen
</a>
</p>
<p style="color: #666; font-size: 14px;">Dieser Link ist 1 Stunde gültig.</p>
<p style="color: #666; font-size: 14px;">Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail.</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 24px 0;" />
<p style="color: #999; font-size: 12px;">Lageplan Feuerwehr Krokier-App</p>
</div>
`
)
return NextResponse.json({ success: true, message: 'Falls ein Konto mit dieser E-Mail existiert, wurde ein Link gesendet.' })
} catch (emailErr) {
console.error('Failed to send reset email:', emailErr)
// Fall through to show token directly
}
}
// No SMTP configured or email failed → log token server-side only, never expose to client
console.log(`[Password Reset] No SMTP configured. Reset URL: ${resetUrl}`)
return NextResponse.json({
success: true,
message: 'Falls ein Konto mit dieser E-Mail existiert, wurde ein Link gesendet. (SMTP nicht konfiguriert — siehe Server-Logs)',
})
} catch (error) {
console.error('Forgot password error:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { login, createToken } from '@/lib/auth'
import { loginSchema } from '@/lib/validations'
import { prisma } from '@/lib/db'
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const validated = loginSchema.safeParse(body)
if (!validated.success) {
return NextResponse.json(
{ error: 'Ungültige Eingabedaten' },
{ status: 400 }
)
}
const { email, password } = validated.data
const result = await login(email, password)
if (!result.success || !result.user) {
return NextResponse.json(
{ error: result.error || 'Login fehlgeschlagen' },
{ status: 401 }
)
}
// Update lastLoginAt
try {
await (prisma as any).user.update({
where: { id: result.user.id },
data: { lastLoginAt: new Date() },
})
} catch {}
const token = await createToken(result.user)
;(await cookies()).set('auth-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24, // 24 hours
path: '/',
})
return NextResponse.json({ user: result.user })
} catch (error) {
console.error('Login error:', error)
return NextResponse.json(
{ error: 'Interner Serverfehler' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,7 @@
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
export async function POST() {
;(await cookies()).delete('auth-token')
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,33 @@
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { prisma } from '@/lib/db'
export async function GET() {
const user = await getSession()
if (!user) {
return NextResponse.json({ user: null, tenant: null })
}
// Enrich with tenant subscription info for non-server-admins
let tenant: any = null
if (user.tenantId) {
tenant = await (prisma as any).tenant.findUnique({
where: { id: user.tenantId },
select: {
id: true,
name: true,
slug: true,
plan: true,
subscriptionStatus: true,
trialEndsAt: true,
subscriptionEndsAt: true,
maxUsers: true,
maxProjects: true,
logoUrl: true,
},
})
}
return NextResponse.json({ user, tenant })
}

View File

@@ -0,0 +1,175 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { hashPassword } from '@/lib/auth'
import { sendEmail } from '@/lib/email'
import { randomBytes } from 'crypto'
import { z } from 'zod'
const registerSchema = z.object({
organizationName: z.string().min(2, 'Organisationsname zu kurz').max(200),
name: z.string().min(2, 'Name zu kurz').max(200),
email: z.string().email('Ungültige E-Mail-Adresse'),
password: z.string().min(6, 'Passwort muss mindestens 6 Zeichen haben'),
})
export async function POST(req: NextRequest) {
try {
const body = await req.json()
const data = registerSchema.parse(body)
// Check if email already exists
const existingUser = await (prisma as any).user.findUnique({
where: { email: data.email },
include: { memberships: true },
})
if (existingUser) {
// If the user is an orphan (no memberships) or never verified their email,
// clean them up so they can re-register
const isOrphan = !existingUser.memberships || existingUser.memberships.length === 0
const isUnverified = existingUser.emailVerified === false
if (isOrphan || isUnverified) {
// Force-delete orphan/unverified user and all their remaining data
try {
await (prisma as any).upgradeRequest.deleteMany({ where: { requestedById: existingUser.id } })
await (prisma as any).iconAsset.updateMany({ where: { ownerId: existingUser.id }, data: { ownerId: null } })
await (prisma as any).project.updateMany({ where: { ownerId: existingUser.id }, data: { ownerId: null } })
await (prisma as any).tenantMembership.deleteMany({ where: { userId: existingUser.id } })
await (prisma as any).user.delete({ where: { id: existingUser.id } })
console.log(`[Register] Cleaned up orphan/unverified user: ${data.email}`)
} catch (cleanupErr) {
console.error('[Register] Failed to cleanup existing user:', cleanupErr)
return NextResponse.json({ error: 'Diese E-Mail-Adresse ist bereits registriert. Bitte kontaktieren Sie den Administrator.' }, { status: 400 })
}
} else {
return NextResponse.json({ error: 'Diese E-Mail-Adresse ist bereits registriert.' }, { status: 400 })
}
}
// Generate slug from organization name
let slug = data.organizationName
.toLowerCase()
.replace(/[äÄ]/g, 'ae').replace(/[öÖ]/g, 'oe').replace(/[üÜ]/g, 'ue')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
// Ensure slug is unique
const existingTenant = await (prisma as any).tenant.findUnique({ where: { slug } })
if (existingTenant) {
slug = `${slug}-${Date.now().toString(36)}`
}
// Hash password
const hashedPassword = await hashPassword(data.password)
// Generate email verification token
const verificationToken = randomBytes(32).toString('hex')
// Create tenant (no trial, directly ACTIVE) with privacy consent
const tenant = await (prisma as any).tenant.create({
data: {
name: data.organizationName,
slug,
plan: 'FREE',
subscriptionStatus: 'ACTIVE',
maxUsers: 5,
maxProjects: 10,
contactEmail: data.email,
privacyAccepted: body.privacyAccepted === true,
privacyAcceptedAt: body.privacyAccepted ? new Date() : null,
adminAccessAccepted: body.adminAccessAccepted === true,
},
})
// Create user as TENANT_ADMIN with email not yet verified
const user = await (prisma as any).user.create({
data: {
email: data.email,
password: hashedPassword,
name: data.name,
role: 'TENANT_ADMIN',
emailVerified: false,
emailVerificationToken: verificationToken,
},
})
// Create tenant membership
await (prisma as any).tenantMembership.create({
data: {
userId: user.id,
tenantId: tenant.id,
role: 'TENANT_ADMIN',
},
})
// Send verification email
let baseUrl = process.env.NEXTAUTH_URL || req.headers.get('origin') || `${req.headers.get('x-forwarded-proto') || 'https'}://${req.headers.get('host')}` || 'http://localhost:3000'
if (baseUrl && !baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
baseUrl = `https://${baseUrl}`
}
const verifyUrl = `${baseUrl}/api/auth/verify-email?token=${verificationToken}`
try {
await sendEmail(
data.email,
'E-Mail-Adresse bestätigen — Lageplan',
`<div style="font-family:sans-serif;max-width:600px;margin:0 auto;">
<div style="background:#dc2626;color:white;padding:20px 24px;border-radius:12px 12px 0 0;">
<h1 style="margin:0;font-size:22px;">E-Mail bestätigen</h1>
</div>
<div style="border:1px solid #e5e7eb;border-top:none;padding:24px;border-radius:0 0 12px 12px;">
<p>Hallo <strong>${data.name}</strong>,</p>
<p>Bitte bestätigen Sie Ihre E-Mail-Adresse, um Ihr Konto für <strong>${data.organizationName}</strong> zu aktivieren.</p>
<div style="text-align:center;margin:24px 0;">
<a href="${verifyUrl}" style="background:#dc2626;color:white;padding:12px 32px;text-decoration:none;border-radius:8px;font-weight:600;display:inline-block;">
E-Mail bestätigen
</a>
</div>
<p style="color:#666;font-size:13px;">Falls der Button nicht funktioniert, kopieren Sie diesen Link:<br/>
<a href="${verifyUrl}" style="word-break:break-all;">${verifyUrl}</a></p>
</div>
</div>`
)
} catch (e) {
console.warn('Failed to send verification email:', e)
}
// Notify server admin about new registration (#13)
try {
const adminSetting = await (prisma as any).systemSetting.findUnique({ where: { key: 'notify_registration_email' } })
const adminEmail = adminSetting?.value
if (adminEmail) {
await sendEmail(
adminEmail,
`Neue Registrierung — ${data.organizationName}`,
`<div style="font-family:sans-serif;max-width:600px;margin:0 auto;">
<div style="background:#1e293b;color:white;padding:20px 24px;border-radius:12px 12px 0 0;">
<h1 style="margin:0;font-size:22px;">Neue Registrierung</h1>
</div>
<div style="border:1px solid #e5e7eb;border-top:none;padding:24px;border-radius:0 0 12px 12px;">
<p><strong>Organisation:</strong> ${data.organizationName}</p>
<p><strong>Name:</strong> ${data.name}</p>
<p><strong>E-Mail:</strong> ${data.email}</p>
<p><strong>Mandant-Slug:</strong> ${slug}</p>
<p><strong>Datum:</strong> ${new Date().toLocaleString('de-CH')}</p>
</div>
</div>`
)
}
} catch (e) {
console.warn('Failed to send registration notification:', e)
}
return NextResponse.json({
success: true,
message: 'Registrierung erfolgreich! Bitte bestätigen Sie Ihre E-Mail-Adresse.',
tenantSlug: tenant.slug,
requiresVerification: true,
}, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {
const firstError = error.errors[0]
return NextResponse.json({ error: firstError.message }, { status: 400 })
}
console.error('Registration error:', error)
return NextResponse.json({ error: 'Registrierung fehlgeschlagen. Bitte versuchen Sie es später.' }, { status: 500 })
}
}

View File

@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { hashPassword } from '@/lib/auth'
export async function POST(req: NextRequest) {
try {
const { token, password } = await req.json()
if (!token || !password) {
return NextResponse.json({ error: 'Token und Passwort erforderlich' }, { status: 400 })
}
if (password.length < 6) {
return NextResponse.json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' }, { status: 400 })
}
const user = await (prisma as any).user.findFirst({
where: {
resetToken: token,
resetTokenExpiry: { gt: new Date() },
},
})
if (!user) {
return NextResponse.json({ error: 'Ungültiger oder abgelaufener Link. Bitte fordern Sie einen neuen Link an.' }, { status: 400 })
}
const hashedPassword = await hashPassword(password)
await (prisma as any).user.update({
where: { id: user.id },
data: {
password: hashedPassword,
resetToken: null,
resetTokenExpiry: null,
},
})
return NextResponse.json({ success: true, message: 'Passwort wurde erfolgreich geändert.' })
} catch (error) {
console.error('Reset password error:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
function getBaseUrl(req: NextRequest): string {
// Use NEXTAUTH_URL if set, ensure it has a protocol
if (process.env.NEXTAUTH_URL) {
const url = process.env.NEXTAUTH_URL.trim()
if (url.startsWith('http://') || url.startsWith('https://')) return url
return `https://${url}`
}
const proto = req.headers.get('x-forwarded-proto') || 'https'
const host = req.headers.get('host') || 'localhost:3000'
return `${proto}://${host}`
}
export async function GET(req: NextRequest) {
const base = getBaseUrl(req)
try {
const token = req.nextUrl.searchParams.get('token')
if (!token) {
return NextResponse.redirect(`${base}/login?error=invalid-token`)
}
// Find user by verification token
const user = await (prisma as any).user.findFirst({
where: { emailVerificationToken: token },
})
if (!user) {
return NextResponse.redirect(`${base}/login?error=invalid-token`)
}
// Mark email as verified
await (prisma as any).user.update({
where: { id: user.id },
data: {
emailVerified: true,
emailVerificationToken: null,
},
})
// Redirect to login with success message
return NextResponse.redirect(`${base}/login?verified=true`)
} catch (error) {
console.error('Email verification error:', error)
return NextResponse.redirect(`${base}/login?error=verification-failed`)
}
}

View File

@@ -0,0 +1,75 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { sendEmail, getSmtpConfig } from '@/lib/email'
import { z } from 'zod'
const contactSchema = z.object({
name: z.string().min(1).max(200),
email: z.string().email(),
message: z.string().min(1).max(5000),
})
// Get contact email from SystemSettings, fallback to app@lageplan.ch
async function getContactEmail(): Promise<string> {
try {
const setting = await (prisma as any).systemSetting.findUnique({
where: { key: 'contact_email' },
})
return setting?.value || 'app@lageplan.ch'
} catch {
return 'app@lageplan.ch'
}
}
export async function POST(req: NextRequest) {
try {
const body = await req.json()
const data = contactSchema.parse(body)
const contactEmail = await getContactEmail()
const smtpConfig = await getSmtpConfig()
if (smtpConfig) {
// Send via SMTP
const html = `
<h2>Neue Kontaktanfrage — Lageplan</h2>
<table style="border-collapse:collapse;width:100%;max-width:500px;">
<tr><td style="padding:8px;font-weight:bold;border-bottom:1px solid #eee;">Name</td><td style="padding:8px;border-bottom:1px solid #eee;">${escapeHtml(data.name)}</td></tr>
<tr><td style="padding:8px;font-weight:bold;border-bottom:1px solid #eee;">E-Mail</td><td style="padding:8px;border-bottom:1px solid #eee;"><a href="mailto:${escapeHtml(data.email)}">${escapeHtml(data.email)}</a></td></tr>
</table>
<h3 style="margin-top:20px;">Nachricht</h3>
<div style="background:#f9f9f9;padding:16px;border-radius:8px;white-space:pre-wrap;">${escapeHtml(data.message)}</div>
<p style="margin-top:20px;font-size:12px;color:#999;">Gesendet über das Kontaktformular auf lageplan.ch</p>
`
await sendEmail(contactEmail, `Kontaktanfrage von ${data.name}`, html)
} else {
// No SMTP configured — store in SystemSettings as fallback log
const logKey = `contact_msg_${Date.now()}`
await (prisma as any).systemSetting.create({
data: {
key: logKey,
value: JSON.stringify({ name: data.name, email: data.email, message: data.message, date: new Date().toISOString() }),
isSecret: false,
category: 'contact_messages',
},
})
}
return NextResponse.json({ success: true })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Bitte füllen Sie alle Felder korrekt aus.' }, { status: 400 })
}
console.error('Contact form error:', error)
return NextResponse.json({ error: 'Senden fehlgeschlagen. Bitte versuchen Sie es später.' }, { status: 500 })
}
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}

72
src/app/api/demo/route.ts Normal file
View File

@@ -0,0 +1,72 @@
export const dynamic = 'force-dynamic'
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
// GET demo project data — no auth required
export async function GET() {
try {
// Find demo project ID from system settings
const setting = await (prisma as any).systemSetting.findUnique({
where: { key: 'demo_project_id' },
})
if (!setting?.value) {
return NextResponse.json({ error: 'Keine Demo konfiguriert' }, { status: 404 })
}
const project = await (prisma as any).project.findUnique({
where: { id: setting.value },
})
if (!project) {
return NextResponse.json({ error: 'Demo-Projekt nicht gefunden' }, { status: 404 })
}
const [features, journalEntries, journalCheckItems, journalPendenzen] = await Promise.all([
(prisma as any).feature.findMany({
where: { projectId: project.id },
orderBy: { createdAt: 'asc' },
}),
(prisma as any).journalEntry.findMany({
where: { projectId: project.id },
orderBy: [{ time: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }],
}).catch(() => []),
(prisma as any).journalCheckItem.findMany({
where: { projectId: project.id },
orderBy: { sortOrder: 'asc' },
}).catch(() => []),
(prisma as any).journalPendenz.findMany({
where: { projectId: project.id },
orderBy: { sortOrder: 'asc' },
}).catch(() => []),
])
return NextResponse.json({
project: {
id: project.id,
title: project.title,
location: project.location,
description: project.description,
einsatzleiter: project.einsatzleiter || '',
journalfuehrer: project.journalfuehrer || '',
mapCenter: project.mapCenter || { lng: 8.2275, lat: 47.3497 },
mapZoom: project.mapZoom || 15,
},
features: features.map((f: any) => ({
id: f.id,
type: f.type,
geometry: f.geometry,
properties: f.properties || {},
})),
journal: {
entries: journalEntries,
checkItems: journalCheckItems,
pendenzen: journalPendenzen,
},
})
} catch (error) {
console.error('[Demo API] Error:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isAdmin } from '@/lib/auth'
// DELETE: Remove a dictionary word
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user || !isAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const entry = await (prisma as any).dictionaryEntry.findUnique({
where: { id: params.id },
})
if (!entry) {
return NextResponse.json({ error: 'Eintrag nicht gefunden' }, { status: 404 })
}
// Only SERVER_ADMIN can delete global words
if (entry.scope === 'GLOBAL' && user.role !== 'SERVER_ADMIN') {
return NextResponse.json({ error: 'Nur Server-Admin kann globale Wörter löschen' }, { status: 403 })
}
// TENANT_ADMIN can only delete their own tenant's words
if (entry.scope === 'TENANT' && entry.tenantId !== user.tenantId && user.role !== 'SERVER_ADMIN') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
await (prisma as any).dictionaryEntry.delete({ where: { id: params.id } })
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error deleting dictionary word:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isAdmin } from '@/lib/auth'
// GET: Fetch dictionary words (global + tenant-specific merged)
export async function GET(req: NextRequest) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
// Global words
const globalWords = await (prisma as any).dictionaryEntry.findMany({
where: { scope: 'GLOBAL' },
orderBy: { word: 'asc' },
})
// Tenant words (if user has a tenant)
let tenantWords: any[] = []
if (user.tenantId) {
tenantWords = await (prisma as any).dictionaryEntry.findMany({
where: { scope: 'TENANT', tenantId: user.tenantId },
orderBy: { word: 'asc' },
})
}
// Merge: tenant words override global (by word)
const tenantWordSet = new Set(tenantWords.map((w: any) => w.word.toLowerCase()))
const merged = [
...tenantWords.map((w: any) => ({ ...w, source: 'tenant' })),
...globalWords
.filter((w: any) => !tenantWordSet.has(w.word.toLowerCase()))
.map((w: any) => ({ ...w, source: 'global' })),
].sort((a, b) => a.word.localeCompare(b.word, 'de'))
return NextResponse.json({ words: merged })
} catch (error) {
console.error('Error fetching dictionary:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
// POST: Add a word to dictionary
export async function POST(req: NextRequest) {
try {
const user = await getSession()
if (!user || !isAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const body = await req.json()
const { word, scope } = body
if (!word?.trim()) {
return NextResponse.json({ error: 'Wort erforderlich' }, { status: 400 })
}
// Only SERVER_ADMIN can add global words
if (scope === 'GLOBAL' && user.role !== 'SERVER_ADMIN') {
return NextResponse.json({ error: 'Nur Server-Admin kann globale Wörter hinzufügen' }, { status: 403 })
}
const tenantId = scope === 'TENANT' ? user.tenantId : null
const entry = await (prisma as any).dictionaryEntry.create({
data: {
word: word.trim(),
scope: scope || 'GLOBAL',
tenantId,
},
})
return NextResponse.json(entry)
} catch (error: any) {
if (error?.code === 'P2002') {
return NextResponse.json({ error: 'Wort existiert bereits' }, { status: 409 })
}
console.error('Error adding dictionary word:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server'
import { getStripe } from '@/lib/stripe'
export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) {
try {
const { amount, name, message } = await req.json()
if (!amount || amount < 1 || amount > 1000) {
return NextResponse.json({ error: 'Ungültiger Betrag' }, { status: 400 })
}
const stripe = await getStripe()
if (!stripe) {
return NextResponse.json({ error: 'Stripe ist nicht konfiguriert. Bitte kontaktiere den Administrator.' }, { status: 503 })
}
const amountInCents = Math.round(amount * 100)
const origin = req.headers.get('origin') || req.nextUrl.origin
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card', 'twint'],
mode: 'payment',
line_items: [
{
price_data: {
currency: 'chf',
product_data: {
name: 'Spende für Lageplan',
description: `Freiwillige Spende von CHF ${amount} für die Weiterentwicklung`,
},
unit_amount: amountInCents,
},
quantity: 1,
},
],
metadata: {
donor_name: name || 'Anonym',
donor_message: message || '',
type: 'donation',
},
success_url: `${origin}/spenden/danke?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/spenden`,
})
return NextResponse.json({ url: session.url })
} catch (error) {
console.error('Stripe checkout error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Checkout fehlgeschlagen' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
export const dynamic = 'force-dynamic'
// Public endpoint — returns only the publishable key
export async function GET() {
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
if (!secretKey || !publicKey) {
return NextResponse.json({ configured: false })
}
return NextResponse.json({ configured: true, publicKey })
} catch (error: any) {
console.error('[Stripe Config] Error:', error?.message || error)
return NextResponse.json({ configured: false })
}
}

View File

@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server'
import { getStripe, getStripeConfig } from '@/lib/stripe'
import { prisma } from '@/lib/db'
export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) {
try {
const stripe = await getStripe()
const config = await getStripeConfig()
if (!stripe || !config) {
return NextResponse.json({ error: 'Stripe not configured' }, { status: 503 })
}
const body = await req.text()
const sig = req.headers.get('stripe-signature')
if (!sig) {
return NextResponse.json({ error: 'Missing signature' }, { status: 400 })
}
let event
try {
if (config.webhookSecret) {
event = stripe.webhooks.constructEvent(body, sig, config.webhookSecret)
} else {
// No webhook secret configured — parse event directly (not recommended for production)
event = JSON.parse(body)
}
} catch (err) {
console.error('Webhook signature verification failed:', err)
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object
const metadata = session.metadata || {}
if (metadata.type === 'donation') {
// Store donation record
try {
await (prisma as any).systemSetting.create({
data: {
key: `donation_${Date.now()}`,
value: JSON.stringify({
amount: (session.amount_total || 0) / 100,
currency: session.currency,
donor: metadata.donor_name || 'Anonym',
message: metadata.donor_message || '',
paymentId: session.payment_intent,
date: new Date().toISOString(),
}),
isSecret: false,
category: 'donations',
},
})
} catch (e) {
console.error('Failed to store donation record:', e)
}
console.log(`[Donation] CHF ${(session.amount_total || 0) / 100} from ${metadata.donor_name || 'Anonym'}`)
}
}
return NextResponse.json({ received: true })
} catch (error) {
console.error('Webhook error:', error)
return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 })
}
}

View File

@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
// PUT: Update a hose type
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role !== 'SERVER_ADMIN' && user.role !== 'TENANT_ADMIN') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const body = await request.json()
const { name, diameterMm, lengthPerPieceM, flowRateLpm, frictionCoeff, description, isDefault, isActive, sortOrder } = body
// If setting as default, unset other defaults
if (isDefault) {
await prisma.hoseType.updateMany({
where: { isDefault: true, id: { not: params.id } },
data: { isDefault: false },
})
}
const hoseType = await prisma.hoseType.update({
where: { id: params.id },
data: {
...(name !== undefined && { name }),
...(diameterMm !== undefined && { diameterMm: parseInt(diameterMm) }),
...(lengthPerPieceM !== undefined && { lengthPerPieceM: parseInt(lengthPerPieceM) }),
...(flowRateLpm !== undefined && { flowRateLpm: parseFloat(flowRateLpm) }),
...(frictionCoeff !== undefined && { frictionCoeff: parseFloat(frictionCoeff) }),
...(description !== undefined && { description }),
...(isDefault !== undefined && { isDefault }),
...(isActive !== undefined && { isActive }),
...(sortOrder !== undefined && { sortOrder }),
},
})
return NextResponse.json({ hoseType })
} catch (error: any) {
if (error.code === 'P2025') {
return NextResponse.json({ error: 'Schlauchtyp nicht gefunden' }, { status: 404 })
}
console.error('Error updating hose type:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
// DELETE: Delete a hose type
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role !== 'SERVER_ADMIN' && user.role !== 'TENANT_ADMIN') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
await prisma.hoseType.delete({ where: { id: params.id } })
return NextResponse.json({ success: true })
} catch (error: any) {
if (error.code === 'P2025') {
return NextResponse.json({ error: 'Schlauchtyp nicht gefunden' }, { status: 404 })
}
console.error('Error deleting hose type:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
// GET: List all hose types (global + tenant-specific)
export async function GET() {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
// Show global hose types (tenantId=null) + tenant-specific ones
const where: any = { isActive: true }
if (user.role !== 'SERVER_ADMIN' && user.tenantId) {
where.OR = [{ tenantId: null }, { tenantId: user.tenantId }]
delete where.isActive
where.AND = [{ isActive: true }]
}
const hoseTypes = await (prisma as any).hoseType.findMany({
where: user.role === 'SERVER_ADMIN'
? { isActive: true }
: { isActive: true, OR: [{ tenantId: null }, { tenantId: user.tenantId }] },
orderBy: { sortOrder: 'asc' },
})
return NextResponse.json({ hoseTypes })
} catch (error) {
console.error('Error fetching hose types:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
// POST: Create a new hose type (admin only)
export async function POST(request: NextRequest) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role !== 'SERVER_ADMIN' && user.role !== 'TENANT_ADMIN') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const body = await request.json()
const { name, diameterMm, lengthPerPieceM, flowRateLpm, frictionCoeff, description, isDefault, sortOrder } = body
if (!name || !diameterMm || !flowRateLpm || !frictionCoeff) {
return NextResponse.json({ error: 'Name, Durchmesser, Durchfluss und Reibungskoeffizient sind erforderlich' }, { status: 400 })
}
if (isDefault) {
await (prisma as any).hoseType.updateMany({ where: { isDefault: true }, data: { isDefault: false } })
}
const hoseType = await (prisma as any).hoseType.create({
data: {
name,
diameterMm: parseInt(diameterMm),
lengthPerPieceM: parseInt(lengthPerPieceM) || 10,
flowRateLpm: parseFloat(flowRateLpm),
frictionCoeff: parseFloat(frictionCoeff),
description: description || null,
isDefault: isDefault || false,
sortOrder: sortOrder || 0,
tenantId: user.role === 'SERVER_ADMIN' ? null : user.tenantId || null,
},
})
return NextResponse.json({ hoseType }, { status: 201 })
} catch (error: any) {
if (error.code === 'P2002') {
return NextResponse.json({ error: 'Ein Schlauchtyp mit diesem Namen existiert bereits' }, { status: 409 })
}
console.error('Error creating hose type:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getFileStream } from '@/lib/minio'
import { readFile } from 'fs/promises'
import { join } from 'path'
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const icon = await prisma.iconAsset.findUnique({
where: { id: params.id },
})
if (!icon) {
return NextResponse.json({ error: 'Icon nicht gefunden' }, { status: 404 })
}
// Serve system icons from public/signaturen/
if (icon.isSystem && icon.fileKey.startsWith('signaturen/')) {
const contentType = icon.mimeType || (icon.fileKey.endsWith('.svg') ? 'image/svg+xml' : 'image/png')
try {
const filePath = join(process.cwd(), 'public', icon.fileKey)
const buffer = await readFile(filePath)
return new NextResponse(buffer, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000',
},
})
} catch {
try {
const altPath = join(process.cwd(), icon.fileKey)
const buffer = await readFile(altPath)
return new NextResponse(buffer, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000',
},
})
} catch {
console.error('System icon file not found:', icon.fileKey)
}
}
}
// Stream file from MinIO through the app (no external MinIO access needed)
try {
const { stream, contentType } = await getFileStream(icon.fileKey)
const chunks: Buffer[] = []
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk))
}
const buffer = Buffer.concat(chunks)
return new NextResponse(buffer, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=86400',
},
})
} catch (streamErr) {
console.error('Error streaming icon from MinIO:', streamErr)
return NextResponse.json({ error: 'Icon-Datei nicht gefunden' }, { status: 404 })
}
} catch (error) {
console.error('Error fetching icon:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isAdmin } from '@/lib/auth'
// Toggle icon visibility for the current tenant (adds/removes from hiddenIconIds)
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user || !isAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
if (!user.tenantId) {
return NextResponse.json({ error: 'Kein Mandant zugeordnet' }, { status: 400 })
}
const iconId = params.id
// Get current tenant
const tenant = await (prisma as any).tenant.findUnique({
where: { id: user.tenantId },
select: { hiddenIconIds: true },
})
if (!tenant) {
return NextResponse.json({ error: 'Mandant nicht gefunden' }, { status: 404 })
}
const hiddenIds: string[] = tenant.hiddenIconIds || []
const isHidden = hiddenIds.includes(iconId)
// Toggle: if hidden, unhide; if visible, hide
const updatedHiddenIds = isHidden
? hiddenIds.filter((id: string) => id !== iconId)
: [...hiddenIds, iconId]
await (prisma as any).tenant.update({
where: { id: user.tenantId },
data: { hiddenIconIds: updatedHiddenIds },
})
return NextResponse.json({
success: true,
isHidden: !isHidden,
message: isHidden ? 'Symbol eingeblendet' : 'Symbol ausgeblendet',
})
} catch (error) {
console.error('Error toggling icon visibility:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
export async function GET() {
try {
const user = await getSession()
// Build icon filter: global icons (tenantId=null) + tenant-specific icons
const iconFilter: any = { isActive: true }
if (user?.tenantId) {
iconFilter.OR = [
{ tenantId: null },
{ tenantId: user.tenantId },
]
delete iconFilter.isActive
iconFilter.AND = [{ isActive: true }]
} else {
// Server admin or no tenant: show all global icons
iconFilter.tenantId = null
}
// Filter categories: global (tenantId=null) + tenant-specific
const categoryWhere: any = user?.tenantId
? { OR: [{ tenantId: null }, { tenantId: user.tenantId }] }
: {}
const categories = await (prisma as any).iconCategory.findMany({
where: categoryWhere,
orderBy: { sortOrder: 'asc' },
include: {
icons: {
where: user?.tenantId
? { isActive: true, OR: [{ tenantId: null }, { tenantId: user.tenantId }] }
: { isActive: true },
orderBy: { name: 'asc' },
},
},
})
// Get tenant's hidden icon IDs
let hiddenIconIds: string[] = []
if (user?.tenantId) {
const tenant = await (prisma as any).tenant.findUnique({
where: { id: user.tenantId },
select: { hiddenIconIds: true },
})
hiddenIconIds = tenant?.hiddenIconIds || []
}
const categoriesWithUrls = categories.map((cat: any) => ({
...cat,
icons: cat.icons
.filter((icon: any) => !hiddenIconIds.includes(icon.id))
.map((icon: any) => ({
...icon,
url: `/api/icons/${icon.id}/image`,
})),
}))
return NextResponse.json({ categories: categoriesWithUrls })
} catch (error) {
console.error('Error fetching icons:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { prisma } from '@/lib/db'
import { uploadFile } from '@/lib/minio'
import { v4 as uuidv4 } from 'uuid'
export async function POST(request: NextRequest) {
try {
const user = await getSession()
if (!user) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
}
if (user.role === 'VIEWER') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const formData = await request.formData()
const file = formData.get('file') as File | null
const name = formData.get('name') as string | null
const categoryId = formData.get('categoryId') as string | null
if (!file || !name || !categoryId) {
return NextResponse.json(
{ error: 'Datei, Name und Kategorie sind erforderlich' },
{ status: 400 }
)
}
const allowedTypes = ['image/png', 'image/svg+xml', 'image/jpeg', 'image/webp']
if (!allowedTypes.includes(file.type)) {
return NextResponse.json(
{ error: 'Nur PNG, SVG, JPEG und WebP Dateien sind erlaubt' },
{ status: 400 }
)
}
const maxSize = 5 * 1024 * 1024 // 5MB
if (file.size > maxSize) {
return NextResponse.json(
{ error: 'Datei ist zu gross (max. 5MB)' },
{ status: 400 }
)
}
const category = await prisma.iconCategory.findUnique({
where: { id: categoryId },
})
if (!category) {
return NextResponse.json(
{ error: 'Kategorie nicht gefunden' },
{ status: 404 }
)
}
const fileExtension = file.name.split('.').pop() || 'png'
const fileKey = `icons/${user.id}/${uuidv4()}.${fileExtension}`
const buffer = Buffer.from(await file.arrayBuffer())
await uploadFile(fileKey, buffer, file.type)
const iconAsset = await prisma.iconAsset.create({
data: {
name,
categoryId,
ownerId: user.id,
fileKey,
mimeType: file.type,
isSystem: false,
},
})
return NextResponse.json({ icon: iconAsset }, { status: 201 })
} catch (error) {
console.error('Error uploading icon:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,173 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
const HEARTBEAT_TIMEOUT_MS = 2 * 60 * 1000 // 2 minutes
function isEditingExpired(heartbeat: Date | null): boolean {
if (!heartbeat) return true
return Date.now() - new Date(heartbeat).getTime() > HEARTBEAT_TIMEOUT_MS
}
// GET: Check editing status of a project
export async function GET(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const sessionId = req.nextUrl.searchParams.get('sessionId') || ''
const project = await (prisma as any).project.findUnique({
where: { id: params.id },
select: {
id: true,
editingById: true,
editingUserName: true,
editingSessionId: true,
editingStartedAt: true,
editingHeartbeat: true,
isLocked: true,
},
})
if (!project) {
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
}
// Auto-release if heartbeat expired
if (project.editingById && isEditingExpired(project.editingHeartbeat)) {
await (prisma as any).project.update({
where: { id: params.id },
data: {
editingById: null,
editingUserName: null,
editingSessionId: null,
editingStartedAt: null,
editingHeartbeat: null,
},
})
return NextResponse.json({
editing: false,
editingBy: null,
isMe: false,
isLocked: project.isLocked,
})
}
// isMe is true only if sessionId matches (same tab/device)
const isMe = !!sessionId && project.editingSessionId === sessionId
return NextResponse.json({
editing: !!project.editingById,
editingBy: project.editingById ? {
id: project.editingById,
name: project.editingUserName,
since: project.editingStartedAt,
} : null,
isMe,
isLocked: project.isLocked,
})
} catch (error) {
console.error('Error checking editing status:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
// POST: Start editing / heartbeat / stop editing
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const body = await req.json()
const { action, sessionId } = body // 'start', 'heartbeat', 'stop'
const project = await (prisma as any).project.findUnique({
where: { id: params.id },
select: {
id: true,
editingById: true,
editingSessionId: true,
editingHeartbeat: true,
isLocked: true,
tenantId: true,
},
})
if (!project) {
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
}
if (action === 'start') {
if (!sessionId) {
return NextResponse.json({ error: 'sessionId fehlt' }, { status: 400 })
}
// Check if another session is editing (and heartbeat not expired)
if (project.editingSessionId && project.editingSessionId !== sessionId && !isEditingExpired(project.editingHeartbeat)) {
return NextResponse.json({
error: 'Projekt wird gerade von jemand anderem bearbeitet',
editingBy: project.editingById,
}, { status: 409 })
}
// Start editing
await (prisma as any).project.update({
where: { id: params.id },
data: {
editingById: user.id,
editingUserName: user.name,
editingSessionId: sessionId,
editingStartedAt: new Date(),
editingHeartbeat: new Date(),
},
})
return NextResponse.json({ success: true, action: 'started' })
}
if (action === 'heartbeat') {
// Only the current session can send heartbeats
if (project.editingSessionId !== sessionId) {
return NextResponse.json({ error: 'Sie sind nicht der aktuelle Bearbeiter' }, { status: 403 })
}
await (prisma as any).project.update({
where: { id: params.id },
data: { editingHeartbeat: new Date() },
})
return NextResponse.json({ success: true, action: 'heartbeat' })
}
if (action === 'stop') {
// Only the current session or a SERVER_ADMIN can stop editing
if (project.editingSessionId !== sessionId && user.role !== 'SERVER_ADMIN') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
await (prisma as any).project.update({
where: { id: params.id },
data: {
editingById: null,
editingUserName: null,
editingSessionId: null,
editingStartedAt: null,
editingHeartbeat: null,
},
})
return NextResponse.json({ success: true, action: 'stopped' })
}
return NextResponse.json({ error: 'Ungültige Aktion' }, { status: 400 })
} catch (error) {
console.error('Error managing editing lock:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { prisma } from '@/lib/db'
import { getProjectWithTenantCheck } from '@/lib/tenant'
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
}
// Tenant isolation check
const projectCheck = await getProjectWithTenantCheck(params.id, user)
if (!projectCheck) {
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
}
const { searchParams } = new URL(request.url)
const format = searchParams.get('format') || 'json'
const project = await (prisma as any).project.findUnique({
where: { id: params.id },
include: {
features: true,
owner: {
select: { name: true },
},
},
})
if (!project) {
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
}
if (format === 'geojson') {
const geojson = {
type: 'FeatureCollection',
properties: {
title: project.title,
location: project.location,
description: project.description,
createdAt: project.createdAt,
updatedAt: project.updatedAt,
owner: project.owner?.name ?? null,
},
features: project.features.map((f: any) => ({
type: 'Feature',
id: f.id,
geometry: f.geometry,
properties: {
type: f.type,
...f.properties as object,
},
})),
}
return new NextResponse(JSON.stringify(geojson, null, 2), {
headers: {
'Content-Type': 'application/geo+json',
'Content-Disposition': `attachment; filename="${project.title.replace(/[^a-z0-9]/gi, '_')}.geojson"`,
},
})
}
// Default: return project data for client-side PDF/PNG generation
return NextResponse.json({
project: {
...project,
features: project.features.map((f: any) => ({
id: f.id,
type: f.type,
geometry: f.geometry,
properties: f.properties,
})),
},
})
} catch (error) {
console.error('Error exporting project:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,139 @@
import { NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { prisma } from '@/lib/db'
import { featureSchema } from '@/lib/validations'
import { getProjectWithTenantCheck } from '@/lib/tenant'
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
}
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) {
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
}
const features = await (prisma as any).feature.findMany({
where: { projectId: params.id },
orderBy: { createdAt: 'asc' },
})
return NextResponse.json({ features })
} catch (error) {
console.error('Error fetching features:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
}
if (user.role === 'VIEWER') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) {
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
}
if (project.isLocked && user.role !== 'SERVER_ADMIN') {
return NextResponse.json({ error: 'Projekt ist gesperrt' }, { status: 403 })
}
const body = await request.json()
const validated = featureSchema.safeParse(body)
if (!validated.success) {
return NextResponse.json(
{ error: 'Ungültige Eingabedaten', details: validated.error.flatten() },
{ status: 400 }
)
}
const feature = await (prisma as any).feature.create({
data: {
projectId: params.id,
type: validated.data.type,
geometry: validated.data.geometry,
properties: validated.data.properties || {},
},
})
return NextResponse.json({ feature }, { status: 201 })
} catch (error) {
console.error('Error creating feature:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
}
if (user.role === 'VIEWER') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) {
const exists = await (prisma as any).project.findUnique({ where: { id: params.id }, select: { id: true, tenantId: true, ownerId: true } })
if (!exists) {
console.warn(`[Features PUT] Project ${params.id} not in DB`)
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
}
console.warn(`[Features PUT] Access denied: user=${user.id} tenant=${user.tenantId}, project owner=${exists.ownerId} tenant=${exists.tenantId}`)
return NextResponse.json({ error: 'Keine Berechtigung für dieses Projekt' }, { status: 403 })
}
if (project.isLocked && user.role !== 'SERVER_ADMIN') {
return NextResponse.json({ error: 'Projekt ist gesperrt' }, { status: 403 })
}
const body = await request.json()
const { features } = body as { features: Array<{ id?: string; type: string; geometry: object; properties?: object }> }
await (prisma as any).feature.deleteMany({
where: { projectId: params.id },
})
if (features && features.length > 0) {
await (prisma as any).feature.createMany({
data: features.map((f: any) => ({
projectId: params.id,
type: f.type,
geometry: f.geometry,
properties: f.properties || {},
})),
})
}
const updatedFeatures = await (prisma as any).feature.findMany({
where: { projectId: params.id },
})
return NextResponse.json({ features: updatedFeatures })
} catch (error) {
console.error('Error updating features:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { getProjectWithTenantCheck } from '@/lib/tenant'
// PUT: Toggle confirmed/ok on a check item
export async function PUT(req: NextRequest, { params }: { params: { id: string; itemId: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
// Verify item belongs to this project
const existing = await (prisma as any).journalCheckItem.findFirst({
where: { id: params.itemId, projectId: params.id },
})
if (!existing) return NextResponse.json({ error: 'Element nicht gefunden' }, { status: 404 })
const body = await req.json()
const data: any = {}
if (body.label !== undefined) data.label = body.label
if (body.confirmed !== undefined) {
data.confirmed = body.confirmed
data.confirmedAt = body.confirmed ? new Date() : null
}
if (body.ok !== undefined) {
data.ok = body.ok
data.okAt = body.ok ? new Date() : null
}
const item = await (prisma as any).journalCheckItem.update({
where: { id: params.itemId },
data,
})
return NextResponse.json(item)
} catch (error) {
console.error('Error updating check item:', error)
return NextResponse.json({ error: 'Failed to update check item' }, { status: 500 })
}
}
// DELETE
export async function DELETE(req: NextRequest, { params }: { params: { id: string; itemId: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
// Verify item belongs to this project
const existing = await (prisma as any).journalCheckItem.findFirst({
where: { id: params.itemId, projectId: params.id },
})
if (!existing) return NextResponse.json({ error: 'Element nicht gefunden' }, { status: 404 })
await (prisma as any).journalCheckItem.delete({ where: { id: params.itemId } })
return NextResponse.json({ ok: true })
} catch (error) {
console.error('Error deleting check item:', error)
return NextResponse.json({ error: 'Failed to delete check item' }, { status: 500 })
}
}

View File

@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { getProjectWithTenantCheck } from '@/lib/tenant'
// POST: Add check item (or initialize from templates)
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
const body = await req.json()
// If 'initFromTemplates' is true, create check items from templates (only if none exist)
if (body.initFromTemplates) {
const existing = await (prisma as any).journalCheckItem.findMany({
where: { projectId: params.id },
})
if (existing.length > 0) {
return NextResponse.json(existing)
}
const templates = await (prisma as any).journalCheckTemplate.findMany({
where: { isActive: true },
orderBy: { sortOrder: 'asc' },
})
const items = await Promise.all(
templates.map((tpl: any, i: number) =>
(prisma as any).journalCheckItem.create({
data: {
projectId: params.id,
label: tpl.label,
sortOrder: i,
},
})
)
)
return NextResponse.json(items)
}
// Single item creation
const item = await (prisma as any).journalCheckItem.create({
data: {
projectId: params.id,
label: body.label || '',
sortOrder: body.sortOrder || 0,
},
})
return NextResponse.json(item)
} catch (error) {
console.error('Error creating check item:', error)
return NextResponse.json({ error: 'Failed to create check item' }, { status: 500 })
}
}

View File

@@ -0,0 +1,101 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { getProjectWithTenantCheck } from '@/lib/tenant'
// PUT: Update a journal entry — only toggle done status allowed directly
export async function PUT(req: NextRequest, { params }: { params: { id: string; entryId: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
const existing = await (prisma as any).journalEntry.findFirst({
where: { id: params.entryId, projectId: params.id },
})
if (!existing) return NextResponse.json({ error: 'Eintrag nicht gefunden' }, { status: 404 })
const body = await req.json()
// Only done toggle is allowed as direct edit
if (body.done !== undefined) {
const entry = await (prisma as any).journalEntry.update({
where: { id: params.entryId },
data: { done: body.done, doneAt: body.done ? new Date() : null },
})
return NextResponse.json(entry)
}
return NextResponse.json({ error: 'Direkte Bearbeitung nicht erlaubt. Bitte Korrektur erstellen.' }, { status: 400 })
} catch (error) {
console.error('Error updating journal entry:', error)
return NextResponse.json({ error: 'Failed to update entry' }, { status: 500 })
}
}
// POST: Create a correction for a journal entry (replaces DELETE)
// Marks the original as corrected (strikethrough) and creates a new correction entry below it
export async function POST(req: NextRequest, { params }: { params: { id: string; entryId: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
const existing = await (prisma as any).journalEntry.findFirst({
where: { id: params.entryId, projectId: params.id },
})
if (!existing) return NextResponse.json({ error: 'Eintrag nicht gefunden' }, { status: 404 })
// Prevent double-correction or correcting a correction entry
if (existing.isCorrected) {
return NextResponse.json({ error: 'Dieser Eintrag wurde bereits korrigiert.' }, { status: 400 })
}
if (existing.correctionOfId) {
return NextResponse.json({ error: 'Ein Korrektureintrag kann nicht nochmals korrigiert werden.' }, { status: 400 })
}
const body = await req.json()
const correctionText = body.what || ''
if (!correctionText.trim()) {
return NextResponse.json({ error: 'Korrekturtext ist erforderlich' }, { status: 400 })
}
// Mark original as corrected
await (prisma as any).journalEntry.update({
where: { id: params.entryId },
data: { isCorrected: true },
})
// Create correction entry with same time, placed right after the original
const correction = await (prisma as any).journalEntry.create({
data: {
time: existing.time,
what: `[Korrektur] ${correctionText}`,
who: body.who || existing.who || user.name,
sortOrder: existing.sortOrder + 1,
correctionOfId: existing.id,
projectId: params.id,
},
})
return NextResponse.json({ original: existing, correction })
} catch (error) {
console.error('Error creating correction:', error)
return NextResponse.json({ error: 'Failed to create correction' }, { status: 500 })
}
}
// DELETE: Not allowed — entries cannot be deleted, only corrected
export async function DELETE() {
return NextResponse.json(
{ error: 'Journal-Einträge können nicht gelöscht werden. Bitte erstellen Sie eine Korrektur.' },
{ status: 403 }
)
}

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { getProjectWithTenantCheck } from '@/lib/tenant'
// POST: Add a new journal entry
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
const body = await req.json()
const entry = await (prisma as any).journalEntry.create({
data: {
projectId: params.id,
time: body.time ? new Date(body.time) : new Date(),
what: body.what || '',
who: body.who || null,
done: body.done || false,
},
})
return NextResponse.json(entry)
} catch (error) {
console.error('Error creating journal entry:', error)
return NextResponse.json({ error: 'Failed to create entry' }, { status: 500 })
}
}

View File

@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { getProjectWithTenantCheck } from '@/lib/tenant'
// PUT: Update a pendenz
export async function PUT(req: NextRequest, { params }: { params: { id: string; pendenzId: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
// Verify pendenz belongs to this project
const existing = await (prisma as any).journalPendenz.findFirst({
where: { id: params.pendenzId, projectId: params.id },
})
if (!existing) return NextResponse.json({ error: 'Pendenz nicht gefunden' }, { status: 404 })
const body = await req.json()
const data: any = {}
if (body.what !== undefined) data.what = body.what
if (body.who !== undefined) data.who = body.who
if (body.whenHow !== undefined) data.whenHow = body.whenHow
if (body.done !== undefined) {
data.done = body.done
data.doneAt = body.done ? new Date() : null
}
const item = await (prisma as any).journalPendenz.update({
where: { id: params.pendenzId },
data,
})
return NextResponse.json(item)
} catch (error) {
console.error('Error updating pendenz:', error)
return NextResponse.json({ error: 'Failed to update pendenz' }, { status: 500 })
}
}
// DELETE
export async function DELETE(req: NextRequest, { params }: { params: { id: string; pendenzId: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
// Verify pendenz belongs to this project
const existing = await (prisma as any).journalPendenz.findFirst({
where: { id: params.pendenzId, projectId: params.id },
})
if (!existing) return NextResponse.json({ error: 'Pendenz nicht gefunden' }, { status: 404 })
await (prisma as any).journalPendenz.delete({ where: { id: params.pendenzId } })
return NextResponse.json({ ok: true })
} catch (error) {
console.error('Error deleting pendenz:', error)
return NextResponse.json({ error: 'Failed to delete pendenz' }, { status: 500 })
}
}

View File

@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { getProjectWithTenantCheck } from '@/lib/tenant'
// POST: Add a new pendenz
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
const body = await req.json()
const item = await (prisma as any).journalPendenz.create({
data: {
projectId: params.id,
what: body.what || '',
who: body.who || null,
whenHow: body.whenHow || null,
},
})
return NextResponse.json(item)
} catch (error) {
console.error('Error creating pendenz:', error)
return NextResponse.json({ error: 'Failed to create pendenz' }, { status: 500 })
}
}

View File

@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { getProjectWithTenantCheck } from '@/lib/tenant'
// GET all journal data for a project (entries, check items, pendenzen)
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
const [entries, checkItems, pendenzen] = await Promise.all([
(prisma as any).journalEntry.findMany({
where: { projectId: params.id },
orderBy: [{ time: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }],
}),
(prisma as any).journalCheckItem.findMany({
where: { projectId: params.id },
orderBy: { sortOrder: 'asc' },
}),
(prisma as any).journalPendenz.findMany({
where: { projectId: params.id },
orderBy: { sortOrder: 'asc' },
}),
])
return NextResponse.json({ entries, checkItems, pendenzen })
} catch (error) {
console.error('Error fetching journal:', error)
return NextResponse.json({ error: 'Failed to fetch journal' }, { status: 500 })
}
}

View File

@@ -0,0 +1,168 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { getProjectWithTenantCheck } from '@/lib/tenant'
import { sendEmail } from '@/lib/email'
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
// Load tenant logo
let tenantLogoUrl = ''
let tenantName = ''
if ((project as any).tenantId) {
const tenant = await (prisma as any).tenant.findUnique({
where: { id: (project as any).tenantId },
select: { logoUrl: true, name: true },
})
if (tenant?.logoUrl) tenantLogoUrl = tenant.logoUrl
if (tenant?.name) tenantName = tenant.name
}
const body = await req.json()
const { recipientEmail } = body
if (!recipientEmail) {
return NextResponse.json({ error: 'Empfänger-E-Mail erforderlich' }, { status: 400 })
}
// Load journal data
const entries = await (prisma as any).journalEntry.findMany({
where: { projectId: params.id },
orderBy: [{ time: 'asc' }, { sortOrder: 'asc' }],
})
const checkItems = await (prisma as any).journalCheckItem.findMany({
where: { projectId: params.id },
orderBy: { sortOrder: 'asc' },
})
const pendenzen = await (prisma as any).journalPendenz.findMany({
where: { projectId: params.id },
orderBy: { sortOrder: 'asc' },
})
// Build HTML report
const formatTime = (d: Date) => new Date(d).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
const formatDate = (d: Date) => new Date(d).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
const p = project as any
let entriesHtml = ''
for (const e of entries) {
const correctedStyle = e.isCorrected ? 'text-decoration:line-through;opacity:0.5;' : ''
const correctionStyle = e.correctionOfId ? 'color:#b45309;font-style:italic;' : ''
const doneIcon = e.done ? '✅' : ''
const doneAtStr = e.done && e.doneAt ? ` <span style="color:#16a34a;font-size:10px;">(erledigt ${formatTime(e.doneAt)})</span>` : ''
entriesHtml += `
<tr style="${correctedStyle}${correctionStyle}">
<td style="padding:4px 8px;border-bottom:1px solid #e5e7eb;font-family:monospace;font-size:12px;white-space:nowrap;">${formatTime(e.time)}</td>
<td style="padding:4px 8px;border-bottom:1px solid #e5e7eb;font-size:13px;">${e.what}${e.isCorrected ? ' <span style="color:#ef4444;font-size:11px;">(korrigiert)</span>' : ''}${doneAtStr}</td>
<td style="padding:4px 8px;border-bottom:1px solid #e5e7eb;font-size:12px;color:#666;">${e.who || ''}</td>
<td style="padding:4px 8px;border-bottom:1px solid #e5e7eb;text-align:center;">${doneIcon}</td>
</tr>`
}
let checkHtml = ''
for (const c of checkItems) {
const confirmedTime = c.confirmed && c.confirmedAt ? ` <span style="font-size:10px;color:#666;">${formatTime(c.confirmedAt)}</span>` : ''
checkHtml += `
<tr>
<td style="padding:3px 8px;border-bottom:1px solid #e5e7eb;font-size:13px;">${c.label}${confirmedTime}</td>
<td style="padding:3px 8px;border-bottom:1px solid #e5e7eb;text-align:center;">${c.confirmed ? '✅' : ''}</td>
<td style="padding:3px 8px;border-bottom:1px solid #e5e7eb;text-align:center;">${c.ok ? '✅' : ''}</td>
</tr>`
}
let pendHtml = ''
for (const p of pendenzen) {
const pendDoneAt = p.done && p.doneAt ? ` <span style="color:#16a34a;font-size:10px;">(${formatTime(p.doneAt)})</span>` : ''
pendHtml += `
<tr>
<td style="padding:3px 8px;border-bottom:1px solid #e5e7eb;font-size:13px;">${p.what}${pendDoneAt}</td>
<td style="padding:3px 8px;border-bottom:1px solid #e5e7eb;font-size:12px;color:#666;">${p.who || ''}</td>
<td style="padding:3px 8px;border-bottom:1px solid #e5e7eb;font-size:12px;color:#666;">${p.whenHow || ''}</td>
<td style="padding:3px 8px;border-bottom:1px solid #e5e7eb;text-align:center;">${p.done ? '✅' : ''}</td>
</tr>`
}
const logoHtml = tenantLogoUrl
? `<img src="${tenantLogoUrl}" alt="${tenantName}" style="height:40px;max-width:120px;object-fit:contain;margin-right:16px;border-radius:4px;" />`
: ''
const html = `
<div style="font-family:sans-serif;max-width:800px;margin:0 auto;">
<div style="background:#dc2626;color:white;padding:20px 24px;border-radius:12px 12px 0 0;display:flex;align-items:center;">
${logoHtml}
<div>
<h1 style="margin:0;font-size:22px;">Einsatzrapport</h1>
<p style="margin:4px 0 0;opacity:0.9;">${p.title || 'Ohne Titel'}${tenantName ? `${tenantName}` : ''}</p>
</div>
</div>
<div style="border:1px solid #e5e7eb;border-top:none;padding:24px;border-radius:0 0 12px 12px;">
<div style="display:flex;gap:24px;margin-bottom:20px;flex-wrap:wrap;">
<div><strong>Standort:</strong> ${p.location || ''}</div>
<div><strong>Datum:</strong> ${p.createdAt ? formatDate(p.createdAt) : ''}</div>
</div>
<h2 style="font-size:16px;border-bottom:2px solid #dc2626;padding-bottom:4px;margin:20px 0 8px;">Journal-Einträge</h2>
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr style="background:#f5f5f4;">
<th style="padding:6px 8px;text-align:left;font-size:11px;color:#666;border-bottom:2px solid #dc2626;">Zeit</th>
<th style="padding:6px 8px;text-align:left;font-size:11px;color:#666;border-bottom:2px solid #dc2626;">Was</th>
<th style="padding:6px 8px;text-align:left;font-size:11px;color:#666;border-bottom:2px solid #dc2626;">Wer</th>
<th style="padding:6px 8px;text-align:center;font-size:11px;color:#666;border-bottom:2px solid #dc2626;">Ok</th>
</tr>
</thead>
<tbody>${entriesHtml || '<tr><td colspan="4" style="padding:12px;text-align:center;color:#999;">Keine Einträge</td></tr>'}</tbody>
</table>
${checkItems.length > 0 ? `
<h2 style="font-size:16px;border-bottom:2px solid #dc2626;padding-bottom:4px;margin:20px 0 8px;">SOMA Checkliste</h2>
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr style="background:#f5f5f4;">
<th style="padding:6px 8px;text-align:left;font-size:11px;color:#666;">Punkt</th>
<th style="padding:6px 8px;text-align:center;font-size:11px;color:#666;">Bestätigt</th>
<th style="padding:6px 8px;text-align:center;font-size:11px;color:#666;">Ok</th>
</tr>
</thead>
<tbody>${checkHtml}</tbody>
</table>` : ''}
${pendenzen.length > 0 ? `
<h2 style="font-size:16px;border-bottom:2px solid #dc2626;padding-bottom:4px;margin:20px 0 8px;">Pendenzen</h2>
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr style="background:#f5f5f4;">
<th style="padding:6px 8px;text-align:left;font-size:11px;color:#666;">Was</th>
<th style="padding:6px 8px;text-align:left;font-size:11px;color:#666;">Wer</th>
<th style="padding:6px 8px;text-align:left;font-size:11px;color:#666;">Wann/Wie</th>
<th style="padding:6px 8px;text-align:center;font-size:11px;color:#666;">Erledigt</th>
</tr>
</thead>
<tbody>${pendHtml}</tbody>
</table>` : ''}
<hr style="margin:20px 0;border:none;border-top:1px solid #e5e7eb;" />
<p style="color:#999;font-size:11px;">Gesendet von Lageplan am ${new Date().toLocaleString('de-CH')} durch ${user.name || user.email}</p>
</div>
</div>`
await sendEmail(
recipientEmail,
`Einsatzrapport — ${p.title || 'Ohne Titel'}`,
html
)
return NextResponse.json({ success: true, message: `Rapport an ${recipientEmail} gesendet` })
} catch (error) {
console.error('Error sending report:', error)
return NextResponse.json({ error: 'Fehler beim Senden des Rapports' }, { status: 500 })
}
}

View File

@@ -0,0 +1,121 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { getProjectWithTenantCheck } from '@/lib/tenant'
import { uploadFile, deleteFile, getFileUrl } from '@/lib/minio'
// POST: Upload a plan image for a project
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
const formData = await req.formData()
const file = formData.get('file') as File | null
const boundsStr = formData.get('bounds') as string | null
if (!file) {
return NextResponse.json({ error: 'Keine Datei angegeben' }, { status: 400 })
}
// Validate file type
const allowedTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml']
if (!allowedTypes.includes(file.type)) {
return NextResponse.json({ error: 'Nur PNG, JPEG, WebP oder SVG erlaubt' }, { status: 400 })
}
// Delete old plan image if exists
const p = project as any
if (p.planImageKey) {
try { await deleteFile(p.planImageKey) } catch {}
}
// Upload to MinIO
const buffer = Buffer.from(await file.arrayBuffer())
const ext = file.name.split('.').pop() || 'png'
const fileKey = `plans/${params.id}/${Date.now()}.${ext}`
await uploadFile(fileKey, buffer, file.type)
// Parse bounds or use default (current map view)
let bounds = null
if (boundsStr) {
try { bounds = JSON.parse(boundsStr) } catch {}
}
// Update project
await (prisma as any).project.update({
where: { id: params.id },
data: {
planImageKey: fileKey,
planBounds: bounds,
},
})
const url = await getFileUrl(fileKey)
return NextResponse.json({
success: true,
planImageUrl: url,
planImageKey: fileKey,
planBounds: bounds,
})
} catch (error) {
console.error('Error uploading plan image:', error)
return NextResponse.json({ error: 'Fehler beim Hochladen' }, { status: 500 })
}
}
// DELETE: Remove the plan image
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
const p = project as any
if (p.planImageKey) {
try { await deleteFile(p.planImageKey) } catch {}
}
await (prisma as any).project.update({
where: { id: params.id },
data: { planImageKey: null, planBounds: null },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error deleting plan image:', error)
return NextResponse.json({ error: 'Fehler beim Löschen' }, { status: 500 })
}
}
// PATCH: Update plan bounds (repositioning)
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
const body = await req.json()
if (!body.bounds) return NextResponse.json({ error: 'Bounds erforderlich' }, { status: 400 })
await (prisma as any).project.update({
where: { id: params.id },
data: { planBounds: body.bounds },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error updating plan bounds:', error)
return NextResponse.json({ error: 'Fehler beim Aktualisieren' }, { status: 500 })
}
}

View File

@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { getFileStream } from '@/lib/minio'
// Serve plan image (authenticated users only)
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const project = await (prisma as any).project.findUnique({
where: { id: params.id },
select: { planImageKey: true },
})
if (!project?.planImageKey) {
return NextResponse.json({ error: 'Kein Plan vorhanden' }, { status: 404 })
}
const { stream, contentType } = await getFileStream(project.planImageKey)
// Collect stream into buffer
const chunks: Buffer[] = []
for await (const chunk of stream as AsyncIterable<Buffer>) {
chunks.push(chunk)
}
const buffer = Buffer.concat(chunks)
return new NextResponse(buffer, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=3600',
},
})
} catch (error) {
console.error('Error serving plan image:', error)
return NextResponse.json({ error: 'Fehler beim Laden des Plans' }, { status: 500 })
}
}

View File

@@ -0,0 +1,110 @@
import { NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { prisma } from '@/lib/db'
import { projectSchema } from '@/lib/validations'
import { getProjectWithTenantCheck } from '@/lib/tenant'
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
}
const projectBase = await getProjectWithTenantCheck(params.id, user)
if (!projectBase) {
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
}
// Re-fetch with includes
const project = await (prisma as any).project.findUnique({
where: { id: params.id },
include: {
owner: {
select: { id: true, name: true, email: true },
},
features: true,
},
})
return NextResponse.json({ project })
} catch (error) {
console.error('Error fetching project:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
}
if (user.role === 'VIEWER') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const existingProject = await getProjectWithTenantCheck(params.id, user)
if (!existingProject) {
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
}
const body = await request.json()
const validated = projectSchema.partial().safeParse(body)
if (!validated.success) {
return NextResponse.json(
{ error: 'Ungültige Eingabedaten', details: validated.error.flatten() },
{ status: 400 }
)
}
const project = await (prisma as any).project.update({
where: { id: params.id },
data: validated.data,
})
return NextResponse.json({ project })
} catch (error) {
console.error('Error updating project:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
}
const existingProject = await getProjectWithTenantCheck(params.id, user)
if (!existingProject) {
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
}
// Only owner, tenant admin, or server admin can delete
if (user.role !== 'SERVER_ADMIN' && user.role !== 'TENANT_ADMIN' && existingProject.ownerId !== user.id) {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
await (prisma as any).project.delete({
where: { id: params.id },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error deleting project:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,94 @@
import { NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { prisma } from '@/lib/db'
import { projectSchema } from '@/lib/validations'
import { getTenantFilter } from '@/lib/tenant'
export async function GET() {
try {
const user = await getSession()
if (!user) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
}
// Tenant isolation: each user only sees their tenant's projects
const tenantFilter = getTenantFilter(user)
const projects = await (prisma as any).project.findMany({
where: tenantFilter,
orderBy: { updatedAt: 'desc' },
include: {
owner: {
select: { id: true, name: true, email: true },
},
_count: {
select: { features: true },
},
},
})
return NextResponse.json({ projects })
} catch (error) {
console.error('Error fetching projects:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const user = await getSession()
if (!user) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
}
if (user.role === 'VIEWER') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const body = await request.json()
const validated = projectSchema.safeParse(body)
if (!validated.success) {
return NextResponse.json(
{ error: 'Ungültige Eingabedaten', details: validated.error.flatten() },
{ status: 400 }
)
}
// Generate unique Einsatz-Nr: E-YYYY-NNNN (auto-increment per tenant per year)
const year = new Date().getFullYear()
const einsatzPrefix = `E-${year}-`
let einsatzNr = `${einsatzPrefix}0001`
try {
const lastProject = await (prisma as any).project.findFirst({
where: {
tenantId: user.tenantId || undefined,
einsatzNr: { startsWith: einsatzPrefix },
},
orderBy: { einsatzNr: 'desc' },
select: { einsatzNr: true },
})
if (lastProject?.einsatzNr) {
const lastNum = parseInt(lastProject.einsatzNr.split('-').pop())
if (!isNaN(lastNum)) einsatzNr = `${einsatzPrefix}${String(lastNum + 1).padStart(4, '0')}`
}
} catch {}
// Always set tenantId from the logged-in user's tenant
const project = await (prisma as any).project.create({
data: {
...validated.data,
einsatzNr,
ownerId: user.id,
tenantId: user.tenantId || null,
mapCenter: validated.data.mapCenter || { lng: 8.5417, lat: 47.3769 },
mapZoom: validated.data.mapZoom || 15,
},
})
return NextResponse.json({ project }, { status: 201 })
} catch (error) {
console.error('Error creating project:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import React from 'react'
// Convert a MinIO/internal logo URL to a base64 data URI server-side
async function resolveLogoDataUri(rapport: any): Promise<string> {
try {
const logoUrl = rapport.data?.logoUrl
// Already a data URI — use as-is
if (logoUrl && logoUrl.startsWith('data:')) return logoUrl
// Try to load logo from MinIO via tenant's logoFileKey / logoUrl
const tenantId = rapport.tenantId
if (!tenantId) return ''
const tenant = await (prisma as any).tenant.findUnique({
where: { id: tenantId },
select: { logoFileKey: true, logoUrl: true },
})
let fileKey = tenant?.logoFileKey
if (!fileKey && tenant?.logoUrl) {
const match = tenant.logoUrl.match(/logos\/[^?]+/)
if (match) fileKey = match[0]
}
if (!fileKey) return ''
const { getFileStream } = await import('@/lib/minio')
const { stream, contentType } = await getFileStream(fileKey)
const chunks: Buffer[] = []
for await (const chunk of stream as AsyncIterable<Buffer>) {
chunks.push(chunk)
}
const buffer = Buffer.concat(chunks)
return `data:${contentType};base64,${buffer.toString('base64')}`
} catch (e) {
console.warn('[Rapport PDF] Could not resolve logo:', e)
return ''
}
}
// GET: Generate and serve PDF for a rapport (public, token-based)
export async function GET(req: NextRequest, { params }: { params: { token: string } }) {
try {
const rapport = await (prisma as any).rapport.findUnique({
where: { token: params.token },
include: {
tenant: { select: { name: true } },
},
})
if (!rapport) {
return NextResponse.json({ error: 'Rapport nicht gefunden' }, { status: 404 })
}
// Ensure logo is a valid data URI for PDF rendering
const pdfData = { ...rapport.data }
const resolvedLogo = await resolveLogoDataUri(rapport)
if (resolvedLogo) {
pdfData.logoUrl = resolvedLogo
} else if (pdfData.logoUrl && !pdfData.logoUrl.startsWith('data:')) {
// Remove non-data-URI logo URLs — @react-pdf can't fetch them
pdfData.logoUrl = ''
}
// Dynamic import to avoid issues during build when @react-pdf/renderer isn't installed
const { renderToBuffer } = await import('@react-pdf/renderer')
const { RapportDocument } = await import('@/lib/rapport-pdf')
const buffer = await renderToBuffer(
React.createElement(RapportDocument, { data: pdfData })
)
return new NextResponse(buffer, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `inline; filename="Rapport-${rapport.reportNumber}.pdf"`,
'Cache-Control': 'public, max-age=3600',
},
})
} catch (error: any) {
console.error('Error generating rapport PDF:', error)
const msg = error?.message || String(error)
return NextResponse.json({ error: 'PDF-Generierung fehlgeschlagen', detail: msg }, { status: 500 })
}
}

View File

@@ -0,0 +1,77 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
// Resolve tenant logo to a base64 data URI (avoids exposing internal MinIO URLs)
async function resolveLogoForClient(rapport: any): Promise<string> {
try {
const logoUrl = rapport.data?.logoUrl
if (logoUrl && logoUrl.startsWith('data:')) return logoUrl
const tenantId = rapport.tenantId
if (!tenantId) return ''
const tenant = await (prisma as any).tenant.findUnique({
where: { id: tenantId },
select: { logoFileKey: true, logoUrl: true },
})
let fileKey = tenant?.logoFileKey
if (!fileKey && tenant?.logoUrl) {
const match = tenant.logoUrl.match(/logos\/[^?]+/)
if (match) fileKey = match[0]
}
if (!fileKey) return ''
const { getFileStream } = await import('@/lib/minio')
const { stream, contentType } = await getFileStream(fileKey)
const chunks: Buffer[] = []
for await (const chunk of stream as AsyncIterable<Buffer>) {
chunks.push(chunk)
}
const buffer = Buffer.concat(chunks)
return `data:${contentType};base64,${buffer.toString('base64')}`
} catch (e) {
console.warn('[Rapport] Could not resolve logo:', e)
return ''
}
}
// GET: Public access to rapport by token (no auth required)
export async function GET(req: NextRequest, { params }: { params: { token: string } }) {
try {
const rapport = await (prisma as any).rapport.findUnique({
where: { token: params.token },
include: {
project: { select: { title: true, location: true } },
tenant: { select: { name: true } },
createdBy: { select: { name: true } },
},
})
if (!rapport) {
return NextResponse.json({ error: 'Rapport nicht gefunden' }, { status: 404 })
}
// Resolve logo to data URI so the client never sees internal MinIO URLs
const rapportData = { ...rapport.data }
const resolvedLogo = await resolveLogoForClient(rapport)
if (resolvedLogo) {
rapportData.logoUrl = resolvedLogo
} else if (rapportData.logoUrl && !rapportData.logoUrl.startsWith('data:')) {
rapportData.logoUrl = ''
}
return NextResponse.json({
id: rapport.id,
reportNumber: rapport.reportNumber,
data: rapportData,
generatedAt: rapport.generatedAt,
project: rapport.project,
tenant: rapport.tenant,
createdBy: rapport.createdBy,
})
} catch (error) {
console.error('Error fetching rapport by token:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { sendEmail } from '@/lib/email'
// POST: Send rapport link via email
export async function POST(req: NextRequest, { params }: { params: { token: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const { email } = await req.json()
if (!email) return NextResponse.json({ error: 'E-Mail-Adresse erforderlich' }, { status: 400 })
const rapport = await (prisma as any).rapport.findUnique({
where: { token: params.token },
include: {
tenant: { select: { name: true } },
project: { select: { title: true, location: true } },
},
})
if (!rapport) {
return NextResponse.json({ error: 'Rapport nicht gefunden' }, { status: 404 })
}
const baseUrl = process.env.NEXTAUTH_URL || req.headers.get('origin') || `${req.headers.get('x-forwarded-proto') || 'https'}://${req.headers.get('host')}` || 'http://localhost:3000'
const rapportUrl = `${baseUrl}/rapport/${rapport.token}`
const pdfUrl = `${baseUrl}/api/rapports/${rapport.token}/pdf`
const html = `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: #1a1a1a; color: white; padding: 20px 24px; border-radius: 8px 8px 0 0;">
<h2 style="margin: 0; font-size: 18px;">Einsatzrapport</h2>
<p style="margin: 4px 0 0; font-size: 13px; opacity: 0.8;">${rapport.tenant?.name || ''}</p>
</div>
<div style="border: 1px solid #e5e7eb; border-top: none; padding: 24px; border-radius: 0 0 8px 8px;">
<table style="width: 100%; font-size: 14px; margin-bottom: 16px;">
<tr><td style="color: #6b7280; padding: 4px 0;">Rapport-Nr.</td><td style="font-weight: 600;">${rapport.reportNumber}</td></tr>
<tr><td style="color: #6b7280; padding: 4px 0;">Einsatz</td><td style="font-weight: 600;">${rapport.project?.title || '—'}</td></tr>
<tr><td style="color: #6b7280; padding: 4px 0;">Standort</td><td>${rapport.project?.location || '—'}</td></tr>
<tr><td style="color: #6b7280; padding: 4px 0;">Erstellt</td><td>${new Date(rapport.createdAt).toLocaleString('de-CH')}</td></tr>
</table>
<div style="margin: 20px 0;">
<a href="${rapportUrl}" style="display: inline-block; background: #1a1a1a; color: white; padding: 10px 24px; border-radius: 6px; text-decoration: none; font-weight: 600; font-size: 14px; margin-right: 8px;">
Rapport ansehen
</a>
<a href="${pdfUrl}" style="display: inline-block; background: white; color: #1a1a1a; padding: 10px 24px; border-radius: 6px; text-decoration: none; font-weight: 600; font-size: 14px; border: 1px solid #d1d5db;">
PDF herunterladen
</a>
</div>
<p style="font-size: 12px; color: #9ca3af; margin-top: 16px;">
Dieser Link ist öffentlich zugänglich — keine Anmeldung nötig.<br/>
Gesendet von ${user.name || user.email} via app.lageplan.ch
</p>
</div>
</div>
`
await sendEmail(
email,
`Einsatzrapport ${rapport.reportNumber}${rapport.project?.title || 'Lageplan'}`,
html
)
return NextResponse.json({ success: true, message: `Rapport an ${email} gesendet` })
} catch (error) {
console.error('Error sending rapport email:', error)
return NextResponse.json({ error: 'E-Mail konnte nicht gesendet werden' }, { status: 500 })
}
}

View File

@@ -0,0 +1,140 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isAdmin } from '@/lib/auth'
import QRCode from 'qrcode'
// Helper: create a rapport record and return JSON response
async function createRapport(projectId: string, data: any, tenantId: string, userId: string, req: NextRequest) {
// Einsatz-Nummer: one unique number per project (not per rapport)
const existingRapport = await (prisma as any).rapport.findFirst({
where: { projectId },
orderBy: { createdAt: 'asc' },
select: { reportNumber: true },
})
let reportNumber: string
if (existingRapport?.reportNumber) {
reportNumber = existingRapport.reportNumber
} else {
const year = new Date().getFullYear()
const prefix = `${year}-`
const lastRapport = await (prisma as any).rapport.findFirst({
where: { tenantId, reportNumber: { startsWith: prefix } },
orderBy: { reportNumber: 'desc' },
select: { reportNumber: true },
})
let nextNum = 1
if (lastRapport?.reportNumber) {
const lastNum = parseInt(lastRapport.reportNumber.split('-')[1])
if (!isNaN(lastNum)) nextNum = lastNum + 1
}
reportNumber = `${prefix}${String(nextNum).padStart(4, '0')}`
}
data.reportNumber = reportNumber
const rapport = await (prisma as any).rapport.create({
data: { reportNumber, data, projectId, tenantId, createdById: userId },
})
// Generate QR code
const baseUrl = process.env.NEXTAUTH_URL || req.headers.get('origin') || `${req.headers.get('x-forwarded-proto') || 'https'}://${req.headers.get('host')}` || 'http://localhost:3000'
const rapportUrl = `${baseUrl}/rapport/${rapport.token}`
let qrCodeDataUri = ''
try {
qrCodeDataUri = await QRCode.toDataURL(rapportUrl, { width: 200, margin: 1, color: { dark: '#1a1a1a', light: '#ffffff' } })
} catch (e) {
console.warn('QR code generation failed:', e)
}
if (qrCodeDataUri) {
data.qrCodeUrl = qrCodeDataUri
await (prisma as any).rapport.update({ where: { id: rapport.id }, data: { data } })
}
return NextResponse.json({
id: rapport.id,
reportNumber: rapport.reportNumber,
token: rapport.token,
qrCodeUrl: qrCodeDataUri,
})
}
// GET: List rapports for a project
export async function GET(req: NextRequest) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const { searchParams } = new URL(req.url)
const projectId = searchParams.get('projectId')
if (!projectId) {
return NextResponse.json({ error: 'projectId erforderlich' }, { status: 400 })
}
const rapports = await (prisma as any).rapport.findMany({
where: { projectId },
orderBy: { createdAt: 'desc' },
select: {
id: true,
reportNumber: true,
token: true,
generatedAt: true,
createdAt: true,
createdBy: { select: { name: true } },
},
})
return NextResponse.json({ rapports })
} catch (error) {
console.error('Error fetching rapports:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
// POST: Create a new rapport
export async function POST(req: NextRequest) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
let body: any
try {
body = await req.json()
} catch (parseErr: any) {
console.error('[Rapport] Body parse error:', parseErr?.message)
return NextResponse.json({ error: 'Request zu gross oder ungültig: ' + (parseErr?.message || 'Body konnte nicht gelesen werden') }, { status: 400 })
}
const { projectId, data } = body || {}
if (!projectId || !data) {
console.error('[Rapport] Missing fields — projectId:', !!projectId, 'data:', !!data, 'body keys:', Object.keys(body || {}))
return NextResponse.json({ error: `Felder fehlen (projectId=${!!projectId}, data=${!!data})` }, { status: 400 })
}
// Resolve tenantId: project → user session → membership lookup
const project = await (prisma as any).project.findUnique({
where: { id: projectId },
select: { tenantId: true },
})
let tenantId = project?.tenantId || user.tenantId || null
if (!tenantId) {
const membership = await (prisma as any).tenantMembership.findFirst({
where: { userId: user.id },
select: { tenantId: true },
})
tenantId = membership?.tenantId || null
}
console.log('[Rapport] Creating rapport — project:', projectId, 'tenant:', tenantId || '(none)')
return await createRapport(projectId, data, tenantId, user.id, req)
} catch (error: any) {
console.error('[Rapport] Error:', error?.message || error)
if (error?.code === 'P2002') {
return NextResponse.json({ error: 'Rapportnummer existiert bereits' }, { status: 409 })
}
return NextResponse.json({ error: error?.message || 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
// GET: Public app settings (no auth required, non-sensitive values only)
export async function GET() {
try {
let defaultSymbolScale = 1.5
try {
const setting = await (prisma as any).systemSetting.findUnique({ where: { key: 'default_symbol_scale' } })
if (setting) defaultSymbolScale = parseFloat(setting.value) || 1.5
} catch {}
return NextResponse.json({ defaultSymbolScale })
} catch {
return NextResponse.json({ defaultSymbolScale: 1.5 })
}
}

View File

@@ -0,0 +1,102 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
// POST: Tenant self-deletion — TENANT_ADMIN deletes own organization with all data
export async function POST(req: NextRequest) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const body = await req.json()
const { confirmText } = body
if (!user.tenantId) {
return NextResponse.json({ error: 'Kein Mandant zugeordnet' }, { status: 400 })
}
// Verify user is TENANT_ADMIN
if (user.role !== 'TENANT_ADMIN' && user.role !== 'SERVER_ADMIN') {
return NextResponse.json({ error: 'Nur der Organisations-Administrator kann die Organisation löschen' }, { status: 403 })
}
// Get tenant info for confirmation
const tenant = await (prisma as any).tenant.findUnique({
where: { id: user.tenantId },
select: { id: true, name: true, slug: true },
})
if (!tenant) {
return NextResponse.json({ error: 'Mandant nicht gefunden' }, { status: 404 })
}
// Require confirmation text to match tenant name
if (confirmText !== tenant.name) {
return NextResponse.json({ error: `Bitte geben Sie "${tenant.name}" zur Bestätigung ein` }, { status: 400 })
}
console.log(`[Tenant Delete] User ${user.id} deleting tenant ${tenant.id} (${tenant.name})`)
// Delete everything in order (respecting foreign keys)
// 1. Rapports
await (prisma as any).rapport.deleteMany({ where: { tenantId: tenant.id } })
// 2. Journal entries (via projects)
const projectIds = await (prisma as any).project.findMany({
where: { tenantId: tenant.id },
select: { id: true },
})
const pIds = projectIds.map((p: any) => p.id)
if (pIds.length > 0) {
await (prisma as any).journalEntry.deleteMany({ where: { projectId: { in: pIds } } })
await (prisma as any).journalCheckItem.deleteMany({ where: { projectId: { in: pIds } } })
await (prisma as any).journalPendenz.deleteMany({ where: { projectId: { in: pIds } } })
}
// 3. Projects
await (prisma as any).project.deleteMany({ where: { tenantId: tenant.id } })
// 4. Icon assets & categories
await (prisma as any).iconAsset.deleteMany({ where: { tenantId: tenant.id } })
await (prisma as any).iconCategory.deleteMany({ where: { tenantId: tenant.id } })
// 5. Hose types, check templates, dictionary entries
try { await (prisma as any).hoseType.deleteMany({ where: { tenantId: tenant.id } }) } catch {}
try { await (prisma as any).journalCheckTemplate.deleteMany({ where: { tenantId: tenant.id } }) } catch {}
try { await (prisma as any).dictionaryEntry.deleteMany({ where: { tenantId: tenant.id } }) } catch {}
try { await (prisma as any).upgradeRequest.deleteMany({ where: { tenantId: tenant.id } }) } catch {}
// 6. Get all user IDs from memberships
const memberships = await (prisma as any).tenantMembership.findMany({
where: { tenantId: tenant.id },
select: { userId: true },
})
const userIds = memberships.map((m: any) => m.userId)
// 7. Delete memberships
await (prisma as any).tenantMembership.deleteMany({ where: { tenantId: tenant.id } })
// 8. Delete users that have no other memberships
for (const uid of userIds) {
const otherMemberships = await (prisma as any).tenantMembership.count({
where: { userId: uid },
})
if (otherMemberships === 0) {
try {
await (prisma as any).user.delete({ where: { id: uid } })
} catch (e) {
console.warn(`[Tenant Delete] Could not delete user ${uid}:`, e)
}
}
}
// 9. Delete tenant itself
await (prisma as any).tenant.delete({ where: { id: tenant.id } })
console.log(`[Tenant Delete] Tenant ${tenant.name} (${tenant.id}) fully deleted`)
return NextResponse.json({ success: true, message: 'Organisation und alle Daten wurden gelöscht' })
} catch (error: any) {
console.error('[Tenant Delete] Error:', error?.message || error)
return NextResponse.json({ error: error?.message || 'Löschung fehlgeschlagen' }, { status: 500 })
}
}

View File

@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
export async function GET(req: NextRequest) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (!user.tenantId) {
return NextResponse.json({ tenant: null })
}
const tenant = await (prisma as any).tenant.findUnique({
where: { id: user.tenantId },
select: {
id: true,
name: true,
slug: true,
plan: true,
subscriptionStatus: true,
contactEmail: true,
privacyAccepted: true,
privacyAcceptedAt: true,
adminAccessAccepted: true,
createdAt: true,
_count: {
select: {
memberships: true,
projects: true,
},
},
},
})
return NextResponse.json({ tenant })
} catch (error: any) {
console.error('[Tenant Info] Error:', error?.message)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,77 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isAdmin } from '@/lib/auth'
// GET: Fetch journal suggestions for a tenant (global + tenant dictionary merged)
export async function GET(
req: NextRequest,
{ params }: { params: { tenantId: string } }
) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
// Fetch from new Dictionary model (global + tenant)
const [globalWords, tenantWords, tenant] = await Promise.all([
(prisma as any).dictionaryEntry.findMany({
where: { scope: 'GLOBAL' },
select: { word: true },
}).catch(() => []),
(prisma as any).dictionaryEntry.findMany({
where: { scope: 'TENANT', tenantId: params.tenantId },
select: { word: true },
}).catch(() => []),
(prisma as any).tenant.findUnique({
where: { id: params.tenantId },
select: { journalSuggestions: true },
}),
])
// Merge: dictionary entries + legacy journalSuggestions (backward compat)
const wordSet = new Set<string>()
for (const w of tenantWords) wordSet.add(w.word)
for (const w of globalWords) wordSet.add(w.word)
if (tenant?.journalSuggestions) {
for (const s of tenant.journalSuggestions) wordSet.add(s)
}
const suggestions = Array.from(wordSet).sort((a, b) => a.localeCompare(b, 'de'))
return NextResponse.json({ suggestions })
} catch (error) {
console.error('Error fetching suggestions:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
// PUT: Replace all journal suggestions for a tenant (admin only)
export async function PUT(
req: NextRequest,
{ params }: { params: { tenantId: string } }
) {
try {
const user = await getSession()
if (!user || !isAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
// TENANT_ADMIN can only edit their own tenant
if (user.role !== 'SERVER_ADMIN' && user.tenantId !== params.tenantId) {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const body = await req.json()
const suggestions: string[] = Array.isArray(body.suggestions)
? body.suggestions.filter((s: any) => typeof s === 'string' && s.trim()).map((s: string) => s.trim())
: []
await (prisma as any).tenant.update({
where: { id: params.tenantId },
data: { journalSuggestions: suggestions },
})
return NextResponse.json({ suggestions })
} catch (error) {
console.error('Error updating suggestions:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
// Public endpoint: get tenant info by slug (logo, name)
export async function GET(
req: NextRequest,
{ params }: { params: { slug: string } }
) {
try {
const tenant = await (prisma as any).tenant.findUnique({
where: { slug: params.slug },
select: {
id: true,
name: true,
slug: true,
logoUrl: true,
isActive: true,
},
})
if (!tenant) {
return NextResponse.json({ error: 'Mandant nicht gefunden' }, { status: 404 })
}
if (!tenant.isActive) {
return NextResponse.json({
error: 'Mandant gesperrt',
reason: 'suspended',
tenant: { name: tenant.name, slug: tenant.slug, logoUrl: tenant.logoUrl },
}, { status: 403 })
}
return NextResponse.json({
tenant: {
name: tenant.name,
slug: tenant.slug,
logoUrl: tenant.logoUrl,
},
})
} catch (error) {
console.error('Error fetching tenant by slug:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,185 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isServerAdmin } from '@/lib/auth'
import { sendEmail, getSmtpConfig } from '@/lib/email'
import { z } from 'zod'
const processSchema = z.object({
action: z.enum(['approve', 'reject']),
adminNote: z.string().max(500).optional(),
// Only for approve: override limits
maxUsers: z.number().min(1).max(1000).optional(),
maxProjects: z.number().min(1).max(10000).optional(),
})
// PATCH: Approve or reject an upgrade request (SERVER_ADMIN only)
export async function PATCH(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const body = await req.json()
const validated = processSchema.safeParse(body)
if (!validated.success) {
return NextResponse.json({ error: 'Ungültige Eingabe', details: validated.error.flatten() }, { status: 400 })
}
// Get the request
const upgradeReq = await (prisma as any).upgradeRequest.findUnique({
where: { id: params.id },
include: {
tenant: { select: { id: true, name: true, plan: true, contactEmail: true } },
requestedBy: { select: { name: true, email: true } },
},
})
if (!upgradeReq) {
return NextResponse.json({ error: 'Anfrage nicht gefunden' }, { status: 404 })
}
if (upgradeReq.status !== 'PENDING') {
return NextResponse.json({ error: 'Anfrage wurde bereits bearbeitet' }, { status: 400 })
}
const planLabels: Record<string, string> = {
FREE: 'Free', PRO: 'Pro',
}
const planLimits: Record<string, { maxUsers: number; maxProjects: number }> = {
FREE: { maxUsers: 5, maxProjects: 10 },
PRO: { maxUsers: 999, maxProjects: 9999 },
}
if (validated.data.action === 'approve') {
const limits = planLimits[upgradeReq.requestedPlan] || planLimits.PRO
// Update tenant plan
await (prisma as any).tenant.update({
where: { id: upgradeReq.tenantId },
data: {
plan: upgradeReq.requestedPlan,
subscriptionStatus: 'ACTIVE',
trialEndsAt: null,
maxUsers: validated.data.maxUsers || limits.maxUsers,
maxProjects: validated.data.maxProjects || limits.maxProjects,
},
})
// Update request status
await (prisma as any).upgradeRequest.update({
where: { id: params.id },
data: {
status: 'APPROVED',
adminNote: validated.data.adminNote || null,
processedAt: new Date(),
processedById: user.id,
},
})
// Send approval email to requester
const smtpConfig = await getSmtpConfig()
if (smtpConfig) {
try {
await sendEmail(
upgradeReq.requestedBy.email,
`Upgrade bestätigt — ${planLabels[upgradeReq.requestedPlan]} Plan aktiviert`,
`
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 520px; margin: 0 auto;">
<div style="background: #16a34a; color: white; padding: 16px 24px; border-radius: 8px 8px 0 0;">
<h2 style="margin: 0; font-size: 18px;">✓ Upgrade bestätigt</h2>
</div>
<div style="border: 1px solid #e5e7eb; border-top: none; padding: 24px; border-radius: 0 0 8px 8px;">
<p style="margin: 0 0 16px; line-height: 1.6; color: #374151;">
Ihr Upgrade für <strong>${upgradeReq.tenant.name}</strong> wurde bestätigt und ist ab sofort aktiv.
</p>
<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
<tr>
<td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Neuer Plan</td>
<td style="padding: 8px 0; font-weight: 600; text-align: right; color: #16a34a;">${planLabels[upgradeReq.requestedPlan]}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Max. Benutzer</td>
<td style="padding: 8px 0; font-weight: 600; text-align: right;">${validated.data.maxUsers || limits.maxUsers}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Max. Projekte</td>
<td style="padding: 8px 0; font-weight: 600; text-align: right;">${validated.data.maxProjects || limits.maxProjects}</td>
</tr>
</table>
${validated.data.adminNote ? `<p style="margin: 16px 0 0; padding: 12px; background: #f0fdf4; border-radius: 6px; font-size: 14px; color: #374151;"><strong>Hinweis:</strong><br/>${validated.data.adminNote}</p>` : ''}
<p style="margin: 16px 0 0; font-size: 13px; color: #6b7280;">
Vielen Dank für Ihr Vertrauen. Bei Fragen stehen wir Ihnen gerne zur Verfügung.
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 20px 0;" />
<p style="margin: 0; font-size: 12px; color: #9ca3af;">Lageplan — Digitale Lagepläne für die Feuerwehr</p>
</div>
</div>
`
)
} catch (e) {
console.error('Failed to send approval email:', e)
}
}
} else {
// Reject
await (prisma as any).upgradeRequest.update({
where: { id: params.id },
data: {
status: 'REJECTED',
adminNote: validated.data.adminNote || null,
processedAt: new Date(),
processedById: user.id,
},
})
// Send rejection email
const smtpConfig = await getSmtpConfig()
if (smtpConfig) {
try {
await sendEmail(
upgradeReq.requestedBy.email,
`Upgrade-Anfrage — Rückmeldung`,
`
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 520px; margin: 0 auto;">
<div style="background: #6b7280; color: white; padding: 16px 24px; border-radius: 8px 8px 0 0;">
<h2 style="margin: 0; font-size: 18px;">Upgrade-Anfrage</h2>
</div>
<div style="border: 1px solid #e5e7eb; border-top: none; padding: 24px; border-radius: 0 0 8px 8px;">
<p style="margin: 0 0 16px; line-height: 1.6; color: #374151;">
Ihre Upgrade-Anfrage für <strong>${upgradeReq.tenant.name}</strong> auf den <strong>${planLabels[upgradeReq.requestedPlan]}</strong>-Plan konnte leider nicht bestätigt werden.
</p>
${validated.data.adminNote ? `<p style="margin: 0 0 16px; padding: 12px; background: #f9fafb; border-radius: 6px; font-size: 14px; color: #374151;"><strong>Begründung:</strong><br/>${validated.data.adminNote}</p>` : ''}
<p style="margin: 0; font-size: 13px; color: #6b7280;">
Bei Fragen kontaktieren Sie uns bitte unter app@lageplan.ch. Sie können jederzeit eine neue Anfrage stellen.
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 20px 0;" />
<p style="margin: 0; font-size: 12px; color: #9ca3af;">Lageplan — Digitale Lagepläne für die Feuerwehr</p>
</div>
</div>
`
)
} catch (e) {
console.error('Failed to send rejection email:', e)
}
}
}
// Return updated request
const updated = await (prisma as any).upgradeRequest.findUnique({
where: { id: params.id },
include: {
tenant: { select: { name: true, slug: true, plan: true, subscriptionStatus: true } },
requestedBy: { select: { name: true, email: true } },
},
})
return NextResponse.json({ request: updated })
} catch (error) {
console.error('Error processing upgrade request:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,204 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { sendEmail, getSmtpConfig } from '@/lib/email'
import { z } from 'zod'
const upgradeSchema = z.object({
requestedPlan: z.enum(['PRO']),
message: z.string().max(1000).optional(),
})
// GET: List upgrade requests for current tenant (TENANT_ADMIN) or all (SERVER_ADMIN)
export async function GET() {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
let where: any = {}
if (user.role === 'SERVER_ADMIN') {
// Server admin sees all
} else if (user.role === 'TENANT_ADMIN' && user.tenantId) {
where = { tenantId: user.tenantId }
} else {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const requests = await (prisma as any).upgradeRequest.findMany({
where,
include: {
tenant: { select: { name: true, slug: true, plan: true, subscriptionStatus: true } },
requestedBy: { select: { name: true, email: true } },
},
orderBy: { createdAt: 'desc' },
})
return NextResponse.json({ requests })
} catch (error) {
console.error('Error fetching upgrade requests:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
// POST: Create a new upgrade request (TENANT_ADMIN only)
export async function POST(req: NextRequest) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role !== 'TENANT_ADMIN' || !user.tenantId) {
return NextResponse.json({ error: 'Nur Mandanten-Administratoren können Upgrades anfordern' }, { status: 403 })
}
const body = await req.json()
const validated = upgradeSchema.safeParse(body)
if (!validated.success) {
return NextResponse.json({ error: 'Ungültige Eingabe', details: validated.error.flatten() }, { status: 400 })
}
// Get current tenant
const tenant = await (prisma as any).tenant.findUnique({
where: { id: user.tenantId },
select: { id: true, name: true, plan: true, contactEmail: true },
})
if (!tenant) {
return NextResponse.json({ error: 'Mandant nicht gefunden' }, { status: 404 })
}
// Check for existing pending request
const existingPending = await (prisma as any).upgradeRequest.findFirst({
where: { tenantId: user.tenantId, status: 'PENDING' },
})
if (existingPending) {
return NextResponse.json({
error: 'Es gibt bereits eine offene Upgrade-Anfrage. Bitte warten Sie auf die Bearbeitung.',
}, { status: 409 })
}
// Don't allow "downgrade" requests or same plan
const planOrder = { FREE: 0, PRO: 1 }
if ((planOrder[validated.data.requestedPlan as keyof typeof planOrder] || 0) <= (planOrder[tenant.plan as keyof typeof planOrder] || 0)) {
return NextResponse.json({ error: 'Der gewählte Plan ist kein Upgrade gegenüber dem aktuellen Plan.' }, { status: 400 })
}
// Create request
const request = await (prisma as any).upgradeRequest.create({
data: {
tenantId: user.tenantId,
requestedById: user.id,
requestedPlan: validated.data.requestedPlan,
currentPlan: tenant.plan,
message: validated.data.message || null,
},
include: {
tenant: { select: { name: true } },
requestedBy: { select: { name: true, email: true } },
},
})
// Send emails
const smtpConfig = await getSmtpConfig()
if (smtpConfig) {
const planLabels: Record<string, string> = {
FREE: 'Free', PRO: 'Pro',
}
// 1. Confirmation to tenant admin
try {
await sendEmail(
user.email,
`Upgrade-Anfrage bestätigt — ${planLabels[validated.data.requestedPlan]}`,
`
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 520px; margin: 0 auto;">
<div style="background: #2563eb; color: white; padding: 16px 24px; border-radius: 8px 8px 0 0;">
<h2 style="margin: 0; font-size: 18px;">Upgrade-Anfrage eingegangen</h2>
</div>
<div style="border: 1px solid #e5e7eb; border-top: none; padding: 24px; border-radius: 0 0 8px 8px;">
<p style="margin: 0 0 16px; line-height: 1.6; color: #374151;">
Ihre Upgrade-Anfrage für <strong>${tenant.name}</strong> wurde erfolgreich übermittelt.
</p>
<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
<tr>
<td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Aktueller Plan</td>
<td style="padding: 8px 0; font-weight: 600; text-align: right;">${planLabels[tenant.plan] || tenant.plan}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Gewünschter Plan</td>
<td style="padding: 8px 0; font-weight: 600; text-align: right; color: #2563eb;">${planLabels[validated.data.requestedPlan]}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Status</td>
<td style="padding: 8px 0; font-weight: 600; text-align: right;">
<span style="background: #fef3c7; color: #92400e; padding: 2px 8px; border-radius: 4px; font-size: 12px;">Wird geprüft</span>
</td>
</tr>
</table>
${validated.data.message ? `<p style="margin: 16px 0 0; padding: 12px; background: #f9fafb; border-radius: 6px; font-size: 14px; color: #374151;"><strong>Ihre Nachricht:</strong><br/>${validated.data.message.replace(/\n/g, '<br/>')}</p>` : ''}
<p style="margin: 16px 0 0; font-size: 13px; color: #6b7280;">
Wir werden Ihre Anfrage so schnell wie möglich bearbeiten. Sie erhalten eine Benachrichtigung, sobald Ihr Plan aktiviert wurde.
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 20px 0;" />
<p style="margin: 0; font-size: 12px; color: #9ca3af;">Lageplan — Digitale Lagepläne für die Feuerwehr</p>
</div>
</div>
`
)
} catch (e) {
console.error('Failed to send upgrade confirmation email:', e)
}
// 2. Notification to all server admins
try {
const serverAdmins = await (prisma as any).user.findMany({
where: { role: 'SERVER_ADMIN' },
select: { email: true, name: true },
})
for (const admin of serverAdmins) {
await sendEmail(
admin.email,
`Neue Upgrade-Anfrage: ${tenant.name}${planLabels[validated.data.requestedPlan]}`,
`
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 520px; margin: 0 auto;">
<div style="background: #dc2626; color: white; padding: 16px 24px; border-radius: 8px 8px 0 0;">
<h2 style="margin: 0; font-size: 18px;">Neue Upgrade-Anfrage</h2>
</div>
<div style="border: 1px solid #e5e7eb; border-top: none; padding: 24px; border-radius: 0 0 8px 8px;">
<table style="width: 100%; border-collapse: collapse; margin: 0 0 16px;">
<tr>
<td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Organisation</td>
<td style="padding: 8px 0; font-weight: 600; text-align: right;">${tenant.name}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Angefragt von</td>
<td style="padding: 8px 0; font-weight: 600; text-align: right;">${user.name} (${user.email})</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Aktueller Plan</td>
<td style="padding: 8px 0; text-align: right;">${planLabels[tenant.plan] || tenant.plan}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Gewünschter Plan</td>
<td style="padding: 8px 0; font-weight: 600; text-align: right; color: #2563eb;">${planLabels[validated.data.requestedPlan]}</td>
</tr>
</table>
${validated.data.message ? `<p style="margin: 0 0 16px; padding: 12px; background: #f9fafb; border-radius: 6px; font-size: 14px; color: #374151;"><strong>Nachricht:</strong><br/>${validated.data.message.replace(/\n/g, '<br/>')}</p>` : ''}
<p style="margin: 0; font-size: 13px; color: #6b7280;">
Bitte prüfen und bestätigen Sie die Anfrage im Admin-Panel unter "Upgrade-Anfragen".
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 20px 0;" />
<p style="margin: 0; font-size: 12px; color: #9ca3af;">Lageplan — Automatische Benachrichtigung</p>
</div>
</div>
`
)
}
} catch (e) {
console.error('Failed to send admin notification email:', e)
}
}
return NextResponse.json({ request }, { status: 201 })
} catch (error) {
console.error('Error creating upgrade request:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

1384
src/app/app/page.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,187 @@
'use client'
import Link from 'next/link'
import { ArrowLeft, Shield, Server, Trash2, Mail } from 'lucide-react'
export default function DatenschutzPage() {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 px-4 py-12">
<div className="max-w-3xl mx-auto">
<Link href="/" className="text-sm text-gray-400 hover:text-gray-300 inline-flex items-center gap-1 mb-8">
<ArrowLeft className="w-3.5 h-3.5" />
Zurück zur Startseite
</Link>
<div className="bg-card rounded-xl shadow-2xl p-8 md:p-12 border border-border">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 bg-red-600 rounded-full flex items-center justify-center">
<Shield className="w-5 h-5 text-white" />
</div>
<h1 className="text-3xl font-bold text-foreground">Datenschutzerklärung</h1>
</div>
<p className="text-muted-foreground mb-8">
Gültig ab 1. Januar 2026 Lageplan.ch, betrieben in der Schweiz.
</p>
{/* 1. Verantwortlicher */}
<section className="mb-8">
<h2 className="text-xl font-semibold text-foreground mb-3 flex items-center gap-2">
<span className="text-red-500 font-mono text-sm">01</span>
Verantwortlicher
</h2>
<p className="text-muted-foreground text-sm leading-relaxed">
Verantwortlich für die Datenverarbeitung auf lageplan.ch ist der Betreiber der Plattform.
Bei Fragen zum Datenschutz wenden Sie sich bitte an die im Impressum angegebene Kontaktadresse
oder per E-Mail an <span className="text-foreground font-medium">datenschutz@lageplan.ch</span>.
</p>
</section>
{/* 2. Welche Daten */}
<section className="mb-8">
<h2 className="text-xl font-semibold text-foreground mb-3 flex items-center gap-2">
<span className="text-red-500 font-mono text-sm">02</span>
Welche Daten werden erhoben?
</h2>
<div className="space-y-3 text-sm text-muted-foreground leading-relaxed">
<p><strong className="text-foreground">Registrierungsdaten:</strong> Name, E-Mail-Adresse, Organisationsname, Passwort (verschlüsselt gespeichert).</p>
<p><strong className="text-foreground">Einsatzdaten:</strong> Lagepläne, Journal-Einträge, Zeichnungen, Koordinaten, Symbole und zugehörige Projektdaten, die Sie in der Applikation erstellen.</p>
<p><strong className="text-foreground">Technische Daten:</strong> IP-Adresse, Browser-Typ, Zugriffszeitpunkt ausschliesslich für den Betrieb und die Sicherheit der Plattform.</p>
<p><strong className="text-foreground">Hochgeladene Dateien:</strong> Logos, Planbilder und Symbole, die Sie über die Applikation hochladen.</p>
</div>
</section>
{/* 3. Zweck */}
<section className="mb-8">
<h2 className="text-xl font-semibold text-foreground mb-3 flex items-center gap-2">
<span className="text-red-500 font-mono text-sm">03</span>
Zweck der Datenverarbeitung
</h2>
<div className="space-y-2 text-sm text-muted-foreground leading-relaxed">
<p>Ihre Daten werden ausschliesslich für folgende Zwecke verwendet:</p>
<ul className="list-disc pl-5 space-y-1">
<li>Bereitstellung und Betrieb der Lageplan-Applikation</li>
<li>Authentifizierung und Benutzerverwaltung</li>
<li>Echtzeit-Synchronisierung von Einsatzdaten zwischen Teammitgliedern</li>
<li>Erstellung von Einsatzrapports und PDF-Exporten</li>
<li>Technischer Support und Fehlerbehebung</li>
</ul>
</div>
</section>
{/* 4. Betrieb und Wartung */}
<section className="mb-8">
<h2 className="text-xl font-semibold text-foreground mb-3 flex items-center gap-2">
<span className="text-red-500 font-mono text-sm">04</span>
Betrieb, Wartung und Support
</h2>
<div className="space-y-3 text-sm text-muted-foreground leading-relaxed">
<p>
Für den sicheren und zuverlässigen Betrieb der Plattform können autorisierte Mitarbeitende
des Betreibers im Rahmen ihrer Aufgaben auf Systemdaten zugreifen. Dies geschieht ausschliesslich zu folgenden Zwecken:
</p>
<ul className="list-disc pl-5 space-y-1">
<li>Sicherstellung der Systemverfügbarkeit und -stabilität</li>
<li>Fehlerbehebung und technischer Support auf Anfrage</li>
<li>Durchführung von Wartungs- und Aktualisierungsarbeiten</li>
<li>Gewährleistung der Datensicherheit und Missbrauchsprävention</li>
</ul>
<p>
Der Zugriff erfolgt unter Einhaltung strikter Vertraulichkeitspflichten und wird auf das
betrieblich notwendige Minimum beschränkt. Personenbezogene Daten werden nicht an Dritte weitergegeben
und ausschliesslich im Rahmen der oben genannten Zwecke verarbeitet.
</p>
</div>
</section>
{/* 5. Hosting & Speicherung */}
<section className="mb-8">
<h2 className="text-xl font-semibold text-foreground mb-3 flex items-center gap-2">
<Server className="w-4 h-4 text-red-500" />
<span><span className="text-red-500 font-mono text-sm mr-2">05</span>Hosting und Speicherung</span>
</h2>
<div className="space-y-2 text-sm text-muted-foreground leading-relaxed">
<p>
Alle Daten werden auf Servern in der <strong className="text-foreground">Schweiz</strong> gespeichert.
Es findet keine Übermittlung von Daten ins Ausland statt.
</p>
<p>
Die Daten werden in einer PostgreSQL-Datenbank gespeichert. Dateien (Logos, Bilder) werden in einem
S3-kompatiblen Objektspeicher (MinIO) abgelegt. Die Übertragung erfolgt verschlüsselt via HTTPS/TLS.
</p>
</div>
</section>
{/* 6. Datenlöschung */}
<section className="mb-8">
<h2 className="text-xl font-semibold text-foreground mb-3 flex items-center gap-2">
<Trash2 className="w-4 h-4 text-red-500" />
<span><span className="text-red-500 font-mono text-sm mr-2">06</span>Datenlöschung und Konto-Auflösung</span>
</h2>
<div className="space-y-2 text-sm text-muted-foreground leading-relaxed">
<p>
Sie haben jederzeit das Recht, Ihr Konto und Ihre Organisation inklusive aller zugehörigen Daten
vollständig zu löschen. Dies können Sie selbstständig über die Applikation durchführen
(Einstellungen Organisation löschen).
</p>
<p>
Bei der Löschung werden sämtliche Daten unwiderruflich entfernt: Benutzerkonten, Projekte,
Lagepläne, Journal-Einträge, Rapports, hochgeladene Dateien und alle zugehörigen Metadaten.
</p>
<p>
Alternativ können Sie die Löschung per E-Mail an <span className="text-foreground font-medium">datenschutz@lageplan.ch</span> beantragen.
</p>
</div>
</section>
{/* 7. Rechte */}
<section className="mb-8">
<h2 className="text-xl font-semibold text-foreground mb-3 flex items-center gap-2">
<span className="text-red-500 font-mono text-sm">07</span>
Ihre Rechte
</h2>
<div className="space-y-2 text-sm text-muted-foreground leading-relaxed">
<p>Gemäss dem Schweizer Datenschutzgesetz (DSG) und der DSGVO haben Sie folgende Rechte:</p>
<ul className="list-disc pl-5 space-y-1">
<li><strong className="text-foreground">Auskunftsrecht:</strong> Sie können Auskunft über Ihre gespeicherten Daten verlangen.</li>
<li><strong className="text-foreground">Berichtigungsrecht:</strong> Sie können die Korrektur unrichtiger Daten verlangen.</li>
<li><strong className="text-foreground">Löschungsrecht:</strong> Sie können die Löschung Ihrer Daten verlangen.</li>
<li><strong className="text-foreground">Datenportabilität:</strong> Sie können Ihre Daten in einem gängigen Format anfordern.</li>
<li><strong className="text-foreground">Widerspruchsrecht:</strong> Sie können der Verarbeitung Ihrer Daten widersprechen.</li>
</ul>
</div>
</section>
{/* 8. Cookies */}
<section className="mb-8">
<h2 className="text-xl font-semibold text-foreground mb-3 flex items-center gap-2">
<span className="text-red-500 font-mono text-sm">08</span>
Cookies
</h2>
<p className="text-sm text-muted-foreground leading-relaxed">
Die Applikation verwendet ausschliesslich <strong className="text-foreground">technisch notwendige Cookies</strong> für
die Authentifizierung (Session-Token). Es werden keine Tracking-, Analyse- oder Werbe-Cookies eingesetzt.
Es werden keine Daten an Dritte weitergegeben.
</p>
</section>
{/* 9. Kontakt */}
<section className="mb-4">
<h2 className="text-xl font-semibold text-foreground mb-3 flex items-center gap-2">
<Mail className="w-4 h-4 text-red-500" />
<span><span className="text-red-500 font-mono text-sm mr-2">09</span>Kontakt</span>
</h2>
<p className="text-sm text-muted-foreground leading-relaxed">
Bei Fragen oder Anliegen zum Datenschutz kontaktieren Sie uns unter:{' '}
<span className="text-foreground font-medium">datenschutz@lageplan.ch</span>
</p>
</section>
</div>
<div className="text-center mt-6 text-xs text-gray-500">
© {new Date().getFullYear()} Lageplan.ch Alle Rechte vorbehalten.
</div>
</div>
</div>
)
}

610
src/app/demo/page.tsx Normal file
View File

@@ -0,0 +1,610 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import {
Flame, Loader2, MapPin, Save, MoreVertical, Clock,
Moon, Maximize, ClipboardList, User, LogOut,
MousePointer2, CircleDot, Minus, Pentagon, Square,
Circle, Pencil, MoveRight, Type, Eraser, Ruler,
Undo2, Redo2, Search, Map, Target,
Droplets, AlertTriangle, Car, Users, Truck, Building, Upload,
} from 'lucide-react'
interface DemoProject {
id: string
title: string
location?: string
description?: string
einsatzleiter?: string
journalfuehrer?: string
mapCenter: { lng: number; lat: number }
mapZoom: number
}
interface DemoJournalEntry {
id: string
time: string
what: string
who: string | null
done: boolean
isCorrected?: boolean
}
interface DemoJournalCheckItem {
id: string
label: string
confirmed: boolean
ok: boolean
}
interface DemoJournalPendenz {
id: string
what: string
who: string | null
done: boolean
}
interface DemoFeature {
id: string
type: string
geometry: { type: string; coordinates: any }
properties: Record<string, any>
}
interface DemoCategory {
id: string
name: string
symbols: { id: string; name: string; imageUrl: string }[]
}
/* ── Static data for demo toolbar ── */
const drawTools = [
{ icon: MousePointer2, label: 'Auswählen', active: true },
{ icon: CircleDot, label: 'Punkt' },
{ icon: Minus, label: 'Linie' },
{ icon: Pentagon, label: 'Polygon' },
{ icon: Square, label: 'Rechteck' },
{ icon: Circle, label: 'Kreis' },
{ icon: Pencil, label: 'Freihand' },
{ icon: MoveRight, label: 'Pfeil / Route' },
{ icon: Type, label: 'Text' },
{ icon: Eraser, label: 'Radiergummi' },
{ icon: Ruler, label: 'Messen' },
]
const colors = ['#ef4444','#f97316','#eab308','#22c55e','#3b82f6','#8b5cf6','#000000','#ffffff']
const categoryIcons: Record<string, typeof Flame> = {
Feuer: Flame, Wasser: Droplets, Gefahren: AlertTriangle, Gefahrstoffe: AlertTriangle,
Verkehr: Car, Personen: Users, Fahrzeuge: Truck, Infrastruktur: Building,
Taktik: Target, Eigene: Upload,
}
export default function DemoPage() {
const mapContainer = useRef<HTMLDivElement | null>(null)
const map = useRef<maplibregl.Map | null>(null)
const markersRef = useRef<maplibregl.Marker[]>([])
const [project, setProject] = useState<DemoProject | null>(null)
const [features, setFeatures] = useState<DemoFeature[]>([])
const [journalEntries, setJournalEntries] = useState<DemoJournalEntry[]>([])
const [journalCheckItems, setJournalCheckItems] = useState<DemoJournalCheckItem[]>([])
const [journalPendenzen, setJournalPendenzen] = useState<DemoJournalPendenz[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState('')
const [isMapLoaded, setIsMapLoaded] = useState(false)
const [categories, setCategories] = useState<DemoCategory[]>([])
const [activeCategory, setActiveCategory] = useState('')
const [searchQuery, setSearchQuery] = useState('')
const [activeTab, setActiveTab] = useState<'map' | 'journal'>('map')
const [now, setNow] = useState(new Date())
// Live clock
useEffect(() => {
const t = setInterval(() => setNow(new Date()), 1000)
return () => clearInterval(t)
}, [])
// Fetch demo data + icons
useEffect(() => {
async function load() {
try {
const [demoRes, iconsRes] = await Promise.all([
fetch('/api/demo'),
fetch('/api/icons'),
])
if (demoRes.ok) {
const d = await demoRes.json()
setProject(d.project)
setFeatures(d.features || [])
if (d.journal) {
setJournalEntries(d.journal.entries || [])
setJournalCheckItems(d.journal.checkItems || [])
setJournalPendenzen(d.journal.pendenzen || [])
}
} else {
setError('Keine Demo verfügbar')
}
if (iconsRes.ok) {
const d = await iconsRes.json()
const cats: DemoCategory[] = (d.categories || [])
.filter((c: any) => c.icons?.length > 0)
.map((c: any) => ({
id: c.id,
name: c.name,
symbols: c.icons.map((i: any) => ({
id: i.id,
name: i.name,
imageUrl: i.url || `/api/icons/${i.id}/image`,
})),
}))
setCategories(cats)
if (cats.length > 0) setActiveCategory(cats[0].id)
}
} catch {
setError('Demo konnte nicht geladen werden')
} finally {
setIsLoading(false)
}
}
load()
}, [])
// Initialize map
useEffect(() => {
if (!mapContainer.current || !project || map.current) return
const m = new maplibregl.Map({
container: mapContainer.current,
style: {
version: 8,
sources: {
osm: {
type: 'raster',
tiles: [
'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png',
'https://b.tile.openstreetmap.org/{z}/{x}/{y}.png',
'https://c.tile.openstreetmap.org/{z}/{x}/{y}.png',
],
tileSize: 256,
maxzoom: 19,
attribution: '&copy; OpenStreetMap contributors',
},
},
layers: [{ id: 'osm', type: 'raster', source: 'osm' }],
},
center: [project.mapCenter.lng, project.mapCenter.lat],
zoom: project.mapZoom,
maxZoom: 19,
attributionControl: false,
})
m.addControl(new maplibregl.NavigationControl(), 'bottom-right')
m.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-right')
m.on('load', () => {
setIsMapLoaded(true)
// Force resize after load to ensure tiles render correctly
setTimeout(() => m.resize(), 50)
})
map.current = m
// Also resize after initial render in case container dimensions weren't ready
requestAnimationFrame(() => m.resize())
return () => { m.remove(); map.current = null }
}, [project])
// Render features on map
useEffect(() => {
if (!map.current || !isMapLoaded || features.length === 0) return
const m = map.current
markersRef.current.forEach(marker => marker.remove())
markersRef.current = []
if (!m.getSource('demo-features')) {
m.addSource('demo-features', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
})
m.addLayer({
id: 'demo-fill', type: 'fill', source: 'demo-features',
filter: ['==', '$type', 'Polygon'],
paint: { 'fill-color': ['coalesce', ['get', 'color'], '#ef4444'], 'fill-opacity': 0.2 },
})
m.addLayer({
id: 'demo-line', type: 'line', source: 'demo-features',
filter: ['==', '$type', 'LineString'],
paint: { 'line-color': ['coalesce', ['get', 'color'], '#ef4444'], 'line-width': ['coalesce', ['get', 'width'], 3] },
})
m.addLayer({
id: 'demo-outline', type: 'line', source: 'demo-features',
filter: ['==', '$type', 'Polygon'],
paint: { 'line-color': ['coalesce', ['get', 'color'], '#ef4444'], 'line-width': 2 },
})
m.addLayer({
id: 'demo-point', type: 'circle', source: 'demo-features',
filter: ['all', ['==', '$type', 'Point'], ['!=', ['get', 'featureType'], 'symbol'], ['!=', ['get', 'featureType'], 'text']],
paint: { 'circle-radius': 6, 'circle-color': ['coalesce', ['get', 'color'], '#ef4444'], 'circle-stroke-width': 2, 'circle-stroke-color': '#fff' },
})
}
const geoJsonFeatures: any[] = []
features.forEach(f => {
if (f.type === 'symbol' || f.type === 'text') return
geoJsonFeatures.push({ type: 'Feature', geometry: f.geometry, properties: { ...f.properties, featureType: f.type } })
})
const src = m.getSource('demo-features') as maplibregl.GeoJSONSource
if (src) src.setData({ type: 'FeatureCollection', features: geoJsonFeatures })
features.forEach(f => {
if (f.type === 'symbol' && f.geometry.type === 'Point') {
const [lng, lat] = f.geometry.coordinates
const el = document.createElement('div')
const scale = (f.properties.scale as number) || 1
const rotation = (f.properties.rotation as number) || 0
const size = 40 * scale
el.style.width = `${size}px`
el.style.height = `${size}px`
el.style.transform = `rotate(${rotation}deg)`
el.style.pointerEvents = 'none'
const img = document.createElement('img')
img.src = f.properties.imageUrl as string || `/api/icons/${f.properties.iconId}/image`
img.style.width = '100%'
img.style.height = '100%'
img.style.objectFit = 'contain'
img.crossOrigin = 'anonymous'
el.appendChild(img)
const marker = new maplibregl.Marker({ element: el, anchor: 'center' }).setLngLat([lng, lat]).addTo(m)
markersRef.current.push(marker)
}
if (f.type === 'text' && f.geometry.type === 'Point') {
const [lng, lat] = f.geometry.coordinates
const el = document.createElement('div')
el.style.fontSize = `${f.properties.fontSize || 16}px`
el.style.color = (f.properties.color as string) || '#000'
el.style.fontWeight = '700'
el.style.textShadow = '0 0 4px rgba(255,255,255,0.9), 0 0 8px rgba(255,255,255,0.7)'
el.style.whiteSpace = 'nowrap'
el.style.pointerEvents = 'none'
el.textContent = f.properties.text as string || ''
const marker = new maplibregl.Marker({ element: el, anchor: 'center' }).setLngLat([lng, lat]).addTo(m)
markersRef.current.push(marker)
}
})
}, [features, isMapLoaded])
/* ── Derived data ── */
const totalSymbols = categories.reduce((s, c) => s + c.symbols.length, 0)
const currentCat = categories.find(c => c.id === activeCategory)
const filteredSymbols = currentCat?.symbols.filter(s => s.name.toLowerCase().includes(searchQuery.toLowerCase())) || []
/* ── Loading / Error states ── */
if (isLoading) {
return (
<div className="w-full h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<Loader2 className="w-8 h-8 animate-spin text-red-500 mx-auto mb-2" />
<p className="text-sm text-gray-500">Demo wird geladen...</p>
</div>
</div>
)
}
if (error || !project) {
return (
<div className="w-full h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100">
<div className="text-center">
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
<Flame className="w-8 h-8 text-red-500" />
</div>
<h2 className="text-lg font-bold text-gray-900 mb-1">Lageplan Demo</h2>
<p className="text-sm text-gray-500">Demo wird vorbereitet bald verfügbar!</p>
</div>
</div>
)
}
return (
<div className="h-screen w-full flex flex-col bg-background text-foreground overflow-hidden">
{/* ═══════ TOPBAR ═══════ */}
<header className="h-12 border-b border-border bg-card flex items-center justify-between px-2 shrink-0">
{/* Left */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<div className="w-7 h-7 bg-primary rounded-md flex items-center justify-center">
<MapPin className="w-4 h-4 text-primary-foreground" />
</div>
<span className="font-semibold text-sm hidden sm:inline">Lageplan</span>
</div>
<div className="h-5 w-px bg-border hidden sm:block" />
<button className="h-8 px-2 text-xs border border-border rounded-md flex items-center gap-1 bg-background hover:bg-accent transition-colors">
<Save className="w-3.5 h-3.5" />
<span className="hidden md:inline">Speichern</span>
</button>
<button className="h-8 px-2 text-xs border border-border rounded-md flex items-center gap-1 bg-background hover:bg-accent transition-colors">
<MoreVertical className="w-3.5 h-3.5" />
<span className="hidden md:inline">Menü</span>
</button>
</div>
{/* Right */}
<div className="flex items-center gap-1.5">
{/* Project info */}
<div className="hidden lg:flex items-center gap-1.5 text-xs border border-border rounded-md px-2 py-1 bg-muted/30">
<span className="font-semibold truncate max-w-[140px]">{project.title}</span>
{project.location && (
<><span className="text-muted-foreground">|</span><span className="text-muted-foreground truncate max-w-[120px]">{project.location}</span></>
)}
</div>
{/* Clock */}
<div className="hidden md:flex items-center gap-1 text-xs font-medium tabular-nums">
<Clock className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-primary font-bold">{now.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })}</span>
</div>
<button className="h-8 w-8 border border-border rounded-md hidden md:flex items-center justify-center hover:bg-accent transition-colors">
<Moon className="w-3.5 h-3.5" />
</button>
<button className="h-8 w-8 border border-border rounded-md hidden md:flex items-center justify-center hover:bg-accent transition-colors">
<Maximize className="w-3.5 h-3.5" />
</button>
<button className="h-8 w-8 border border-border rounded-md hidden md:flex items-center justify-center hover:bg-accent transition-colors relative">
<ClipboardList className="w-3.5 h-3.5" />
<span className="absolute -top-1 -right-1 w-3.5 h-3.5 bg-primary text-primary-foreground text-[8px] rounded-full flex items-center justify-center font-bold">
{features.length > 0 ? features.length : 0}
</span>
</button>
<div className="hidden md:flex items-center gap-1 ml-1 pl-1.5 border-l border-border">
<User className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground hidden lg:inline">adminroppe</span>
<button className="h-7 w-7 flex items-center justify-center text-muted-foreground hover:text-destructive transition-colors">
<LogOut className="w-3.5 h-3.5" />
</button>
</div>
</div>
</header>
{/* ═══════ MAIN AREA ═══════ */}
<div className="flex flex-1 overflow-hidden">
{/* ── LEFT TOOLBAR ── */}
<aside className="w-11 md:w-14 border-r border-border bg-card flex flex-col items-center py-1.5 shrink-0 overflow-y-auto">
<div className="flex flex-col gap-px">
{drawTools.map((t, i) => (
<button
key={i}
className={`w-8 h-8 md:w-10 md:h-10 rounded-md flex items-center justify-center transition-colors ${
t.active ? 'bg-primary text-primary-foreground' : 'text-foreground hover:bg-accent'
}`}
title={t.label}
>
<t.icon className="w-4 h-4 md:w-5 md:h-5" />
</button>
))}
</div>
<div className="w-6 md:w-8 h-px bg-border my-1" />
<div className="grid grid-cols-2 gap-px">
<button className="w-5 h-5 md:w-6 md:h-6 rounded flex items-center justify-center text-muted-foreground hover:bg-accent transition-colors" title="Rückgängig">
<Undo2 className="w-3.5 h-3.5" />
</button>
<button className="w-5 h-5 md:w-6 md:h-6 rounded flex items-center justify-center text-muted-foreground hover:bg-accent transition-colors" title="Wiederholen">
<Redo2 className="w-3.5 h-3.5" />
</button>
</div>
<div className="w-6 md:w-8 h-px bg-border my-1" />
<div className="grid grid-cols-2 gap-0.5">
{colors.map(c => (
<div
key={c}
className={`w-4 h-4 md:w-5 md:h-5 rounded border-2 ${c === '#ef4444' ? 'border-primary ring-1 ring-primary ring-offset-1 scale-110' : 'border-muted'}`}
style={{ backgroundColor: c }}
/>
))}
</div>
</aside>
{/* ── MAP ── */}
<main className="flex-1 relative overflow-hidden">
<div ref={mapContainer} className="absolute inset-0" />
{/* Satellite / Map toggle */}
<div className="absolute top-2 right-2 z-10 flex rounded-md overflow-hidden border border-border shadow-sm text-xs">
<button className="px-3 py-1.5 bg-muted/80 text-muted-foreground backdrop-blur-sm">Satellit</button>
<button className="px-3 py-1.5 bg-card/90 text-foreground font-semibold backdrop-blur-sm border-l border-border">Karte</button>
</div>
{/* Scale bar */}
<div className="absolute bottom-2 left-2 z-10 flex items-center gap-1.5 text-[10px] text-muted-foreground">
<div className="border-b-2 border-l-2 border-r-2 border-foreground/40 w-16 h-2" />
<span>1:500</span>
</div>
{/* CTA overlay */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10">
<a
href="/register"
className="inline-flex items-center gap-2 bg-red-600 hover:bg-red-700 text-white px-5 py-2.5 rounded-full shadow-lg text-sm font-semibold transition-colors"
>
Kostenlos registrieren & loslegen
</a>
</div>
{/* DEMO badge */}
<div className="absolute top-2 left-2 z-10 bg-red-100 text-red-700 px-2 py-0.5 rounded-full text-[10px] font-bold shadow-sm">
DEMO
</div>
</main>
{/* ── RIGHT SIDEBAR ── */}
<aside className="hidden md:flex w-48 lg:w-56 xl:w-72 border-l border-border bg-card flex-col shrink-0">
{/* Tab switcher */}
<div className="flex border-b border-border shrink-0">
<button
onClick={() => setActiveTab('map')}
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors ${
activeTab === 'map' ? 'bg-primary/10 text-primary border-b-2 border-primary' : 'text-muted-foreground hover:bg-accent'
}`}
>
<Map className="w-3.5 h-3.5" /> Karte
</button>
<button
onClick={() => setActiveTab('journal')}
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors ${
activeTab === 'journal' ? 'bg-primary/10 text-primary border-b-2 border-primary' : 'text-muted-foreground hover:bg-accent'
}`}
>
<ClipboardList className="w-3.5 h-3.5" /> Journal
</button>
</div>
{activeTab === 'map' && (
<>
{/* Header + search */}
<div className="p-2 lg:p-3 border-b border-border">
<h3 className="font-semibold text-sm mb-1.5">Symbole</h3>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Suchen..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="w-full pl-8 h-9 text-sm rounded-md border border-border bg-background px-3 focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<p className="text-xs text-muted-foreground mt-1.5">{totalSymbols} Symbole verfügbar</p>
</div>
{/* Category pills */}
<div className="p-1.5 border-b border-border">
<div className="flex flex-wrap gap-0.5">
{categories.map(cat => {
const Icon = categoryIcons[cat.name] || Target
return (
<button
key={cat.id}
onClick={() => setActiveCategory(cat.id)}
className={`flex items-center gap-1 px-2 py-1.5 rounded-md text-xs font-medium transition-colors ${
activeCategory === cat.id
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-accent text-muted-foreground'
}`}
title={`${cat.name} (${cat.symbols.length})`}
>
<Icon className="w-3.5 h-3.5" />
<span className="hidden lg:inline max-w-[70px] truncate">{cat.name}</span>
</button>
)
})}
</div>
</div>
{/* Symbols grid */}
<div className="flex-1 overflow-y-auto p-2">
{currentCat && (
<>
<h4 className="text-xs font-medium text-muted-foreground mb-1.5">
{currentCat.name} ({filteredSymbols.length})
</h4>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-1">
{filteredSymbols.map(sym => (
<div
key={sym.id}
className="flex flex-col items-center gap-1 p-1.5 lg:p-2 rounded-lg border-2 border-transparent hover:border-border hover:bg-accent cursor-grab transition-all"
title={sym.name}
>
<div className="w-10 h-10 lg:w-14 lg:h-14 flex items-center justify-center rounded-lg bg-muted">
<img
src={sym.imageUrl}
alt={sym.name}
className="w-8 h-8 lg:w-12 lg:h-12 object-contain"
crossOrigin="anonymous"
/>
</div>
<span className="text-[10px] text-center truncate w-full font-medium text-muted-foreground">
{sym.name}
</span>
</div>
))}
</div>
</>
)}
</div>
</>
)}
{activeTab === 'journal' && (
<div className="flex-1 overflow-y-auto p-3">
{/* Project info header */}
{project && (
<div className="border border-border rounded-lg p-3 bg-muted/30 mb-3">
<div className="text-xs font-semibold">{project.title}</div>
{project.location && <div className="text-[10px] text-muted-foreground">{project.location}</div>}
{project.einsatzleiter && <div className="text-[10px] text-muted-foreground">EL: {project.einsatzleiter}</div>}
</div>
)}
{/* Check items */}
{journalCheckItems.length > 0 && (
<div className="mb-3">
<h4 className="text-xs font-semibold text-muted-foreground mb-1.5">Checkliste</h4>
<div className="space-y-1">
{journalCheckItems.map(item => (
<div key={item.id} className="flex items-center gap-2 text-xs px-2 py-1 rounded bg-muted/20">
<span className={`w-4 h-4 rounded border flex items-center justify-center text-[10px] ${item.confirmed ? 'bg-green-100 border-green-400 text-green-700' : 'border-gray-300'}`}>
{item.confirmed ? '✓' : ''}
</span>
<span className={item.confirmed ? 'line-through text-muted-foreground' : ''}>{item.label}</span>
</div>
))}
</div>
</div>
)}
{/* Journal entries */}
<h4 className="text-xs font-semibold text-muted-foreground mb-1.5">Journal-Einträge</h4>
{journalEntries.length === 0 ? (
<div className="text-center text-muted-foreground py-6 text-xs">Keine Journal-Einträge vorhanden</div>
) : (
<div className="space-y-1.5">
{journalEntries.map(entry => (
<div key={entry.id} className={`border border-border rounded-lg p-2.5 ${entry.isCorrected ? 'bg-red-50/50 dark:bg-red-950/20' : 'bg-muted/30'}`}>
<div className="flex items-center justify-between mb-0.5">
<span className={`text-xs font-semibold ${entry.isCorrected ? 'line-through text-muted-foreground' : ''}`}>
{entry.what}
</span>
<span className="text-[10px] text-muted-foreground tabular-nums">
{new Date(entry.time).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
{entry.who && <p className="text-[10px] text-muted-foreground">Wer: {entry.who}</p>}
{entry.done && <span className="text-[10px] text-green-600 font-medium"> Erledigt</span>}
</div>
))}
</div>
)}
{/* Pendenzen */}
{journalPendenzen.length > 0 && (
<div className="mt-3">
<h4 className="text-xs font-semibold text-muted-foreground mb-1.5">Pendenzen</h4>
<div className="space-y-1">
{journalPendenzen.map(p => (
<div key={p.id} className={`border border-border rounded p-2 text-xs ${p.done ? 'bg-green-50/50' : 'bg-orange-50/50'}`}>
<span className={p.done ? 'line-through text-muted-foreground' : 'font-medium'}>{p.what}</span>
{p.who && <span className="text-muted-foreground ml-1">({p.who})</span>}
</div>
))}
</div>
</div>
)}
</div>
)}
</aside>
</div>
</div>
)
}

View File

@@ -0,0 +1,129 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ArrowLeft, Loader2, Mail, CheckCircle, ExternalLink } from 'lucide-react'
import { LogoRound } from '@/components/ui/logo'
export default function ForgotPasswordPage() {
const [email, setEmail] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [sent, setSent] = useState(false)
const [message, setMessage] = useState('')
const [resetUrl, setResetUrl] = useState<string | null>(null)
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
setError('')
setResetUrl(null)
try {
const res = await fetch('/api/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
const data = await res.json()
if (res.ok) {
setSent(true)
setMessage(data.message)
if (data.resetUrl) {
setResetUrl(data.resetUrl)
}
} else {
setError(data.error || 'Ein Fehler ist aufgetreten.')
}
} catch {
setError('Verbindungsfehler. Bitte versuchen Sie es erneut.')
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 px-4">
<div className="w-full max-w-md">
<div className="bg-card rounded-xl shadow-2xl p-8 border border-border">
<div className="flex flex-col items-center mb-6">
<LogoRound size={56} className="mb-3" />
<h1 className="text-2xl font-bold text-foreground">Passwort vergessen</h1>
<p className="text-muted-foreground text-sm mt-1 text-center">
Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen einen Link zum Zurücksetzen.
</p>
</div>
{sent ? (
<div className="text-center space-y-4">
<div className="w-12 h-12 bg-green-600/20 rounded-full flex items-center justify-center mx-auto">
<CheckCircle className="w-6 h-6 text-green-500" />
</div>
<p className="text-sm text-muted-foreground">{message}</p>
{resetUrl && (
<div className="mt-4 p-3 bg-muted rounded-lg">
<p className="text-xs text-muted-foreground mb-2">Reset-Link (kein SMTP konfiguriert):</p>
<Link href={resetUrl} className="text-sm text-red-500 hover:text-red-400 break-all flex items-center gap-1 justify-center">
<ExternalLink className="w-3.5 h-3.5 flex-shrink-0" />
Link öffnen
</Link>
</div>
)}
<div className="pt-2">
<Link href="/login" className="text-sm text-red-600 hover:text-red-500 font-medium">
Zurück zur Anmeldung
</Link>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="email">E-Mail</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
id="email"
type="email"
placeholder="name@feuerwehr.ch"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
className="pl-10"
/>
</div>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<Button
type="submit"
className="w-full bg-red-600 hover:bg-red-700"
disabled={isLoading || !email}
>
{isLoading ? (
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Senden...</>
) : (
'Link senden'
)}
</Button>
</form>
)}
</div>
<div className="text-center mt-4">
<Link href="/login" className="text-sm text-gray-400 hover:text-gray-300 inline-flex items-center gap-1">
<ArrowLeft className="w-3.5 h-3.5" />
Zurück zur Anmeldung
</Link>
</div>
</div>
</div>
)
}

228
src/app/globals.css Normal file
View File

@@ -0,0 +1,228 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
/* Nacht-Modus: Sehr dunkel, bernstein/rot-Akzente (Nachtsicht-Erhaltung) */
.dark {
--background: 0 0% 5%;
--foreground: 30 20% 75%;
--card: 0 0% 7%;
--card-foreground: 30 20% 75%;
--popover: 0 0% 8%;
--popover-foreground: 30 20% 75%;
--primary: 25 90% 45%;
--primary-foreground: 0 0% 5%;
--secondary: 0 0% 12%;
--secondary-foreground: 30 20% 75%;
--muted: 0 0% 12%;
--muted-foreground: 30 10% 50%;
--accent: 0 0% 14%;
--accent-foreground: 30 20% 75%;
--destructive: 0 70% 40%;
--destructive-foreground: 30 20% 85%;
--border: 0 0% 15%;
--input: 0 0% 15%;
--ring: 25 80% 45%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
/* MapLibre styles */
.maplibregl-map {
font-family: inherit;
}
/* Night mode: map stays bright (readable), only UI around it is dark */
/* MapLibre controls get subtle dark styling */
.dark .maplibregl-ctrl-group {
background: #222 !important;
border: 1px solid #444 !important;
}
.dark .maplibregl-ctrl-group button {
background-color: #222 !important;
}
.dark .maplibregl-ctrl-group button .maplibregl-ctrl-icon {
filter: invert(0.8);
}
.dark .maplibregl-ctrl-attrib {
background: rgba(0,0,0,0.6) !important;
color: #aaa !important;
}
.maplibregl-ctrl-group {
@apply shadow-md rounded-lg overflow-hidden;
}
.maplibregl-ctrl-group button {
width: 48px !important;
height: 48px !important;
}
.maplibregl-ctrl-group button .maplibregl-ctrl-icon {
transform: scale(1.4);
}
/* Touch-friendly: disable text selection on interactive elements */
.touch-none {
touch-action: none;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
@apply bg-muted rounded-full;
}
::-webkit-scrollbar-thumb {
@apply bg-muted-foreground/30 rounded-full hover:bg-muted-foreground/50;
}
/* Drawing tools cursor */
.draw-mode-point {
cursor: crosshair;
}
.draw-mode-line,
.draw-mode-polygon,
.draw-mode-rectangle,
.draw-mode-circle {
cursor: crosshair;
}
.draw-mode-select {
cursor: default;
}
/* Symbol markers on map: no border/background (override global border-border) */
.symbol-marker-wrapper {
border: none !important;
background-color: transparent !important;
}
.symbol-marker {
border: none !important;
background-color: transparent !important;
}
/* Symbol dragging */
.symbol-dragging {
@apply opacity-70 scale-110 shadow-lg;
cursor: grabbing;
}
/* Moveable handles: larger and touch-friendly */
.moveable-control-box .moveable-control {
width: 14px !important;
height: 14px !important;
margin-top: -7px !important;
margin-left: -7px !important;
border-radius: 50% !important;
background: #3b82f6 !important;
border: 2px solid #fff !important;
box-shadow: 0 2px 6px rgba(0,0,0,0.3) !important;
}
.moveable-control-box .moveable-rotation .moveable-control {
width: 28px !important;
height: 28px !important;
margin-top: -14px !important;
margin-left: -14px !important;
background: #f97316 !important;
cursor: grab !important;
border: 2px solid #fff !important;
border-radius: 50% !important;
box-shadow: 0 2px 8px rgba(0,0,0,0.35) !important;
position: relative !important;
}
.moveable-control-box .moveable-rotation .moveable-control::after {
content: '' !important;
display: block !important;
width: 16px !important;
height: 16px !important;
position: absolute !important;
top: 50% !important;
left: 50% !important;
transform: translate(-50%, -50%) !important;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8'/%3E%3Cpath d='M21 3v5h-5'/%3E%3C/svg%3E") !important;
background-size: contain !important;
background-repeat: no-repeat !important;
}
.moveable-control-box .moveable-rotation .moveable-control:active {
cursor: grabbing !important;
background: #ea580c !important;
}
.moveable-control-box .moveable-line {
background: #3b82f6 !important;
height: 2px !important;
}
.moveable-control-box .moveable-rotation .moveable-line {
display: none !important;
}
/* Toast animations */
.toast-enter {
animation: toast-enter 0.2s ease-out;
}
.toast-exit {
animation: toast-exit 0.2s ease-in;
}
@keyframes toast-enter {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes toast-exit {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(100%);
}
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Impressum',
description: 'Impressum und rechtliche Informationen zu Lageplan.ch der digitalen Einsatzdokumentation für Feuerwehren.',
robots: { index: true, follow: true },
}
export default function ImpressumLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>
}

147
src/app/impressum/page.tsx Normal file
View File

@@ -0,0 +1,147 @@
'use client'
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
import { Logo } from '@/components/ui/logo'
export default function ImpressumPage() {
return (
<div className="min-h-screen bg-white">
{/* Navigation */}
<nav className="fixed top-0 w-full z-50 bg-white/80 backdrop-blur-md border-b border-gray-100">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<Link href="/" className="flex items-center gap-2">
<Logo size={36} />
<span className="font-bold text-xl text-gray-900">Lageplan</span>
</Link>
<Link href="/" className="flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900 transition">
<ArrowLeft className="w-4 h-4" />
Zurück
</Link>
</div>
</div>
</nav>
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pt-28 pb-16">
<h1 className="text-3xl font-bold text-gray-900 mb-8">Impressum</h1>
<div className="space-y-8 text-gray-700 leading-relaxed">
{/* Betreiber */}
<section>
<h2 className="text-xl font-semibold text-gray-900 mb-3">Betreiber der Webseite</h2>
<div className="bg-gray-50 rounded-xl p-6 space-y-1">
<p className="font-semibold text-gray-900">Purepixel</p>
<p>Ein Angebot der Firma Kingstickers Ziberi</p>
<p className="mt-3">Perparim Ziberi</p>
<p>Anglikerstrasse 56</p>
<p>5612 Villmergen</p>
<p>Schweiz</p>
<p className="mt-3">
<span className="text-gray-500">UID:</span> CHE-353.865.879
</p>
<p>
<span className="text-gray-500">E-Mail:</span>{' '}
<a href="mailto:app@lageplan.ch" className="text-red-600 hover:text-red-500">
app@lageplan.ch
</a>
</p>
</div>
</section>
{/* Verantwortlich */}
<section>
<h2 className="text-xl font-semibold text-gray-900 mb-3">Verantwortlich für den Inhalt</h2>
<p>Perparim Ziberi, Anglikerstrasse 56, 5612 Villmergen</p>
</section>
{/* Haftungsausschluss */}
<section>
<h2 className="text-xl font-semibold text-gray-900 mb-3">Haftungsausschluss</h2>
<p>
Der Autor übernimmt keinerlei Gewähr hinsichtlich der inhaltlichen Richtigkeit, Genauigkeit,
Aktualität, Zuverlässigkeit und Vollständigkeit der Informationen.
</p>
<p className="mt-2">
Haftungsansprüche gegen den Autor wegen Schäden materieller oder immaterieller Art, welche aus
dem Zugriff oder der Nutzung bzw. Nichtnutzung der veröffentlichten Informationen, durch
Missbrauch der Verbindung oder durch technische Störungen entstanden sind, werden ausgeschlossen.
</p>
<p className="mt-2">
Alle Angebote sind unverbindlich. Der Autor behält es sich ausdrücklich vor, Teile der Seiten
oder das gesamte Angebot ohne gesonderte Ankündigung zu verändern, zu ergänzen, zu löschen oder
die Veröffentlichung zeitweise oder endgültig einzustellen.
</p>
</section>
{/* Haftung für Links */}
<section>
<h2 className="text-xl font-semibold text-gray-900 mb-3">Haftung für Links</h2>
<p>
Verweise und Links auf Webseiten Dritter liegen ausserhalb unseres Verantwortungsbereichs.
Es wird jegliche Verantwortung für solche Webseiten abgelehnt. Der Zugriff und die Nutzung
solcher Webseiten erfolgen auf eigene Gefahr des Nutzers.
</p>
</section>
{/* Urheberrechte */}
<section>
<h2 className="text-xl font-semibold text-gray-900 mb-3">Urheberrechte</h2>
<p>
Die Urheber- und alle anderen Rechte an Inhalten, Bildern, Fotos oder anderen Dateien auf
dieser Website gehören ausschliesslich der Firma Kingstickers Ziberi oder den speziell
genannten Rechtsinhabern. Für die Reproduktion jeglicher Elemente ist die schriftliche
Zustimmung der Urheberrechtsträger im Voraus einzuholen.
</p>
</section>
{/* Datenschutz */}
<section>
<h2 className="text-xl font-semibold text-gray-900 mb-3">Datenschutz</h2>
<p>
Gestützt auf Artikel 13 der schweizerischen Bundesverfassung und die datenschutzrechtlichen
Bestimmungen des Bundes (Datenschutzgesetz, DSG) hat jede Person Anspruch auf Schutz ihrer
Privatsphäre sowie auf Schutz vor Missbrauch ihrer persönlichen Daten.
</p>
<p className="mt-2">
Wir halten diese Bestimmungen ein. Persönliche Daten werden streng vertraulich behandelt und
weder an Dritte verkauft noch weitergegeben.
</p>
<p className="mt-2">
Die Nutzung der Applikation erfordert eine Registrierung. Dabei werden folgende Daten
gespeichert: Name, E-Mail-Adresse, Organisationsname. Die Daten werden ausschliesslich für
den Betrieb der Applikation verwendet und auf Servern in der Schweiz gehostet.
</p>
<p className="mt-2">
Einsatzdaten (Lagepläne, Journaleinträge, Zeichnungen) werden verschlüsselt gespeichert und
sind nur für berechtigte Benutzer der jeweiligen Organisation zugänglich.
</p>
</section>
{/* Kartendaten */}
<section>
<h2 className="text-xl font-semibold text-gray-900 mb-3">Kartendaten</h2>
<p>
Die Kartenansicht verwendet Daten von{' '}
<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer" className="text-red-600 hover:text-red-500">
OpenStreetMap
</a>{' '}
(&copy; OpenStreetMap-Mitwirkende) sowie Satellitenbilder von Esri World Imagery.
</p>
<p className="mt-2">
Die Adresssuche verwendet den{' '}
<a href="https://nominatim.org/" target="_blank" rel="noopener noreferrer" className="text-red-600 hover:text-red-500">
Nominatim
</a>{' '}
Geocoding-Service von OpenStreetMap.
</p>
</section>
</div>
<div className="mt-12 pt-8 border-t border-gray-100 text-center">
<p className="text-sm text-gray-400">&copy; {new Date().getFullYear()} Lageplan Purepixel / Kingstickers Ziberi</p>
</div>
</main>
</div>
)
}

116
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,116 @@
import type { Metadata, Viewport } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import { Toaster } from '@/components/ui/toaster'
import { AuthProvider } from '@/components/providers/auth-provider'
import { ServiceWorkerRegister } from '@/components/providers/sw-register'
const inter = Inter({
subsets: ['latin'],
weight: ['400', '500', '600', '700'],
display: 'swap',
})
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://lageplan.ch'
export const metadata: Metadata = {
metadataBase: new URL(siteUrl),
title: {
default: 'Lageplan Digitale Einsatzdokumentation für Feuerwehren',
template: '%s | Lageplan',
},
description:
'Die moderne Krokier-App für Schweizer Feuerwehren. Einsatzpläne digital erstellen, Journal führen, Rapporte generieren direkt im Browser, ohne Installation.',
keywords: [
'Feuerwehr Software',
'Krokier App',
'Lageplan Feuerwehr',
'Einsatzdokumentation',
'Einsatzplanung',
'Feuerwehr App Schweiz',
'Einsatzjournal',
'Rapport Feuerwehr',
'Feuerwehr Krokierung',
'Einsatzleitung Software',
'Feuerwehr digital',
],
authors: [{ name: 'Lageplan.ch' }],
creator: 'Lageplan.ch',
publisher: 'Lageplan.ch',
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
openGraph: {
type: 'website',
locale: 'de_CH',
url: siteUrl,
siteName: 'Lageplan',
title: 'Lageplan Digitale Einsatzdokumentation für Feuerwehren',
description:
'Die moderne Krokier-App für Schweizer Feuerwehren. Einsatzpläne digital erstellen, Journal führen, Rapporte generieren.',
images: [
{
url: '/og-image.png',
width: 1200,
height: 630,
alt: 'Lageplan Feuerwehr Krokier-App',
},
],
},
twitter: {
card: 'summary_large_image',
title: 'Lageplan Digitale Einsatzdokumentation für Feuerwehren',
description:
'Die moderne Krokier-App für Schweizer Feuerwehren. Einsatzpläne digital erstellen, Journal führen, Rapporte generieren.',
images: ['/og-image.png'],
},
alternates: {
canonical: siteUrl,
},
icons: {
icon: '/logo.svg',
apple: '/logo.svg',
},
category: 'technology',
}
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
interactiveWidget: 'resizes-content',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="de" suppressHydrationWarning>
<head>
<link rel="manifest" href="/manifest.json" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Lageplan" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="theme-color" content="#dc2626" />
</head>
<body className={`${inter.className} antialiased`} style={{ fontFeatureSettings: '"tnum", "cv01"' }}>
<AuthProvider>
<ServiceWorkerRegister />
{children}
<Toaster />
</AuthProvider>
</body>
</html>
)
}

11
src/app/login/layout.tsx Normal file
View File

@@ -0,0 +1,11 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Anmelden',
description: 'Melden Sie sich bei Lageplan.ch an Ihrer digitalen Einsatzdokumentation für Feuerwehren.',
robots: { index: false, follow: false },
}
export default function LoginLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>
}

180
src/app/login/page.tsx Normal file
View File

@@ -0,0 +1,180 @@
'use client'
import { useState, useEffect, Suspense } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { useAuth } from '@/components/providers/auth-provider'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
import { ArrowLeft, Loader2 } from 'lucide-react'
import { LogoRound } from '@/components/ui/logo'
export default function LoginPage() {
return (
<Suspense fallback={<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900"><Loader2 className="w-8 h-8 animate-spin text-gray-400" /></div>}>
<LoginForm />
</Suspense>
)
}
function LoginForm() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [tenantLogo, setTenantLogo] = useState<string | null>(null)
const [tenantName, setTenantName] = useState<string | null>(null)
const { login } = useAuth()
const router = useRouter()
const searchParams = useSearchParams()
const { toast } = useToast()
const redirectTo = searchParams.get('redirect')
const tenantSlug = searchParams.get('tenant')
const verified = searchParams.get('verified')
const errorParam = searchParams.get('error')
// Fetch tenant logo if slug is provided
useEffect(() => {
if (tenantSlug) {
fetch(`/api/tenants/by-slug/${tenantSlug}`)
.then(res => res.ok ? res.json() : null)
.then(data => {
if (data?.tenant) {
setTenantLogo(data.tenant.logoUrl)
setTenantName(data.tenant.name)
}
})
.catch(() => {})
}
}, [tenantSlug])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
const result = await login(email, password)
if (result.success) {
toast({
title: 'Erfolgreich angemeldet',
description: 'Willkommen bei Lageplan!',
})
// Redirect: use explicit redirect param, or tenant slug, or /app
if (redirectTo) {
router.push(redirectTo)
} else {
try {
const meRes = await fetch('/api/auth/me')
if (meRes.ok) {
const meData = await meRes.json()
if (meData.user?.tenantSlug) {
router.push(`/${meData.user.tenantSlug}`)
return
}
}
} catch {}
router.push('/app')
}
} else {
toast({
title: 'Anmeldung fehlgeschlagen',
description: result.error || 'Bitte überprüfen Sie Ihre Zugangsdaten.',
variant: 'destructive',
})
}
setIsLoading(false)
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 px-4">
<div className="w-full max-w-md">
<div className="bg-card rounded-xl shadow-2xl p-8 border border-border">
<div className="flex flex-col items-center mb-6">
{tenantLogo ? (
<img src={tenantLogo} alt={tenantName || 'Logo'} className="w-16 h-16 object-contain mb-3 rounded-lg" />
) : (
<LogoRound size={56} className="mb-3" />
)}
<h1 className="text-2xl font-bold text-foreground">Anmelden</h1>
<p className="text-muted-foreground text-sm mt-1">
{tenantName || 'Feuerwehr Krokier-App'}
</p>
</div>
{verified === 'true' && (
<div className="bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 rounded-lg p-3 mb-4 text-sm text-green-700 dark:text-green-400 text-center">
E-Mail-Adresse erfolgreich bestätigt! Sie können sich jetzt anmelden.
</div>
)}
{errorParam === 'invalid-token' && (
<div className="bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 rounded-lg p-3 mb-4 text-sm text-red-700 dark:text-red-400 text-center">
Ungültiger oder abgelaufener Bestätigungslink.
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="email">E-Mail</Label>
<Input
id="email"
type="email"
placeholder="name@feuerwehr.ch"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
/>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="password">Passwort</Label>
<Link href="/forgot-password" className="text-xs text-red-600 hover:text-red-500">
Passwort vergessen?
</Link>
</div>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
/>
</div>
<Button
type="submit"
className="w-full bg-red-600 hover:bg-red-700"
disabled={isLoading}
>
{isLoading ? (
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Anmelden...</>
) : (
'Anmelden'
)}
</Button>
</form>
<div className="mt-6 text-center text-sm text-muted-foreground">
Noch kein Konto?{' '}
<Link href="/register" className="text-red-600 hover:text-red-500 font-medium">
Kostenlos registrieren
</Link>
</div>
</div>
{/* Back to landing */}
<div className="text-center mt-4">
<Link href="/" className="text-sm text-gray-400 hover:text-gray-300 inline-flex items-center gap-1">
<ArrowLeft className="w-3.5 h-3.5" />
Zurück zur Startseite
</Link>
</div>
</div>
</div>
)
}

120
src/app/opengraph-image.tsx Normal file
View File

@@ -0,0 +1,120 @@
import { ImageResponse } from 'next/og'
export const runtime = 'edge'
export const alt = 'Lageplan Digitale Einsatzdokumentation für Feuerwehren'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export default async function Image() {
return new ImageResponse(
(
<div
style={{
background: 'linear-gradient(135deg, #1e293b 0%, #0f172a 50%, #1e293b 100%)',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
fontFamily: 'Inter, sans-serif',
position: 'relative',
}}
>
{/* Red accent line top */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '6px',
background: 'linear-gradient(90deg, #dc2626, #ef4444, #dc2626)',
}}
/>
{/* Logo circle */}
<div
style={{
width: '120px',
height: '120px',
borderRadius: '24px',
background: 'linear-gradient(135deg, #dc2626, #b91c1c)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '32px',
boxShadow: '0 20px 60px rgba(220, 38, 38, 0.3)',
}}
>
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2" />
<path d="M12 8v8" />
<path d="M8 12h8" />
</svg>
</div>
{/* Title */}
<div
style={{
fontSize: '64px',
fontWeight: 800,
color: 'white',
marginBottom: '16px',
letterSpacing: '-1px',
}}
>
Lageplan
</div>
{/* Subtitle */}
<div
style={{
fontSize: '28px',
fontWeight: 500,
color: '#94a3b8',
marginBottom: '40px',
textAlign: 'center',
maxWidth: '800px',
}}
>
Digitale Einsatzdokumentation für Feuerwehren
</div>
{/* Feature pills */}
<div style={{ display: 'flex', gap: '16px' }}>
{['Krokierung', 'Journal', 'Rapporte', 'Echtzeit'].map((f) => (
<div
key={f}
style={{
padding: '10px 24px',
borderRadius: '100px',
border: '1px solid rgba(148, 163, 184, 0.3)',
color: '#e2e8f0',
fontSize: '18px',
fontWeight: 500,
}}
>
{f}
</div>
))}
</div>
{/* Domain */}
<div
style={{
position: 'absolute',
bottom: '32px',
right: '40px',
fontSize: '20px',
color: '#64748b',
fontWeight: 600,
}}
>
lageplan.ch
</div>
</div>
),
{ ...size }
)
}

673
src/app/page.tsx Normal file
View File

@@ -0,0 +1,673 @@
'use client'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Logo } from '@/components/ui/logo'
import { useAuth } from '@/components/providers/auth-provider'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import {
Flame, Map, Shield, Users, Smartphone, FileText, Ruler, Clock,
Check, ArrowRight, Lock, ChevronRight, MessageSquare, Loader2, Send,
Heart, Coffee, Rocket, Sparkles, Lightbulb, HelpCircle,
MousePointer2, Minus, Pentagon, Square, Circle, Pencil, MoveRight, Type, Eraser,
} from 'lucide-react'
export default function LandingPage() {
const { user, loading } = useAuth()
const router = useRouter()
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="animate-pulse text-muted-foreground">Laden...</div>
</div>
)
}
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: 'Lageplan',
applicationCategory: 'BusinessApplication',
operatingSystem: 'Web Browser',
description:
'Die moderne Krokier-App für Schweizer Feuerwehren. Einsatzpläne digital erstellen, Journal führen, Rapporte generieren direkt im Browser.',
url: 'https://lageplan.ch',
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'CHF',
description: 'Kostenlos für Schweizer Feuerwehren',
},
aggregateRating: {
'@type': 'AggregateRating',
ratingValue: '4.8',
ratingCount: '12',
},
author: {
'@type': 'Organization',
name: 'Lageplan.ch',
url: 'https://lageplan.ch',
},
featureList: [
'Digitale Einsatzkrokierung',
'Einsatzjournal mit Wörtervorschlägen',
'Automatische Rapport-Generierung',
'Echtzeit-Zusammenarbeit',
'SOMA-Checkliste',
'PDF-Export',
'Schlauchberechnung',
'Offline-fähig',
],
}
return (
<div className="min-h-screen bg-white">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<main>
{/* Navigation */}
<nav className="fixed top-0 w-full z-50 bg-white/80 backdrop-blur-md border-b border-gray-100">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<div className="flex items-center gap-2">
<Logo size={36} />
<span className="font-bold text-xl text-gray-900">Lageplan</span>
</div>
<div className="hidden md:flex items-center gap-8 text-sm text-gray-600">
<a href="#story" className="hover:text-gray-900 transition">Die Geschichte</a>
<a href="#features" className="hover:text-gray-900 transition">Funktionen</a>
<a href="#support" className="hover:text-gray-900 transition">Unterstützen</a>
<a href="#roadmap" className="hover:text-gray-900 transition">Roadmap</a>
</div>
<div className="flex items-center gap-3">
{user ? (
<Link href="/app">
<Button size="sm" className="bg-red-600 hover:bg-red-700">
Zur App
</Button>
</Link>
) : (
<>
<Link href="/login">
<Button variant="ghost" size="sm">Anmelden</Button>
</Link>
<Link href="/register">
<Button size="sm" className="bg-red-600 hover:bg-red-700">
Kostenlos starten
</Button>
</Link>
</>
)}
</div>
</div>
</div>
</nav>
{/* Hero */}
<section className="pt-32 pb-20 px-4">
<div className="max-w-4xl mx-auto text-center">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-red-50 text-red-700 text-sm font-medium mb-6">
<Heart className="w-3.5 h-3.5" />
Ein Herzensprojekt Swiss Made
</div>
<h1 className="text-5xl sm:text-6xl font-extrabold text-gray-900 leading-tight tracking-tight">
Digitale Lagepläne
<br />
<span className="text-red-600">für die Schweizer Feuerwehr</span>
</h1>
<p className="mt-6 text-xl text-gray-500 max-w-2xl mx-auto leading-relaxed">
Die moderne Krokier-App für Einsatzdokumentation. Lagepläne erstellen,
Journal führen und Einsätze dokumentieren direkt im Browser, auf jedem Gerät.
<span className="block mt-2 text-gray-500">Kostenlos. Von einem Feuerwehrmann für Feuerwehrleute.</span>
</p>
<div className="mt-10 flex flex-col sm:flex-row gap-4 justify-center">
<Link href="/register">
<Button size="lg" className="bg-red-600 hover:bg-red-700 text-base px-8 h-12">
Jetzt kostenlos starten
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</Link>
<a href="#story">
<Button size="lg" variant="outline" className="text-base px-8 h-12">
Die Geschichte dahinter
</Button>
</a>
</div>
</div>
{/* Hero visual — App Preview Mockup */}
<div className="max-w-5xl mx-auto mt-16">
<div className="rounded-2xl border border-gray-200 shadow-2xl overflow-hidden bg-gradient-to-br from-gray-50 to-gray-100 p-1">
<div className="rounded-xl bg-white overflow-hidden">
<div className="bg-gray-800 h-8 flex items-center px-4 gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-red-400" />
<div className="w-2.5 h-2.5 rounded-full bg-yellow-400" />
<div className="w-2.5 h-2.5 rounded-full bg-green-400" />
<span className="ml-3 text-xs text-gray-400 font-mono">lageplan.ch</span>
</div>
<div className="relative bg-gradient-to-br from-emerald-50 via-blue-50 to-sky-100 h-[400px] sm:h-[480px] flex items-center justify-center overflow-hidden">
{/* Stylized map background */}
<div className="absolute inset-0 opacity-20">
<div className="absolute top-[15%] left-[10%] w-32 h-20 bg-orange-200 rounded-sm rotate-6" />
<div className="absolute top-[25%] left-[30%] w-24 h-16 bg-orange-200 rounded-sm -rotate-3" />
<div className="absolute top-[10%] right-[20%] w-28 h-18 bg-orange-200 rounded-sm rotate-2" />
<div className="absolute bottom-[30%] left-[20%] w-20 h-14 bg-orange-200 rounded-sm" />
<div className="absolute bottom-[20%] right-[25%] w-36 h-22 bg-orange-200 rounded-sm rotate-1" />
<div className="absolute top-[5%] left-[5%] right-[5%] h-[2px] bg-gray-300" />
<div className="absolute top-[40%] left-0 right-0 h-[3px] bg-white" />
<div className="absolute top-0 bottom-0 left-[50%] w-[3px] bg-white" />
</div>
{/* Toolbar mockup */}
<div className="absolute left-4 top-4 bg-white/90 backdrop-blur rounded-xl shadow-lg p-2 flex flex-col gap-1.5">
{[MousePointer2, Minus, Pentagon, Square, Circle, Pencil, MoveRight, Type, Eraser, Ruler].map((Icon, i) => (
<div key={i} className={`w-8 h-8 rounded-lg flex items-center justify-center ${i === 1 ? 'bg-red-100 text-red-600' : 'text-gray-500 hover:bg-gray-100'}`}>
<Icon className="w-4 h-4" />
</div>
))}
</div>
{/* Center content */}
<div className="relative z-10 text-center px-8">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-white shadow-xl mb-6">
<Map className="w-10 h-10 text-red-600" />
</div>
<p className="text-2xl sm:text-3xl font-bold text-gray-800">Krokieren. Digital.</p>
<p className="text-gray-500 mt-2 text-sm sm:text-base max-w-md mx-auto">
Interaktive Karte · FKS-Signaturen · Einsatzjournal · Echtzeit-Zusammenarbeit
</p>
<div className="flex flex-wrap gap-2 justify-center mt-6">
{['Zeichnen', 'Messen', 'Symbole', 'Journal', 'Export'].map(label => (
<span key={label} className="bg-white/80 backdrop-blur text-gray-700 text-xs font-medium px-3 py-1.5 rounded-full shadow-sm border border-gray-100">
{label}
</span>
))}
</div>
</div>
{/* Bottom CTA overlay */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-white via-white/80 to-transparent h-20 flex items-end justify-center pb-4">
<Link href="/register">
<Button size="sm" className="bg-red-600 hover:bg-red-700 shadow-lg">
Kostenlos registrieren & loslegen
<ArrowRight className="w-4 h-4 ml-1" />
</Button>
</Link>
</div>
</div>
</div>
</div>
</div>
</section>
{/* Stats / Trust Badges */}
<section className="py-12 px-4 border-b border-gray-100">
<div className="max-w-5xl mx-auto">
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 md:gap-8">
{[
{ value: '117', label: 'FKS-Signaturen', sub: 'Offizielle taktische Zeichen' },
{ value: '100%', label: 'Swiss Hosted', sub: 'Server in der Schweiz' },
{ value: '0.—', label: 'Kostenlos', sub: 'Für alle Feuerwehren' },
{ value: '24/7', label: 'Verfügbar', sub: 'Browser-basiert, jedes Gerät' },
].map((stat, i) => (
<div key={i} className="text-center">
<p className="text-3xl sm:text-4xl font-extrabold text-red-600">{stat.value}</p>
<p className="text-sm font-semibold text-gray-900 mt-1">{stat.label}</p>
<p className="text-xs text-gray-500 mt-0.5">{stat.sub}</p>
</div>
))}
</div>
</div>
</section>
{/* Personal Story */}
<section id="story" className="py-20 px-4 bg-gradient-to-b from-white to-gray-50">
<div className="max-w-5xl mx-auto">
<h2 className="text-3xl font-bold text-gray-900 mb-12 text-center">Warum ich das hier baue</h2>
<div className="grid md:grid-cols-3 gap-12 items-start">
{/* Video column */}
<div className="md:col-span-1 flex flex-col items-center">
<div className="w-full max-w-[280px] rounded-2xl overflow-hidden shadow-xl border border-gray-200">
<video
src="/Pepe_Avatar.mp4"
autoPlay
loop
muted
playsInline
className="w-full h-auto"
/>
</div>
<div className="mt-4 text-center">
<h3 className="text-lg font-bold text-gray-900">Pepe Ziberi</h3>
<p className="text-sm text-red-600 font-medium">Offizier & Chef Drohnenkorps</p>
<p className="text-xs text-gray-500 mt-1">IT · Dad · Feuerwehr</p>
<div className="flex gap-2 mt-3 justify-center">
<span className="text-xs bg-red-50 text-red-700 px-2 py-1 rounded-full">Feuerwehr</span>
<span className="text-xs bg-blue-50 text-blue-700 px-2 py-1 rounded-full">IT</span>
<span className="text-xs bg-green-50 text-green-700 px-2 py-1 rounded-full">Drohnen</span>
</div>
</div>
</div>
{/* Text column */}
<div className="md:col-span-2 space-y-4 text-gray-600 leading-relaxed text-[15px]">
<p>
Ich bin Pepe arbeite in der IT, bin Offizier in der Chemiewehr und Chef des Drohnenkorps, Vater und laut meiner Frau «anstrengend auf einem Level, das die Wissenschaft noch nicht erforscht hat». Nicht weil ich was Schlimmes mache. Sondern weil ich um Mitternacht im Bett liege und flüstere: «Schatz, ich hab eine Idee für eine App.» Ich höre förmlich wie sie neben mir die Augen verdreht. Im Dunkeln. Ohne ein Wort. Nur der tiefste Seufzer den ein Mensch erzeugen kann.
</p>
<p>
Mein Alltag besteht zu 40% aus IT-Tickets, 30% aus Kindergeschrei, 20% aus Drohnenflügen und 10% aus dem Versuch, meiner Frau zu erklären, warum ich schon wieder einen neuen Server brauche. «Aber Schatz, der alte hat nur 16GB RAM!» «Dein Kind braucht Winterschuhe.» «...der Server auch.»
</p>
<p>
Ich überwache meine Katzen per Smart-Home-Dashboard. Ja, wirklich. Ich weiss wann sie fressen, wann sie aufs Klo gehen und wie viel sie wiegen. Meine Frau sagt, unsere Katzen haben mehr Monitoring als mein Arbeitgeber. Sie hat leider recht. Leo und Mimi haben eine bessere Dateninfrastruktur als so manches Rechenzentrum. Das ist kein Witz. Das ist ein Hilferuf.
</p>
<p>
In der Chemiewehr bin ich der, der bei jeder Übung in der Ecke steht und murmelt: «Das könnte man automatisieren.» Meine Kameraden ignorieren mich mittlerweile professionell. Beim Krokieren im Einsatz wurde mir dann endgültig klar es gibt kein vernünftiges digitales Tool für die Schweizer Feuerwehr. Keins. Null. Nada. Niente. Nichts. Nüt.
</p>
<p>Also habe ich <strong>Lageplan</strong> gebaut.</p>
<p>
Und jetzt kommt der Plot-Twist: Ich kann eigentlich gar nicht richtig programmieren. Ich bin ein IT-Dad der nachts um Mitternacht Apps baut mit viel Kaffee, noch mehr Ehrgeiz und einer KI als Co-Pilot.
</p>
<p>
Aber halt es bleibt ja nicht beim Code. Nein nein. Ich bin gleichzeitig auch der Systemadministrator, der Netzwerktechniker, der Datenbankflüsterer, der DNS-Jongleur, der SSL-Zertifikats-Erneuerer-um-3-Uhr-morgens-weil-alles-abgelaufen-ist, der Backup-Verantwortliche, der Security-Beauftragte, der Designer, der Tester, der einzige User der um 2 Uhr nachts einen Bug meldet an sich selbst UND der Typ der dann in den App Store geht und schreibt «Funktioniert einwandfrei, 5 Sterne».
</p>
<p>
Die IT-Abteilung bin ich. Der Support bin ich. Der Kunde der sich beschwert bin ich auch. Das Marketing-Team? Auch ich. Die Finanzabteilung? Mein Bankkonto weint leise. HR? Ich hab mir letztens selbst eine Verwarnung gegeben wegen zu vieler Überstunden. Hab sie ignoriert.
</p>
<p>
Ein-Mann-Startup ohne das Start und ohne das Up. Einfach ein Mann mit WLAN, Energy Drinks und einer bedenklichen Anzahl offener Browser-Tabs. Letzter Stand: 347. Nein, ich werde keinen davon schliessen.
</p>
<p>
Dieses Projekt entsteht in meiner Freizeit. Also in der Zeit, in der normale Menschen schlafen, Netflix schauen oder Hobbys haben, die ihre Partnerin nicht in den Wahnsinn treiben. Meine Frau hat letztens gefragt: «Wann ist das Projekt fertig?» Ich habe gelacht. Sie hat nicht gelacht. Dann hat sie den Router ausgesteckt. Härteste Verhandlungstaktik die ich je erlebt habe.
</p>
<p>
Ich stelle <strong>Lageplan</strong> allen Feuerwehren in der Schweiz <strong>kostenlos</strong> zur Verfügung weil gute Werkzeuge allen gehören sollten. Und weil ich offenbar nicht genug Chaos in meinem Leben habe.
</p>
<p className="text-gray-500 italic text-sm">
Falls du mich suchst: Ich bin der mit den Augenringen, dem dritten Monitor und dem Gesichtsausdruck eines Mannes, der gerade realisiert hat, dass er vergessen hat die Datenbank zu sichern.
</p>
</div>
</div>
</div>
</section>
{/* Why it matters */}
<section className="py-16 px-4">
<div className="max-w-4xl mx-auto">
<div className="bg-gradient-to-r from-red-50 to-orange-50 rounded-2xl p-8 md:p-12 border border-red-100">
<div className="flex items-start gap-4">
<Lightbulb className="w-8 h-8 text-red-500 shrink-0 mt-1" />
<div>
<h2 className="text-xl font-bold text-gray-900 mb-3">Entwicklung kostet Zeit und Geld</h2>
<p className="text-gray-600 leading-relaxed">
Hinter jeder Funktion stecken Stunden an Entwicklung, Testing und Feinschliff.
Server-Hosting, Domain, SSL-Zertifikate das alles kostet. Ich finanziere das aktuell
komplett aus eigener Tasche, weil mir das Projekt am Herzen liegt.
Wenn du Lageplan nützlich findest, kannst du die Weiterentwicklung mit einer freiwilligen
Spende unterstützen. Jeder Beitrag hilft, neue Features zu bauen und die Server am Laufen zu halten.
</p>
</div>
</div>
</div>
</div>
</section>
{/* Features */}
<section id="features" className="py-20 bg-gray-50 px-4">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-16">
<h2 className="text-3xl font-bold text-gray-900">Was Lageplan kann</h2>
<p className="mt-3 text-lg text-gray-500">Professionelle Einsatzdokumentation einfach und schnell</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{[
{ icon: Map, title: 'Interaktive Karte', desc: 'Lagepläne auf OSM-Karten zeichnen mit Linien, Polygonen, Pfeilen und Freihandzeichnung.' },
{ icon: Shield, title: 'FKS-Signaturen', desc: 'Alle offiziellen FKS/BABS-Signaturen per Drag & Drop platzieren. Eigene Symbole hochladen.' },
{ icon: Ruler, title: 'Messwerkzeug', desc: 'Distanzen messen mit Druckverlustberechnung und Pumpenanzahl für Schlauchleitungen.' },
{ icon: FileText, title: 'Einsatzjournal', desc: 'Journal mit Zeitstempel, SOMA-Checkliste und Pendenzenliste — alles an einem Ort.' },
{ icon: Smartphone, title: 'Installierbare App (PWA)', desc: 'Auf dem Homescreen installierbar — funktioniert offline wie eine native App. Desktop, Tablet & Smartphone.' },
{ icon: Users, title: 'Echtzeit-Zusammenarbeit', desc: 'Mehrere Benutzer können gleichzeitig am selben Lageplan arbeiten.' },
{ icon: Clock, title: 'Audit Trail', desc: 'Lückenlose Protokollierung aller Aktionen mit Zeitstempel für Nachvollziehbarkeit.' },
{ icon: Lock, title: 'Sicher & Schweiz', desc: 'Rollenbasierte Zugriffskontrolle. Gehostet in der Schweiz — DSG/DSGVO-konform.' },
{ icon: FileText, title: 'PDF-Export', desc: 'Lagepläne als PDF exportieren mit Metadaten, Datum und Einsatzinformationen.' },
].map((f, i) => (
<div key={i} className="bg-white rounded-xl p-6 border border-gray-100 hover:shadow-lg transition-shadow">
<div className="w-10 h-10 rounded-lg bg-red-50 flex items-center justify-center mb-4">
<f.icon className="w-5 h-5 text-red-600" />
</div>
<h3 className="font-semibold text-gray-900 mb-2" role="heading" aria-level={3}>{f.title}</h3>
<p className="text-sm text-gray-500 leading-relaxed">{f.desc}</p>
</div>
))}
</div>
</div>
</section>
{/* Support / Buy me a coffee */}
<SupportSection />
{/* Roadmap */}
<section id="roadmap" className="py-20 bg-gray-50 px-4">
<div className="max-w-4xl mx-auto">
<div className="text-center mb-16">
<Rocket className="w-10 h-10 text-red-600 mx-auto mb-4" />
<h2 className="text-3xl font-bold text-gray-900">Was noch kommt</h2>
<p className="mt-3 text-lg text-gray-500">
Lageplan wird ständig weiterentwickelt. Hier ein Ausblick auf geplante Features.
</p>
</div>
<div className="space-y-4">
{[
{ status: 'done', title: 'Interaktive Karte mit Zeichenwerkzeugen', desc: 'Linien, Polygone, Pfeile, Freihand, Punkte' },
{ status: 'done', title: 'FKS-Signaturen (117 offizielle Symbole)', desc: 'Alle taktischen Zeichen nach Schweizer Standard (Bildquelle: Feuerwehr Koordination Schweiz FKS)' },
{ status: 'done', title: 'Einsatzjournal mit SOMA-Checkliste', desc: 'Zeitstempel, Pendenzen, Protokollierung' },
{ status: 'done', title: 'Messwerkzeug mit Druckverlustberechnung', desc: 'Distanzen, Höhenmeter, Pumpenanzahl' },
{ status: 'done', title: 'Echtzeit-Zusammenarbeit (WebSocket)', desc: 'Mehrere Benutzer gleichzeitig am Lageplan' },
{ status: 'done', title: 'PDF-Export & Nachtmodus', desc: 'Professioneller Export und augenschonendes Arbeiten' },
{ status: 'done', title: 'Mobile App (PWA)', desc: 'Installierbar auf dem Homescreen — offline-fähig, wie eine native App' },
{ status: 'planned', title: 'Vorlagen & Einsatzpläne', desc: 'Wiederverwendbare Vorlagen für häufige Szenarien' },
].map((item, i) => (
<div key={i} className="flex items-start gap-4 bg-white rounded-xl p-5 border border-gray-100">
<div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 mt-0.5 ${
item.status === 'done' ? 'bg-green-100' : 'bg-amber-100'
}`}>
{item.status === 'done' ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Sparkles className="w-4 h-4 text-amber-600" />
)}
</div>
<div>
<h3 className="font-semibold text-gray-900">{item.title}</h3>
<p className="text-sm text-gray-500 mt-0.5">{item.desc}</p>
</div>
<span className={`ml-auto text-xs font-medium px-2.5 py-1 rounded-full shrink-0 ${
item.status === 'done' ? 'bg-green-50 text-green-700' : 'bg-amber-50 text-amber-700'
}`}>
{item.status === 'done' ? 'Fertig' : 'Geplant'}
</span>
</div>
))}
</div>
<p className="text-center text-sm text-gray-500 mt-8">
Hast du eine Idee für ein neues Feature? <a href="#contact" className="text-red-600 hover:text-red-500 underline">Schreib mir!</a>
</p>
</div>
</section>
{/* Contact */}
<ContactSection />
{/* FAQ */}
<section id="faq" className="py-20 px-4">
<div className="max-w-3xl mx-auto">
<div className="text-center mb-12">
<HelpCircle className="w-10 h-10 text-red-600 mx-auto mb-4" />
<h2 className="text-3xl font-bold text-gray-900">Häufige Fragen</h2>
</div>
<div className="space-y-4">
{[
{
q: 'Was kostet Lageplan?',
a: 'Nichts. Lageplan ist kostenlos für alle Feuerwehren in der Schweiz. Die Entwicklung wird durch freiwillige Spenden finanziert.',
},
{
q: 'Brauche ich eine Installation?',
a: 'Nein. Lageplan läuft komplett im Browser — auf Desktop, Tablet und Smartphone. Einfach registrieren und loslegen.',
},
{
q: 'Funktioniert es auf dem Tablet im Einsatz?',
a: 'Ja. Die App ist für Touch-Bedienung optimiert und funktioniert auf allen modernen Tablets und Smartphones.',
},
{
q: 'Können mehrere Personen gleichzeitig arbeiten?',
a: 'Ja. Über Echtzeit-Synchronisation (WebSocket) können mehrere Benutzer gleichzeitig am selben Lageplan zeichnen und das Journal führen.',
},
{
q: 'Wo werden meine Daten gespeichert?',
a: 'Alle Daten werden auf Servern in der Schweiz gespeichert. Die Applikation ist DSG- und DSGVO-konform.',
},
{
q: 'Welche Symbole sind verfügbar?',
a: 'Alle 117 offiziellen FKS/BABS-Signaturen sind integriert. Zusätzlich können eigene Symbole hochgeladen werden.',
},
{
q: 'Kann ich Lagepläne exportieren?',
a: 'Ja. Lagepläne können als PNG oder PDF exportiert werden — inklusive Metadaten, Datum und Einsatzinformationen.',
},
].map((faq, i) => (
<details key={i} className="group bg-white rounded-xl border border-gray-200 overflow-hidden">
<summary className="flex items-center justify-between cursor-pointer px-6 py-4 text-left font-semibold text-gray-900 hover:bg-gray-50 transition">
{faq.q}
<ChevronRight className="w-4 h-4 text-gray-400 transition-transform group-open:rotate-90 shrink-0 ml-4" />
</summary>
<div className="px-6 pb-4 text-sm text-gray-600 leading-relaxed">
{faq.a}
</div>
</details>
))}
</div>
</div>
</section>
{/* CTA */}
<section className="py-20 px-4 bg-gradient-to-b from-white to-gray-50">
<div className="max-w-3xl mx-auto text-center">
<h2 className="text-3xl font-bold text-gray-900">Bereit loszulegen?</h2>
<p className="mt-4 text-lg text-gray-500">
Registriere dich kostenlos und starte sofort mit dem digitalen Krokieren.
Keine Kreditkarte, keine versteckten Kosten.
</p>
<Link href="/register" className="inline-block mt-8">
<Button size="lg" className="bg-red-600 hover:bg-red-700 text-base px-8 h-12">
Jetzt kostenlos registrieren
<ChevronRight className="w-4 h-4 ml-1" />
</Button>
</Link>
</div>
</section>
{/* Footer */}
<footer className="border-t border-gray-100 py-12 px-4">
<div className="max-w-6xl mx-auto">
<div className="flex flex-col sm:flex-row justify-between items-start gap-8">
<div>
<div className="flex items-center gap-2 mb-2">
<Logo size={28} />
<span className="font-semibold text-gray-900">Lageplan</span>
</div>
<p className="text-sm text-gray-500">Digitale Lagepläne für die Schweizer Feuerwehr</p>
<p className="text-sm text-gray-500 mt-1">Ein Projekt von Pepe Ziberi</p>
<p className="text-sm text-gray-500">Gehostet in der Schweiz Swiss Made 🇨🇭</p>
</div>
<div className="text-sm text-gray-500">
<p className="font-medium text-gray-700 mb-2">Kontakt</p>
<p>app@lageplan.ch</p>
<a href="#contact" className="text-red-600 hover:text-red-500 underline">Kontaktformular</a>
</div>
<div className="text-sm text-gray-500">
<p className="font-medium text-gray-700 mb-2">Produkt</p>
<a href="#features" className="block hover:text-gray-700">Funktionen</a>
<a href="#roadmap" className="block hover:text-gray-700 mt-1">Roadmap</a>
<a href="#support" className="block hover:text-gray-700 mt-1">Unterstützen</a>
<Link href="/register" className="block text-red-600 hover:text-red-500 mt-1">Registrieren</Link>
</div>
<div className="text-sm text-gray-500">
<p className="font-medium text-gray-700 mb-2">Rechtliches</p>
<Link href="/impressum" className="block hover:text-gray-700">Impressum</Link>
<Link href="/datenschutz" className="block hover:text-gray-700 mt-1">Datenschutz</Link>
</div>
</div>
<div className="border-t border-gray-100 mt-8 pt-6 text-center space-y-1">
<p className="text-sm text-gray-500">&copy; {new Date().getFullYear()} Lageplan Purepixel. Alle Rechte vorbehalten.</p>
<p className="text-xs text-gray-500">Taktische Symbole: Bildquelle Feuerwehr Koordination Schweiz FKS</p>
</div>
</div>
</footer>
</main>
</div>
)
}
function SupportSection() {
return (
<section id="support" className="py-20 px-4">
<div className="max-w-3xl mx-auto">
<div className="text-center mb-12">
<Coffee className="w-10 h-10 text-red-600 mx-auto mb-4" />
<h2 className="text-3xl font-bold text-gray-900">Projekt unterstützen</h2>
<p className="mt-3 text-lg text-gray-500 max-w-xl mx-auto">
Lageplan ist und bleibt kostenlos. Wenn du die Weiterentwicklung unterstützen möchtest,
freue ich mich über einen freiwilligen Beitrag.
</p>
</div>
<div className="bg-white rounded-2xl border border-gray-200 shadow-lg p-8 text-center">
{/* Tier preview */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8">
{[
{ emoji: '\u2615', label: 'Kaffee', amount: 'CHF 5' },
{ emoji: '\uD83C\uDF55', label: 'Pizza', amount: 'CHF 10' },
{ emoji: '\uD83D\uDDA5\uFE0F', label: 'Server', amount: 'CHF 25' },
{ emoji: '\uD83D\uDE80', label: 'Feature', amount: 'CHF 50' },
].map(tier => (
<div key={tier.label} className="rounded-xl p-4 border border-gray-100 bg-gray-50">
<span className="text-2xl block mb-1">{tier.emoji}</span>
<span className="text-sm font-bold text-gray-900">{tier.amount}</span>
<span className="text-xs text-gray-500 block">{tier.label}</span>
</div>
))}
</div>
<p className="text-gray-500 mb-6">
Wähle einen Betrag auf unserer Spendenseite Zahlung sicher via Stripe (Kreditkarte, Twint).
</p>
<Link href="/spenden">
<Button className="bg-red-600 hover:bg-red-700 h-12 text-base px-8">
<Heart className="w-4 h-4 mr-2" />
Zur Spendenseite
</Button>
</Link>
</div>
{/* Trust note */}
<div className="mt-8 text-center">
<p className="text-sm text-gray-500">
100% deiner Spende fliesst direkt in Serverkosten und Weiterentwicklung.
<br />Kein Unternehmen, keine Investoren nur ein Feuerwehrmann mit einer Idee.
</p>
</div>
</div>
</section>
)
}
function ContactSection() {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [message, setMessage] = useState('')
const [sending, setSending] = useState(false)
const [sent, setSent] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSending(true)
setError('')
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, message }),
})
if (res.ok) {
setSent(true)
setName('')
setEmail('')
setMessage('')
} else {
const data = await res.json()
setError(data.error || 'Senden fehlgeschlagen')
}
} catch {
setError('Verbindung fehlgeschlagen')
} finally {
setSending(false)
}
}
return (
<section id="contact" className="py-20 px-4">
<div className="max-w-2xl mx-auto">
<div className="text-center mb-10">
<MessageSquare className="w-10 h-10 text-red-600 mx-auto mb-4" />
<h2 className="text-3xl font-bold text-gray-900">Kontakt</h2>
<p className="mt-3 text-lg text-gray-500">
Fragen, Feature-Wünsche oder Feedback? Schreib mir ich freue mich über jede Nachricht.
</p>
</div>
{sent ? (
<div className="text-center bg-green-50 border border-green-200 rounded-xl p-8">
<Check className="w-10 h-10 text-green-600 mx-auto mb-3" />
<h3 className="font-semibold text-green-900 text-lg">Nachricht gesendet!</h3>
<p className="text-green-700 mt-2">Vielen Dank! Ich melde mich so schnell wie möglich.</p>
<Button variant="outline" className="mt-4" onClick={() => setSent(false)}>
Weitere Nachricht senden
</Button>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
placeholder="Dein Name"
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">E-Mail</label>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
required
placeholder="name@feuerwehr.ch"
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nachricht</label>
<textarea
value={message}
onChange={e => setMessage(e.target.value)}
required
rows={5}
placeholder="Deine Nachricht..."
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent resize-none"
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<Button
type="submit"
className="bg-red-600 hover:bg-red-700"
disabled={sending || !name || !email || !message}
>
{sending ? (
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Wird gesendet...</>
) : (
<><Send className="w-4 h-4 mr-2" /> Nachricht senden</>
)}
</Button>
</form>
)}
</div>
</section>
)
}

View File

@@ -0,0 +1,311 @@
'use client'
import { useState, useEffect } from 'react'
import { Loader2, FileText, Download, Printer, MapPin, Send, CheckCircle, XCircle } from 'lucide-react'
interface RapportViewData {
id: string
reportNumber: string
data: any
generatedAt: string
project: { title: string; location: string | null }
tenant: { name: string }
createdBy: { name: string } | null
}
export default function RapportViewerPage({ params }: { params: { token: string } }) {
const [rapport, setRapport] = useState<RapportViewData | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState('')
const [showEmailInput, setShowEmailInput] = useState(false)
const [emailTo, setEmailTo] = useState('')
const [emailSending, setEmailSending] = useState(false)
const [emailStatus, setEmailStatus] = useState<'sent' | 'error' | null>(null)
useEffect(() => {
async function load() {
try {
const res = await fetch(`/api/rapports/${params.token}`)
if (res.ok) {
setRapport(await res.json())
} else {
setError('Rapport nicht gefunden oder Link ungültig.')
}
} catch {
setError('Fehler beim Laden des Rapports.')
} finally {
setIsLoading(false)
}
}
load()
}, [params.token])
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<Loader2 className="w-8 h-8 animate-spin text-red-500 mx-auto mb-2" />
<p className="text-sm text-gray-500">Rapport wird geladen...</p>
</div>
</div>
)
}
if (error || !rapport) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center max-w-md">
<FileText className="w-12 h-12 text-gray-300 mx-auto mb-4" />
<h1 className="text-lg font-bold text-gray-900 mb-2">Rapport nicht verfügbar</h1>
<p className="text-sm text-gray-500">{error}</p>
</div>
</div>
)
}
const d = rapport.data
const pdfUrl = `/api/rapports/${params.token}/pdf`
return (
<div className="min-h-screen bg-gray-100 py-8">
{/* Action bar */}
<div className="max-w-[210mm] mx-auto mb-4 flex justify-between items-center px-4">
<div className="flex items-center gap-2">
<MapPin className="w-5 h-5 text-red-500" />
<span className="font-semibold text-sm">Lageplan Einsatzrapport</span>
</div>
<div className="flex gap-2">
<a
href={pdfUrl}
target="_blank"
className="inline-flex items-center gap-1.5 px-4 py-2 bg-white border rounded-md text-sm font-medium hover:bg-gray-50 transition-colors"
>
<Download className="w-4 h-4" />
PDF herunterladen
</a>
<button
onClick={() => { setShowEmailInput(!showEmailInput); setEmailStatus(null) }}
className="inline-flex items-center gap-1.5 px-4 py-2 bg-white border rounded-md text-sm font-medium hover:bg-gray-50 transition-colors"
>
<Send className="w-4 h-4" />
Per E-Mail
</button>
<button
onClick={() => window.print()}
className="inline-flex items-center gap-1.5 px-4 py-2 bg-gray-900 text-white rounded-md text-sm font-medium hover:bg-gray-800 transition-colors"
>
<Printer className="w-4 h-4" />
Drucken
</button>
</div>
</div>
{/* Email send bar */}
{showEmailInput && (
<div className="max-w-[210mm] mx-auto mb-4 px-4 print:hidden">
<div className="flex items-center gap-2 bg-white border rounded-lg p-3">
<input
type="email"
placeholder="E-Mail-Adresse..."
value={emailTo}
onChange={e => { setEmailTo(e.target.value); setEmailStatus(null) }}
className="flex-1 text-sm border rounded px-3 py-1.5 outline-none focus:ring-2 focus:ring-gray-300"
onKeyDown={e => { if (e.key === 'Enter') document.getElementById('send-email-btn')?.click() }}
/>
<button
id="send-email-btn"
disabled={emailSending || !emailTo.includes('@')}
onClick={async () => {
setEmailSending(true)
setEmailStatus(null)
try {
const res = await fetch(`/api/rapports/${params.token}/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: emailTo }),
})
setEmailStatus(res.ok ? 'sent' : 'error')
if (res.ok) setEmailTo('')
} catch { setEmailStatus('error') } finally { setEmailSending(false) }
}}
className="inline-flex items-center gap-1.5 px-4 py-1.5 bg-gray-900 text-white rounded text-sm font-medium hover:bg-gray-800 disabled:opacity-50 transition-colors"
>
{emailSending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
Senden
</button>
{emailStatus === 'sent' && <CheckCircle className="w-5 h-5 text-green-500" />}
{emailStatus === 'error' && <XCircle className="w-5 h-5 text-red-500" />}
</div>
</div>
)}
{/* Rapport card */}
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg p-8 print:shadow-none print:rounded-none print:p-6">
{/* Header */}
<div className="flex justify-between items-start pb-3 border-b-[3px] border-gray-900 mb-1">
<div className="flex items-center gap-3">
{d.logoUrl && (
<img src={d.logoUrl} alt="Logo" className="w-10 h-10 object-contain" />
)}
<div>
<h1 className="text-2xl font-bold tracking-tight">Einsatzrapport</h1>
<p className="text-xs text-gray-500 mt-0.5 font-medium">{d.organisation} · {d.abteilung}</p>
</div>
</div>
<div className="text-right">
<div className="text-xl font-bold text-red-700 font-mono">{rapport.reportNumber}</div>
<div className="text-xs text-gray-500">{d.datum} · {d.uhrzeit}</div>
</div>
</div>
<div className="h-[3px] bg-gradient-to-r from-red-700 from-30% to-gray-200 to-30% mb-4" />
{/* 1. Einsatzdaten */}
<Section num="1" title="Einsatzdaten">
<div className="grid grid-cols-4 border rounded">
<Field label="Einsatz-Nr." value={d.einsatzNr} mono />
<Field label="Datum" value={d.datum} />
<Field label="Alarmzeit" value={d.alarmzeit} mono />
<Field label="Priorität" value={d.prioritaet} last />
<Field label="Einsatzort / Adresse" value={d.einsatzort} span={2} />
<Field label="Koordinaten" value={d.koordinaten} mono />
<Field label="Objekt / Gebäude" value={d.objekt} last />
<Field label="Alarmierungsart" value={d.alarmierungsart} span={2} />
<Field label="Stichwort / Meldebild" value={d.stichwort} span={2} last />
</div>
</Section>
{/* 2. Zeitverlauf */}
<Section num="2" title="Zeitverlauf">
<div className="grid grid-cols-4 border rounded">
<Field label="Alarmierung" value={d.zeitAlarm} mono highlight />
<Field label="Ausrücken" value={d.zeitAusruecken} mono highlight />
<Field label="Eintreffen" value={d.zeitEintreffen} mono highlight />
<Field label="Einsatzbereit" value={d.zeitBereit} mono highlight last />
<Field label="Feuer unter Kontrolle" value={d.zeitKontrolle} mono highlight />
<Field label="Feuer aus" value={d.zeitAus} mono highlight />
<Field label="Einrücken" value={d.zeitEinruecken} mono highlight />
<Field label="Einsatzende" value={d.zeitEnde} mono highlight last />
</div>
</Section>
{/* 3. Lagebild */}
<Section num="3" title="Lagebild">
<div className="grid grid-cols-1 border rounded">
<Field label="Lage bei Eintreffen" value={d.lageEintreffen} span={1} />
<div className={`p-2 border-b border-r-0 border-gray-100 col-span-1`}>
<div className="text-[6.5pt] font-semibold uppercase tracking-wider text-gray-400 mb-0.5">Getroffene Massnahmen</div>
{Array.isArray(d.massnahmen) ? (
<ul className="text-[9pt] font-medium list-none space-y-0.5">
{d.massnahmen.map((m: string, i: number) => (
<li key={i}> {m}</li>
))}
</ul>
) : (
<div className="text-[9pt] font-medium min-h-[14px] whitespace-pre-line">{d.massnahmen || '—'}</div>
)}
</div>
</div>
</Section>
{/* 4. Lageplan-Karte */}
{d.mapScreenshot && (
<Section num="4" title="Lageplan / Skizze">
<div className="border rounded overflow-hidden">
<img src={d.mapScreenshot} alt="Lageplan" className="w-full h-auto max-h-[80mm] object-contain" />
</div>
</Section>
)}
{/* 5. Eingesetzte Mittel */}
{d.fahrzeuge?.length > 0 && (
<Section num="5" title="Eingesetzte Mittel">
<table className="w-full border-collapse border rounded text-xs">
<thead>
<tr className="bg-gray-900 text-white">
<th className="p-1.5 text-left font-semibold uppercase tracking-wider text-[7pt]">Fahrzeug</th>
<th className="p-1.5 text-left font-semibold uppercase tracking-wider text-[7pt]">Pers.</th>
<th className="p-1.5 text-left font-semibold uppercase tracking-wider text-[7pt]">Ausrücken</th>
<th className="p-1.5 text-left font-semibold uppercase tracking-wider text-[7pt]">Eintreffen</th>
<th className="p-1.5 text-left font-semibold uppercase tracking-wider text-[7pt]">Auftrag</th>
</tr>
</thead>
<tbody>
{d.fahrzeuge.map((fz: any, i: number) => (
<tr key={i} className={i % 2 === 1 ? 'bg-gray-50' : ''}>
<td className="p-1.5 border-b border-gray-100">{fz.name}</td>
<td className="p-1.5 border-b border-gray-100">{fz.pers}</td>
<td className="p-1.5 border-b border-gray-100">{fz.ausruecken}</td>
<td className="p-1.5 border-b border-gray-100">{fz.eintreffen}</td>
<td className="p-1.5 border-b border-gray-100">{fz.auftrag}</td>
</tr>
))}
</tbody>
</table>
</Section>
)}
{/* 6. Bemerkungen */}
<Section num="6" title="Bemerkungen / Besondere Vorkommnisse">
<div className="border rounded p-3 min-h-[50px] text-sm">{d.bemerkungen || '—'}</div>
</Section>
{/* Unterschriften */}
<div className="flex gap-12 mt-6 mb-6">
<div className="flex-1">
<div className="border-b border-gray-900 h-8 mb-1" />
<p className="text-[7pt] text-gray-500">Einsatzleiter/in · {d.einsatzleiter}</p>
</div>
<div className="flex-1">
<div className="border-b border-gray-900 h-8 mb-1" />
<p className="text-[7pt] text-gray-500">Rapport erstellt durch · {d.rapporteur}</p>
</div>
<div className="flex-1">
<div className="border-b border-gray-900 h-8 mb-1" />
<p className="text-[7pt] text-gray-500">Datum / Visum Kdt</p>
</div>
</div>
{/* Footer with QR code */}
<div className="flex justify-between items-end pt-3 border-t border-gray-200 text-[7pt] text-gray-400">
<div>
Erstellt: {d.datum} {d.uhrzeit} · {d.organisation}<br />
Rapport: {rapport.reportNumber}
</div>
{d.qrCodeUrl && (
<div className="flex flex-col items-center">
<img src={d.qrCodeUrl} alt="QR-Code" className="w-16 h-16" />
<span className="text-[5pt] text-gray-400 mt-0.5">Online-Rapport</span>
</div>
)}
<div className="text-right">
app.lageplan.ch
</div>
</div>
</div>
</div>
)
}
function Section({ num, title, children }: { num: string; title: string; children: React.ReactNode }) {
return (
<div className="mb-4">
<div className="flex items-center gap-2 mb-2">
<span className="w-5 h-5 bg-gray-900 text-white rounded text-[8pt] font-bold flex items-center justify-center">{num}</span>
<span className="text-[9pt] font-bold uppercase tracking-wider">{title}</span>
<div className="flex-1 h-px bg-gray-200 ml-2" />
</div>
{children}
</div>
)
}
function Field({ label, value, mono, highlight, span, last }: {
label: string; value: string; mono?: boolean; highlight?: boolean; span?: number; last?: boolean
}) {
return (
<div className={`p-2 border-b border-r border-gray-100 ${highlight ? 'bg-gray-50' : ''} ${span === 2 ? 'col-span-2' : ''} ${last ? 'border-r-0' : ''}`}>
<div className="text-[6.5pt] font-semibold uppercase tracking-wider text-gray-400 mb-0.5">{label}</div>
<div className={`text-[9pt] font-medium min-h-[14px] ${mono ? 'font-mono text-[8.5pt]' : ''}`}>{value || '—'}</div>
</div>
)
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Registrieren',
description: 'Erstellen Sie ein kostenloses Konto für Lageplan.ch die digitale Einsatzdokumentation für Schweizer Feuerwehren.',
robots: { index: true, follow: true },
}
export default function RegisterLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>
}

242
src/app/register/page.tsx Normal file
View File

@@ -0,0 +1,242 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
import { ArrowLeft, Building2, User, Mail, Lock, Loader2, Check } from 'lucide-react'
import { LogoRound } from '@/components/ui/logo'
export default function RegisterPage() {
const [organizationName, setOrganizationName] = useState('')
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [privacyAccepted, setPrivacyAccepted] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const router = useRouter()
const { toast } = useToast()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (password !== confirmPassword) {
toast({ title: 'Passwörter stimmen nicht überein', variant: 'destructive' })
return
}
if (password.length < 6) {
toast({ title: 'Passwort muss mindestens 6 Zeichen haben', variant: 'destructive' })
return
}
setIsLoading(true)
try {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ organizationName, name, email, password, privacyAccepted }),
})
const data = await res.json()
if (res.ok) {
setIsSuccess(true)
} else {
toast({
title: 'Registrierung fehlgeschlagen',
description: data.error || 'Bitte versuchen Sie es erneut.',
variant: 'destructive',
})
}
} catch {
toast({ title: 'Fehler', description: 'Verbindung zum Server fehlgeschlagen.', variant: 'destructive' })
} finally {
setIsLoading(false)
}
}
if (isSuccess) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 px-4">
<div className="w-full max-w-md">
<div className="bg-card rounded-xl shadow-2xl p-8 border border-border text-center">
<div className="w-16 h-16 bg-green-600 rounded-full flex items-center justify-center mx-auto mb-4">
<Check className="w-8 h-8 text-white" />
</div>
<h1 className="text-2xl font-bold text-foreground">Fast geschafft!</h1>
<p className="text-muted-foreground mt-2">
Ihr Konto und Ihre Organisation wurden erfolgreich erstellt.
</p>
<div className="bg-amber-50 dark:bg-amber-950/30 rounded-lg p-4 mt-4 text-sm border border-amber-200 dark:border-amber-800">
<p className="font-medium text-amber-800 dark:text-amber-400">E-Mail bestätigen</p>
<p className="text-amber-700 dark:text-amber-500 mt-1">
Wir haben Ihnen eine E-Mail mit einem Bestätigungslink gesendet. Bitte klicken Sie auf den Link, um Ihr Konto zu aktivieren.
</p>
</div>
<Button
className="w-full mt-6 bg-red-600 hover:bg-red-700"
onClick={() => router.push('/login')}
>
Zur Anmeldeseite
</Button>
</div>
</div>
</div>
)
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 px-4">
<div className="w-full max-w-md py-8">
<div className="bg-card rounded-xl shadow-2xl p-8 border border-border">
{/* Header */}
<div className="flex flex-col items-center mb-6">
<LogoRound size={56} className="mb-3" />
<h1 className="text-2xl font-bold text-foreground">Konto erstellen</h1>
<p className="text-muted-foreground text-sm mt-1">
Erstellen Sie Ihr Konto für die Feuerwehr Krokier-App
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Organization */}
<div className="space-y-1.5">
<Label htmlFor="org" className="text-sm flex items-center gap-1.5">
<Building2 className="w-3.5 h-3.5 text-muted-foreground" />
Organisation / Feuerwehr
</Label>
<Input
id="org"
placeholder="z.B. Feuerwehr Wohlen"
value={organizationName}
onChange={(e) => setOrganizationName(e.target.value)}
required
disabled={isLoading}
/>
</div>
{/* Name */}
<div className="space-y-1.5">
<Label htmlFor="name" className="text-sm flex items-center gap-1.5">
<User className="w-3.5 h-3.5 text-muted-foreground" />
Ihr Name
</Label>
<Input
id="name"
placeholder="Vor- und Nachname"
value={name}
onChange={(e) => setName(e.target.value)}
required
disabled={isLoading}
/>
</div>
{/* Email */}
<div className="space-y-1.5">
<Label htmlFor="email" className="text-sm flex items-center gap-1.5">
<Mail className="w-3.5 h-3.5 text-muted-foreground" />
E-Mail-Adresse
</Label>
<Input
id="email"
type="email"
placeholder="name@feuerwehr.ch"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
/>
</div>
{/* Password */}
<div className="space-y-1.5">
<Label htmlFor="password" className="text-sm flex items-center gap-1.5">
<Lock className="w-3.5 h-3.5 text-muted-foreground" />
Passwort
</Label>
<Input
id="password"
type="password"
placeholder="Mindestens 6 Zeichen"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
/>
</div>
{/* Confirm Password */}
<div className="space-y-1.5">
<Label htmlFor="confirmPassword" className="text-sm flex items-center gap-1.5">
<Lock className="w-3.5 h-3.5 text-muted-foreground" />
Passwort bestätigen
</Label>
<Input
id="confirmPassword"
type="password"
placeholder="Passwort wiederholen"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
disabled={isLoading}
/>
</div>
{/* Privacy Policy Checkbox */}
<div className="pt-2 border-t border-border">
<label className="flex items-start gap-2.5 cursor-pointer">
<input
type="checkbox"
checked={privacyAccepted}
onChange={(e) => setPrivacyAccepted(e.target.checked)}
className="mt-0.5 w-4 h-4 rounded border-gray-300 text-red-600 focus:ring-red-500 cursor-pointer"
disabled={isLoading}
/>
<span className="text-xs text-muted-foreground leading-relaxed">
Ich habe die{' '}
<Link href="/datenschutz" target="_blank" className="text-red-500 hover:text-red-400 underline font-medium">
Datenschutzerklärung
</Link>{' '}
gelesen und akzeptiere die Nutzungsbedingungen.
</span>
</label>
</div>
<Button
type="submit"
className="w-full bg-red-600 hover:bg-red-700"
disabled={isLoading || !organizationName || !name || !email || !password || !confirmPassword || !privacyAccepted}
>
{isLoading ? (
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Wird erstellt...</>
) : (
'Kostenlos registrieren'
)}
</Button>
</form>
<div className="mt-6 text-center text-sm text-muted-foreground">
Bereits registriert?{' '}
<Link href="/login" className="text-red-600 hover:text-red-500 font-medium">
Anmelden
</Link>
</div>
</div>
{/* Back to landing */}
<div className="text-center mt-4">
<Link href="/" className="text-sm text-gray-400 hover:text-gray-300 inline-flex items-center gap-1">
<ArrowLeft className="w-3.5 h-3.5" />
Zurück zur Startseite
</Link>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,166 @@
'use client'
import { useState, Suspense } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ArrowLeft, Loader2, CheckCircle, Lock } from 'lucide-react'
import { LogoRound } from '@/components/ui/logo'
export default function ResetPasswordPage() {
return (
<Suspense fallback={<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900"><Loader2 className="w-8 h-8 animate-spin text-gray-400" /></div>}>
<ResetPasswordForm />
</Suspense>
)
}
function ResetPasswordForm() {
const searchParams = useSearchParams()
const router = useRouter()
const token = searchParams.get('token')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [success, setSuccess] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (password.length < 6) {
setError('Passwort muss mindestens 6 Zeichen lang sein.')
return
}
if (password !== confirmPassword) {
setError('Passwörter stimmen nicht überein.')
return
}
setIsLoading(true)
try {
const res = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password }),
})
const data = await res.json()
if (res.ok && data.success) {
setSuccess(true)
} else {
setError(data.error || 'Ein Fehler ist aufgetreten.')
}
} catch {
setError('Verbindungsfehler. Bitte versuchen Sie es erneut.')
} finally {
setIsLoading(false)
}
}
if (!token) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 px-4">
<div className="text-center">
<LogoRound size={56} className="mx-auto mb-4" />
<h1 className="text-xl font-bold text-white mb-2">Ungültiger Link</h1>
<p className="text-gray-400 mb-6">Kein gültiger Reset-Token gefunden.</p>
<Link href="/forgot-password" className="text-red-400 hover:text-red-300 text-sm underline">
Neuen Link anfordern
</Link>
</div>
</div>
)
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 px-4">
<div className="w-full max-w-md">
<div className="bg-card rounded-xl shadow-2xl p-8 border border-border">
<div className="flex flex-col items-center mb-6">
<LogoRound size={56} className="mb-3" />
<h1 className="text-2xl font-bold text-foreground">Neues Passwort</h1>
<p className="text-muted-foreground text-sm mt-1">
Geben Sie Ihr neues Passwort ein.
</p>
</div>
{success ? (
<div className="text-center space-y-4">
<div className="w-12 h-12 bg-green-600/20 rounded-full flex items-center justify-center mx-auto">
<CheckCircle className="w-6 h-6 text-green-500" />
</div>
<p className="text-sm text-muted-foreground">Passwort wurde erfolgreich geändert.</p>
<Button onClick={() => router.push('/login')} className="w-full bg-red-600 hover:bg-red-700">
Zur Anmeldung
</Button>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="password">Neues Passwort</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
id="password"
type="password"
placeholder="Min. 6 Zeichen"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
className="pl-10"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="confirmPassword">Passwort bestätigen</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
id="confirmPassword"
type="password"
placeholder="Passwort wiederholen"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
disabled={isLoading}
className="pl-10"
/>
</div>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<Button
type="submit"
className="w-full bg-red-600 hover:bg-red-700"
disabled={isLoading || !password || !confirmPassword}
>
{isLoading ? (
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Speichern...</>
) : (
'Passwort ändern'
)}
</Button>
</form>
)}
</div>
<div className="text-center mt-4">
<Link href="/login" className="text-sm text-gray-400 hover:text-gray-300 inline-flex items-center gap-1">
<ArrowLeft className="w-3.5 h-3.5" />
Zurück zur Anmeldung
</Link>
</div>
</div>
</div>
)
}

16
src/app/robots.ts Normal file
View File

@@ -0,0 +1,16 @@
import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://lageplan.ch'
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/app/', '/admin/', '/api/', '/rapport/'],
},
],
sitemap: `${baseUrl}/sitemap.xml`,
}
}

192
src/app/settings/page.tsx Normal file
View File

@@ -0,0 +1,192 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useToast } from '@/components/ui/use-toast'
import {
ArrowLeft, Building2, Trash2, AlertTriangle, Loader2, Shield, Users, FileText,
} from 'lucide-react'
export default function SettingsPage() {
const [tenant, setTenant] = useState<any>(null)
const [loading, setLoading] = useState(true)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [deleteConfirmText, setDeleteConfirmText] = useState('')
const [deleting, setDeleting] = useState(false)
const router = useRouter()
const { toast } = useToast()
useEffect(() => {
fetch('/api/tenant/info')
.then(r => r.json())
.then(data => { setTenant(data.tenant); setLoading(false) })
.catch(() => setLoading(false))
}, [])
const handleDelete = async () => {
if (!tenant || deleteConfirmText !== tenant.name) return
setDeleting(true)
try {
const res = await fetch('/api/tenant/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ confirmText: deleteConfirmText }),
})
const data = await res.json()
if (res.ok) {
toast({ title: 'Organisation gelöscht', description: 'Alle Daten wurden entfernt.' })
// Logout and redirect
await fetch('/api/auth/logout', { method: 'POST' })
router.push('/')
} else {
toast({ title: 'Fehler', description: data.error || 'Löschung fehlgeschlagen', variant: 'destructive' })
}
} catch {
toast({ title: 'Fehler', description: 'Verbindungsfehler', variant: 'destructive' })
} finally {
setDeleting(false)
}
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
)
}
return (
<div className="min-h-screen bg-background">
<div className="max-w-2xl mx-auto px-4 py-8">
<Link href="/app" className="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-6">
<ArrowLeft className="w-3.5 h-3.5" />
Zurück zur App
</Link>
<h1 className="text-2xl font-bold text-foreground mb-6 flex items-center gap-2">
<Building2 className="w-6 h-6" />
Organisation verwalten
</h1>
{tenant ? (
<div className="space-y-6">
{/* Tenant Info */}
<div className="bg-card rounded-lg border p-5">
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2">
<Building2 className="w-4 h-4 text-muted-foreground" />
{tenant.name}
</h2>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-muted-foreground">Slug:</span>
<span className="ml-2 font-mono text-xs">{tenant.slug}</span>
</div>
<div>
<span className="text-muted-foreground">Plan:</span>
<span className="ml-2">{tenant.plan}</span>
</div>
<div>
<span className="text-muted-foreground">Status:</span>
<span className="ml-2">{tenant.subscriptionStatus}</span>
</div>
<div>
<span className="text-muted-foreground">Kontakt:</span>
<span className="ml-2">{tenant.contactEmail || ''}</span>
</div>
</div>
</div>
{/* Privacy Info */}
<div className="bg-card rounded-lg border p-5">
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2">
<Shield className="w-4 h-4 text-muted-foreground" />
Datenschutz
</h2>
<div className="text-sm text-muted-foreground space-y-2">
<p className="flex items-center gap-2">
{tenant.privacyAccepted ? '✅' : '❌'} Datenschutzerklärung akzeptiert
{tenant.privacyAcceptedAt && <span className="text-xs">({new Date(tenant.privacyAcceptedAt).toLocaleDateString('de-CH')})</span>}
</p>
<p className="flex items-center gap-2">
{tenant.adminAccessAccepted ? '✅' : '❌'} Administrator-Zugriff akzeptiert
</p>
<Link href="/datenschutz" target="_blank" className="text-red-500 hover:text-red-400 text-xs underline">
Datenschutzerklärung lesen
</Link>
</div>
</div>
{/* Danger Zone */}
<div className="bg-red-50 dark:bg-red-950/20 rounded-lg border border-red-200 dark:border-red-800/50 p-5">
<h2 className="text-lg font-semibold text-red-700 dark:text-red-400 mb-2 flex items-center gap-2">
<AlertTriangle className="w-5 h-5" />
Gefahrenzone
</h2>
<p className="text-sm text-muted-foreground mb-4">
Löscht Ihre Organisation <strong>{tenant.name}</strong> unwiderruflich, inklusive aller
Benutzer, Projekte, Lagepläne, Journal-Einträge, Rapports und hochgeladenen Dateien.
</p>
{!showDeleteDialog ? (
<Button
variant="destructive"
size="sm"
onClick={() => setShowDeleteDialog(true)}
>
<Trash2 className="w-4 h-4 mr-1.5" />
Organisation löschen
</Button>
) : (
<div className="bg-white dark:bg-gray-900 rounded-lg border border-red-300 dark:border-red-800 p-4 space-y-3">
<p className="text-sm font-medium text-red-700 dark:text-red-400">
Geben Sie den Namen Ihrer Organisation zur Bestätigung ein:
</p>
<p className="text-xs font-mono bg-red-100 dark:bg-red-950 rounded px-2 py-1 text-red-800 dark:text-red-300 inline-block">
{tenant.name}
</p>
<Input
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
placeholder={tenant.name}
className="max-w-xs"
autoFocus
/>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => { setShowDeleteDialog(false); setDeleteConfirmText('') }}
disabled={deleting}
>
Abbrechen
</Button>
<Button
variant="destructive"
size="sm"
disabled={deleting || deleteConfirmText !== tenant.name}
onClick={handleDelete}
>
{deleting ? (
<><Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> Wird gelöscht...</>
) : (
<><Trash2 className="w-4 h-4 mr-1.5" /> Endgültig löschen</>
)}
</Button>
</div>
</div>
)}
</div>
</div>
) : (
<div className="bg-card rounded-lg border p-8 text-center">
<p className="text-muted-foreground">Keine Organisation zugeordnet.</p>
</div>
)}
</div>
</div>
)
}

38
src/app/sitemap.ts Normal file
View File

@@ -0,0 +1,38 @@
import type { MetadataRoute } from 'next'
export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://lageplan.ch'
return [
{
url: baseUrl,
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 1,
},
{
url: `${baseUrl}/login`,
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.8,
},
{
url: `${baseUrl}/register`,
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.8,
},
{
url: `${baseUrl}/impressum`,
lastModified: new Date(),
changeFrequency: 'yearly',
priority: 0.3,
},
{
url: `${baseUrl}/spenden`,
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.5,
},
]
}

View File

@@ -0,0 +1,64 @@
'use client'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Heart, ArrowLeft, CheckCircle } from 'lucide-react'
import { Logo } from '@/components/ui/logo'
export default function DankePage() {
return (
<div className="min-h-screen bg-white">
{/* Navigation */}
<nav className="border-b border-gray-100 bg-white">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<Link href="/" className="flex items-center gap-2">
<Logo size={36} />
<span className="font-bold text-xl text-gray-900">Lageplan</span>
</Link>
</div>
</div>
</nav>
<div className="max-w-2xl mx-auto px-4 py-20 text-center">
<div className="w-24 h-24 rounded-full bg-green-100 flex items-center justify-center mx-auto mb-8">
<CheckCircle className="w-12 h-12 text-green-600" />
</div>
<h1 className="text-4xl font-extrabold text-gray-900 mb-4">
Vielen Dank!
</h1>
<p className="text-xl text-gray-500 mb-2">
Deine Spende ist angekommen.
</p>
<p className="text-gray-400 mb-10 max-w-md mx-auto">
Dein Beitrag hilft direkt dabei, Lageplan weiterzuentwickeln und die Server am Laufen zu halten.
Danke, dass du dieses Projekt unterstützt!
</p>
<div className="bg-red-50 rounded-2xl p-8 border border-red-100 mb-10">
<Heart className="w-8 h-8 text-red-500 mx-auto mb-3" />
<p className="text-gray-700 font-medium">
&laquo;Jede Spende motiviert mich, weiterzumachen. Danke von Herzen!&raquo;
</p>
<p className="text-sm text-gray-500 mt-2"> Pepe Ziberi, Entwickler von Lageplan</p>
</div>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link href="/">
<Button variant="outline" size="lg">
<ArrowLeft className="w-4 h-4 mr-2" />
Zur Startseite
</Button>
</Link>
<Link href="/app">
<Button size="lg" className="bg-red-600 hover:bg-red-700">
Zur App
</Button>
</Link>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Spenden',
description: 'Unterstützen Sie die Weiterentwicklung von Lageplan.ch der kostenlosen Krokier-App für Schweizer Feuerwehren.',
robots: { index: true, follow: true },
}
export default function SpendenLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>
}

230
src/app/spenden/page.tsx Normal file
View File

@@ -0,0 +1,230 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import {
Heart, Coffee, ArrowLeft, Loader2, Shield, Sparkles,
Check, ChevronRight, Lightbulb,
} from 'lucide-react'
import { Logo } from '@/components/ui/logo'
const tiers = [
{ value: 5, label: 'Kaffee', emoji: '\u2615', desc: 'Ein Kaffee für die nächste Coding-Session' },
{ value: 10, label: 'Pizza', emoji: '\uD83C\uDF55', desc: 'Pizza-Abend nach einem langen Entwicklungstag' },
{ value: 25, label: 'Server', emoji: '\uD83D\uDDA5\uFE0F', desc: 'Hilft die monatlichen Serverkosten zu decken' },
{ value: 50, label: 'Feature', emoji: '\uD83D\uDE80', desc: 'Finanziert die Entwicklung eines neuen Features' },
]
export default function SpendenPage() {
const [amount, setAmount] = useState(10)
const [name, setName] = useState('')
const [message, setMessage] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [stripeConfigured, setStripeConfigured] = useState<boolean | null>(null)
const [error, setError] = useState('')
useEffect(() => {
fetch('/api/donate/config')
.then(r => r.json())
.then(data => setStripeConfigured(data.configured))
.catch(() => setStripeConfigured(false))
}, [])
const activeTier = tiers.reduce((prev, curr) =>
amount >= curr.value ? curr : prev
, tiers[0])
const handleDonate = async () => {
setIsLoading(true)
setError('')
try {
const res = await fetch('/api/donate/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount, name: name.trim() || undefined, message: message.trim() || undefined }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Fehler beim Erstellen der Zahlung')
if (data.url) {
window.location.href = data.url
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
setIsLoading(false)
}
}
return (
<div className="min-h-screen bg-white">
{/* Navigation */}
<nav className="border-b border-gray-100 bg-white">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<Link href="/" className="flex items-center gap-2">
<Logo size={36} />
<span className="font-bold text-xl text-gray-900">Lageplan</span>
</Link>
<Link href="/">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4 mr-1" />
Zurück
</Button>
</Link>
</div>
</div>
</nav>
<div className="max-w-3xl mx-auto px-4 py-12">
{/* Header */}
<div className="text-center mb-12">
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-red-500 to-red-700 flex items-center justify-center mx-auto mb-6 shadow-xl">
<Heart className="w-10 h-10 text-white" />
</div>
<h1 className="text-4xl font-extrabold text-gray-900 mb-4">Lageplan unterstützen</h1>
<p className="text-lg text-gray-500 max-w-xl mx-auto">
Lageplan ist ein kostenloses Herzensprojekt von einem Feuerwehrmann für Feuerwehrleute.
Mit deiner Spende hilfst du, die Weiterentwicklung und den Betrieb zu finanzieren.
</p>
</div>
{/* Why donate */}
<div className="bg-gradient-to-r from-red-50 to-orange-50 rounded-2xl p-6 md:p-8 border border-red-100 mb-10">
<div className="flex items-start gap-4">
<Lightbulb className="w-7 h-7 text-red-500 shrink-0 mt-0.5" />
<div>
<h3 className="font-bold text-gray-900 mb-2">Wohin fliesst deine Spende?</h3>
<ul className="space-y-2 text-sm text-gray-600">
<li className="flex items-center gap-2"><Shield className="w-4 h-4 text-red-500 shrink-0" /> Server-Hosting in der Schweiz (monatliche Kosten)</li>
<li className="flex items-center gap-2"><Sparkles className="w-4 h-4 text-red-500 shrink-0" /> Entwicklung neuer Features (PWA, Vorlagen, Einsatzpläne)</li>
<li className="flex items-center gap-2"><Coffee className="w-4 h-4 text-red-500 shrink-0" /> Domain, SSL-Zertifikate und Infrastruktur</li>
</ul>
</div>
</div>
</div>
{/* Donation form */}
<div className="bg-white rounded-2xl border border-gray-200 shadow-lg p-6 md:p-8">
<h2 className="text-xl font-bold text-gray-900 mb-6">Betrag wählen</h2>
{/* Quick amounts */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8">
{tiers.map(tier => (
<button
key={tier.value}
onClick={() => setAmount(tier.value)}
className={`rounded-xl p-4 text-center transition-all border-2 ${
amount === tier.value
? 'border-red-500 bg-red-50 shadow-md'
: 'border-gray-100 hover:border-gray-200 hover:bg-gray-50'
}`}
>
<span className="text-2xl block mb-1">{tier.emoji}</span>
<span className="text-lg font-bold text-gray-900">CHF {tier.value}</span>
<span className="text-xs text-gray-500 block mt-0.5">{tier.label}</span>
</button>
))}
</div>
{/* Slider */}
<div className="mb-6">
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-gray-500">Oder wähle einen eigenen Betrag:</span>
<span className="text-xl font-bold text-red-600">CHF {amount}</span>
</div>
<input
type="range"
min="5"
max="200"
step="5"
value={amount}
onChange={e => setAmount(parseInt(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-red-600"
/>
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>CHF 5</span>
<span>CHF 200</span>
</div>
</div>
{/* Current tier description */}
<div className="bg-gray-50 rounded-xl p-4 mb-6 text-center">
<span className="text-3xl">{activeTier.emoji}</span>
<p className="text-sm text-gray-600 mt-2">{activeTier.desc}</p>
</div>
{/* Optional name & message */}
<div className="space-y-4 mb-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Dein Name (optional)</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="Anonym"
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nachricht (optional)</label>
<textarea
value={message}
onChange={e => setMessage(e.target.value)}
placeholder="z.B. Weiter so! Tolles Projekt."
rows={2}
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent resize-none"
/>
</div>
</div>
{/* Error */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 rounded-lg p-3 mb-4 text-sm">
{error}
</div>
)}
{/* CTA */}
{stripeConfigured === false ? (
<div className="bg-yellow-50 border border-yellow-200 text-yellow-800 rounded-lg p-4 text-sm text-center">
Spenden sind aktuell nicht möglich. Die Zahlungsabwicklung wird gerade eingerichtet.
</div>
) : (
<Button
onClick={handleDonate}
disabled={isLoading || stripeConfigured === null}
className="w-full bg-red-600 hover:bg-red-700 h-12 text-base"
>
{isLoading ? (
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Weiterleitung zu Stripe...</>
) : (
<><Heart className="w-4 h-4 mr-2" /> CHF {amount} spenden</>
)}
</Button>
)}
<div className="mt-4 flex flex-wrap items-center justify-center gap-4 text-xs text-gray-400">
<span className="flex items-center gap-1"><Shield className="w-3.5 h-3.5" /> Sichere Zahlung via Stripe</span>
<span className="flex items-center gap-1"><Check className="w-3.5 h-3.5" /> Kreditkarte & Twint</span>
</div>
</div>
{/* Trust */}
<div className="mt-10 text-center">
<p className="text-sm text-gray-500">
100% deiner Spende fliesst direkt in Serverkosten und Weiterentwicklung.
<br />Kein Unternehmen, keine Investoren nur ein Feuerwehrmann mit einer Idee.
</p>
<div className="mt-6">
<Link href="/">
<Button variant="outline" size="sm">
<ArrowLeft className="w-4 h-4 mr-1" />
Zurück zur Startseite
</Button>
</Link>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,476 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from '@/components/ui/dialog'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useToast } from '@/components/ui/use-toast'
import {
Shield, Trash2, UserPlus, Loader2, Ban, CheckCircle, Clock, AlertTriangle, Copy, Mail, MailX, Upload, X,
} from 'lucide-react'
interface TenantDetail {
id: string
name: string
slug: string
description: string | null
isActive: boolean
contactEmail: string | null
contactPhone: string | null
address: string | null
plan: string
subscriptionStatus: string
trialEndsAt: string | null
subscriptionEndsAt: string | null
maxUsers: number
maxProjects: number
logoUrl: string | null
notes: string | null
createdAt: string
memberships: { id: string; role: string; user: { id: string; email: string; name: string; role: string; lastLoginAt: string | null } }[]
_count: { projects: number; memberships: number }
}
const PLANS = [
{ value: 'FREE', label: 'Free', desc: '5 Benutzer, 10 Projekte', color: 'bg-gray-100 text-gray-700' },
{ value: 'PRO', label: 'Pro', desc: 'CHF 45/Monat — Unbegrenzte Benutzer & Projekte', color: 'bg-blue-100 text-blue-700' },
]
const STATUSES = [
{ value: 'ACTIVE', label: 'Aktiv', icon: CheckCircle, color: 'text-green-600' },
{ value: 'SUSPENDED', label: 'Gesperrt', icon: Ban, color: 'text-red-600' },
{ value: 'EXPIRED', label: 'Abgelaufen', icon: AlertTriangle, color: 'text-orange-600' },
{ value: 'CANCELLED', label: 'Gekündigt', icon: Ban, color: 'text-gray-600' },
]
interface Props {
tenantId: string | null
open: boolean
onOpenChange: (open: boolean) => void
onUpdated: () => void
}
export function TenantDetailDialog({ tenantId, open, onOpenChange, onUpdated }: Props) {
const { toast } = useToast()
const [loading, setLoading] = useState(false)
const [tenant, setTenant] = useState<TenantDetail | null>(null)
const [tab, setTab] = useState('info')
// Editable fields
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [contactEmail, setContactEmail] = useState('')
const [contactPhone, setContactPhone] = useState('')
const [address, setAddress] = useState('')
const [plan, setPlan] = useState('FREE')
const [status, setStatus] = useState('TRIAL')
const [maxUsers, setMaxUsers] = useState(5)
const [maxProjects, setMaxProjects] = useState(10)
const [trialEndsAt, setTrialEndsAt] = useState('')
const [subscriptionEndsAt, setSubscriptionEndsAt] = useState('')
const [notes, setNotes] = useState('')
// Add member (NEW user)
const [newUserName, setNewUserName] = useState('')
const [newUserEmail, setNewUserEmail] = useState('')
const [newUserRole, setNewUserRole] = useState('OPERATOR')
const [addingUser, setAddingUser] = useState(false)
const [createdUserInfo, setCreatedUserInfo] = useState<{ email: string; tempPassword: string; emailSent: boolean } | null>(null)
// Logo
const [uploadingLogo, setUploadingLogo] = useState(false)
// Delete confirmation
const [confirmDelete, setConfirmDelete] = useState(false)
const [deleteSlug, setDeleteSlug] = useState('')
useEffect(() => {
if (tenantId && open) {
fetchTenant()
setCreatedUserInfo(null)
setConfirmDelete(false)
setDeleteSlug('')
}
}, [tenantId, open])
const fetchTenant = async () => {
if (!tenantId) return
setLoading(true)
try {
const res = await fetch(`/api/admin/tenants/${tenantId}`)
if (res.ok) {
const data = await res.json()
const t = data.tenant
setTenant(t)
setName(t.name)
setDescription(t.description || '')
setContactEmail(t.contactEmail || '')
setContactPhone(t.contactPhone || '')
setAddress(t.address || '')
setPlan(t.plan)
setStatus(t.subscriptionStatus)
setMaxUsers(t.maxUsers)
setMaxProjects(t.maxProjects)
setTrialEndsAt(t.trialEndsAt ? t.trialEndsAt.split('T')[0] : '')
setSubscriptionEndsAt(t.subscriptionEndsAt ? t.subscriptionEndsAt.split('T')[0] : '')
setNotes(t.notes || '')
}
} catch (e) {
console.error(e)
} finally {
setLoading(false)
}
}
const handleSave = async () => {
if (!tenantId) return
try {
const res = await fetch(`/api/admin/tenants/${tenantId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name, description: description || null,
contactEmail: contactEmail || null, contactPhone: contactPhone || null, address: address || null,
plan, subscriptionStatus: status, maxUsers, maxProjects,
trialEndsAt: trialEndsAt || null, subscriptionEndsAt: subscriptionEndsAt || null,
notes: notes || null,
}),
})
if (res.ok) {
toast({ title: 'Mandant aktualisiert' })
onUpdated()
fetchTenant()
} else {
const err = await res.json()
throw new Error(err.error)
}
} catch (error) {
toast({ title: 'Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' })
}
}
const handleSuspend = async () => {
if (!tenantId) return
const newStatus = status === 'SUSPENDED' ? 'ACTIVE' : 'SUSPENDED'
try {
const res = await fetch(`/api/admin/tenants/${tenantId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subscriptionStatus: newStatus, isActive: newStatus !== 'SUSPENDED' }),
})
if (res.ok) {
toast({ title: newStatus === 'SUSPENDED' ? 'Mandant gesperrt' : 'Mandant aktiviert' })
setStatus(newStatus)
onUpdated()
fetchTenant()
}
} catch {
toast({ title: 'Fehler', variant: 'destructive' })
}
}
const handleDeleteTenant = async () => {
if (!tenantId || deleteSlug !== tenant?.slug) return
try {
const res = await fetch(`/api/admin/tenants/${tenantId}`, { method: 'DELETE' })
if (res.ok) {
toast({ title: 'Mandant gelöscht' })
onOpenChange(false)
onUpdated()
} else {
const err = await res.json()
throw new Error(err.error)
}
} catch (error) {
toast({ title: 'Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' })
}
}
const handleAddMember = async () => {
if (!tenantId || !newUserName || !newUserEmail) return
setAddingUser(true)
setCreatedUserInfo(null)
try {
const res = await fetch(`/api/admin/tenants/${tenantId}/members`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newUserName, email: newUserEmail, role: newUserRole }),
})
const data = await res.json()
if (res.ok) {
setCreatedUserInfo({ email: newUserEmail.toLowerCase(), tempPassword: data.tempPassword, emailSent: data.emailSent })
toast({ title: 'Benutzer erstellt und hinzugefügt' })
setNewUserName('')
setNewUserEmail('')
setNewUserRole('OPERATOR')
fetchTenant()
onUpdated()
} else {
throw new Error(data.error)
}
} catch (error) {
toast({ title: 'Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' })
} finally {
setAddingUser(false)
}
}
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!tenantId || !e.target.files?.[0]) return
setUploadingLogo(true)
try {
const formData = new FormData()
formData.append('logo', e.target.files[0])
const res = await fetch(`/api/admin/tenants/${tenantId}/logo`, { method: 'POST', body: formData })
const data = await res.json()
if (res.ok) {
toast({ title: 'Logo hochgeladen' })
fetchTenant()
} else {
throw new Error(data.error)
}
} catch (error) {
toast({ title: 'Fehler', description: error instanceof Error ? error.message : 'Upload fehlgeschlagen', variant: 'destructive' })
} finally {
setUploadingLogo(false)
e.target.value = ''
}
}
const handleLogoDelete = async () => {
if (!tenantId) return
try {
const res = await fetch(`/api/admin/tenants/${tenantId}/logo`, { method: 'DELETE' })
if (res.ok) {
toast({ title: 'Logo entfernt' })
fetchTenant()
}
} catch {
toast({ title: 'Fehler', variant: 'destructive' })
}
}
const handleRemoveMember = async (membershipId: string) => {
if (!tenantId || !confirm('Benutzer wirklich entfernen? Der Benutzer und seine Daten werden gelöscht.')) return
try {
const res = await fetch(`/api/admin/tenants/${tenantId}/members?membershipId=${membershipId}&deleteUser=true`, { method: 'DELETE' })
if (res.ok) {
toast({ title: 'Benutzer entfernt' })
fetchTenant()
onUpdated()
}
} catch {
toast({ title: 'Fehler', variant: 'destructive' })
}
}
const statusInfo = STATUSES.find(s => s.value === status)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="w-5 h-5" />
{tenant?.name || 'Mandant'}
{statusInfo && (
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUSES.find(s => s.value === tenant?.subscriptionStatus)?.color || ''}`}>
{statusInfo.label}
</span>
)}
</DialogTitle>
</DialogHeader>
{loading ? (
<div className="flex justify-center py-8"><Loader2 className="w-6 h-6 animate-spin" /></div>
) : tenant ? (
<Tabs value={tab} onValueChange={setTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="info">Stammdaten</TabsTrigger>
<TabsTrigger value="members">Benutzer ({tenant.memberships.length})</TabsTrigger>
</TabsList>
{/* === INFO TAB === */}
<TabsContent value="info" className="space-y-3 mt-3">
{/* Logo */}
<div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-lg border bg-muted flex items-center justify-center overflow-hidden shrink-0">
{tenant.logoUrl ? (
<img src={tenant.logoUrl.startsWith('/') ? tenant.logoUrl : `/api/admin/tenants/${tenantId}/logo/serve`} alt="Logo" className="w-full h-full object-contain" />
) : (
<Shield className="w-8 h-8 text-muted-foreground" />
)}
</div>
<div className="space-y-1">
<Label className="text-xs">Logo</Label>
<div className="flex gap-2">
<Button variant="outline" size="sm" className="relative" disabled={uploadingLogo}>
{uploadingLogo ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <Upload className="w-3.5 h-3.5 mr-1" />}
{tenant.logoUrl ? 'Ändern' : 'Hochladen'}
<input type="file" accept="image/png,image/jpeg,image/svg+xml,image/webp" onChange={handleLogoUpload} className="absolute inset-0 opacity-0 cursor-pointer" />
</Button>
{tenant.logoUrl && (
<Button variant="ghost" size="sm" className="text-destructive" onClick={handleLogoDelete}>
<X className="w-3.5 h-3.5 mr-1" /> Entfernen
</Button>
)}
</div>
<p className="text-[11px] text-muted-foreground">PNG, JPEG, SVG oder WebP, max. 2 MB</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div><Label className="text-xs">Name</Label><Input value={name} onChange={e => setName(e.target.value)} /></div>
<div><Label className="text-xs">Slug</Label><Input value={tenant.slug} disabled className="font-mono bg-muted" /></div>
</div>
<div><Label className="text-xs">Beschreibung</Label><Input value={description} onChange={e => setDescription(e.target.value)} /></div>
<div className="grid grid-cols-2 gap-3">
<div><Label className="text-xs">Kontakt E-Mail</Label><Input type="email" value={contactEmail} onChange={e => setContactEmail(e.target.value)} placeholder="kontakt@firma.ch" /></div>
<div><Label className="text-xs">Kontakt Telefon</Label><Input value={contactPhone} onChange={e => setContactPhone(e.target.value)} placeholder="+41 ..." /></div>
</div>
<div><Label className="text-xs">Adresse</Label><Input value={address} onChange={e => setAddress(e.target.value)} placeholder="Strasse, PLZ Ort" /></div>
<div><Label className="text-xs">Interne Notizen</Label><Input value={notes} onChange={e => setNotes(e.target.value)} placeholder="Interne Anmerkungen..." /></div>
<div className="flex gap-2 pt-2">
<Button onClick={handleSave} disabled={!name.trim()}>Speichern</Button>
<Button variant={status === 'SUSPENDED' ? 'default' : 'destructive'} onClick={handleSuspend}>
{status === 'SUSPENDED' ? <><CheckCircle className="w-4 h-4 mr-1" /> Aktivieren</> : <><Ban className="w-4 h-4 mr-1" /> Sperren</>}
</Button>
</div>
{/* Delete section */}
<div className="border-t pt-4 mt-4">
{!confirmDelete ? (
<Button variant="ghost" className="text-destructive hover:text-destructive text-xs" onClick={() => setConfirmDelete(true)}>
<Trash2 className="w-3.5 h-3.5 mr-1" /> Mandant löschen...
</Button>
) : (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 space-y-3">
<p className="text-sm font-medium text-red-800">Mandant unwiderruflich löschen?</p>
<p className="text-xs text-red-600">Alle Benutzer, Projekte und Daten dieses Mandanten werden gelöscht. Geben Sie <strong>{tenant.slug}</strong> ein zur Bestätigung:</p>
<Input
value={deleteSlug}
onChange={e => setDeleteSlug(e.target.value)}
placeholder={tenant.slug}
className="font-mono text-sm"
/>
<div className="flex gap-2">
<Button variant="destructive" size="sm" onClick={handleDeleteTenant} disabled={deleteSlug !== tenant.slug}>
<Trash2 className="w-3.5 h-3.5 mr-1" /> Endgültig löschen
</Button>
<Button variant="outline" size="sm" onClick={() => { setConfirmDelete(false); setDeleteSlug('') }}>Abbrechen</Button>
</div>
</div>
)}
</div>
</TabsContent>
{/* === MEMBERS TAB === */}
<TabsContent value="members" className="space-y-4 mt-3">
<div className="border rounded-lg p-4 space-y-3 bg-muted/20">
<h4 className="text-sm font-semibold flex items-center gap-1.5"><UserPlus className="w-4 h-4" /> Neuen Benutzer erstellen</h4>
<div className="grid grid-cols-2 gap-3">
<div><Label className="text-xs">Name</Label><Input value={newUserName} onChange={e => setNewUserName(e.target.value)} placeholder="Vor- und Nachname" /></div>
<div><Label className="text-xs">E-Mail</Label><Input type="email" value={newUserEmail} onChange={e => setNewUserEmail(e.target.value)} placeholder="name@feuerwehr.ch" /></div>
</div>
<div className="flex gap-3 items-end">
<div className="w-44">
<Label className="text-xs">Rolle</Label>
<Select value={newUserRole} onValueChange={setNewUserRole}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="TENANT_ADMIN">Admin</SelectItem>
<SelectItem value="OPERATOR">Bediener</SelectItem>
<SelectItem value="VIEWER">Betrachter</SelectItem>
</SelectContent>
</Select>
</div>
<Button onClick={handleAddMember} disabled={!newUserName.trim() || !newUserEmail.trim() || addingUser}>
{addingUser ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <UserPlus className="w-4 h-4 mr-1.5" />}
Benutzer erstellen
</Button>
</div>
<p className="text-[11px] text-muted-foreground">Ein temporäres Passwort wird automatisch generiert. Falls SMTP konfiguriert ist, wird eine Willkommens-E-Mail gesendet.</p>
</div>
{/* Created user info */}
{createdUserInfo && (
<div className="border rounded-lg p-4 bg-green-50 border-green-200 space-y-2">
<h4 className="text-sm font-semibold text-green-800 flex items-center gap-1.5">
<CheckCircle className="w-4 h-4" /> Benutzer erstellt
</h4>
<div className="grid grid-cols-2 gap-2 text-sm">
<div><span className="text-green-700 font-medium">E-Mail:</span> {createdUserInfo.email}</div>
<div className="flex items-center gap-2">
<span className="text-green-700 font-medium">Passwort:</span>
<code className="bg-white px-2 py-0.5 rounded text-sm font-mono">{createdUserInfo.tempPassword}</code>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => {
navigator.clipboard.writeText(createdUserInfo.tempPassword)
toast({ title: 'Passwort kopiert' })
}}>
<Copy className="w-3.5 h-3.5" />
</Button>
</div>
</div>
<div className="flex items-center gap-1.5 text-xs">
{createdUserInfo.emailSent ? (
<><Mail className="w-3.5 h-3.5 text-green-600" /><span className="text-green-700">Willkommens-E-Mail wurde gesendet</span></>
) : (
<><MailX className="w-3.5 h-3.5 text-orange-500" /><span className="text-orange-600">Keine E-Mail gesendet (SMTP nicht konfiguriert). Bitte Passwort manuell weitergeben.</span></>
)}
</div>
</div>
)}
{/* Members list */}
<div className="border rounded-lg divide-y">
{tenant.memberships.length === 0 ? (
<div className="text-center text-muted-foreground py-6 text-sm">Keine Benutzer zugeordnet</div>
) : tenant.memberships.map(m => (
<div key={m.id} className="flex items-center justify-between px-3 py-2 text-sm">
<div className="flex items-center gap-2">
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-[10px] font-bold text-white ${
m.role === 'TENANT_ADMIN' ? 'bg-orange-500' : m.role === 'OPERATOR' ? 'bg-blue-500' : 'bg-gray-400'
}`}>{m.user.name.charAt(0).toUpperCase()}</div>
<div>
<p className="font-medium">{m.user.name}</p>
<p className="text-xs text-muted-foreground">{m.user.email}</p>
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-[10px] text-muted-foreground" title="Zuletzt online">
{m.user.lastLoginAt
? new Date(m.user.lastLoginAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: '2-digit' }) + ' ' + new Date(m.user.lastLoginAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
: 'Nie eingeloggt'}
</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${
m.role === 'TENANT_ADMIN' ? 'bg-orange-100 text-orange-700' :
m.role === 'OPERATOR' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-700'
}`}>{m.role === 'TENANT_ADMIN' ? 'Admin' : m.role === 'OPERATOR' ? 'Bediener' : 'Betrachter'}</span>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => handleRemoveMember(m.id)}>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
{tenant.memberships.length} Benutzer
</p>
</TabsContent>
{/* Subscription tab removed — SERVER_ADMIN only suspends/activates orgs */}
</Tabs>
) : (
<div className="text-center text-muted-foreground py-8">Mandant nicht gefunden</div>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,313 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Plus, Trash2, Save, Star, Loader2 } from 'lucide-react'
interface HoseType {
id: string
name: string
diameterMm: number
lengthPerPieceM: number
flowRateLpm: number
frictionCoeff: number
description: string | null
isDefault: boolean
isActive: boolean
sortOrder: number
}
interface HoseSettingsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function HoseSettingsDialog({ open, onOpenChange }: HoseSettingsDialogProps) {
const [hoseTypes, setHoseTypes] = useState<HoseType[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [showNewForm, setShowNewForm] = useState(false)
const [formData, setFormData] = useState({
name: '',
diameterMm: '',
lengthPerPieceM: '10',
flowRateLpm: '',
frictionCoeff: '',
description: '',
isDefault: false,
})
const fetchHoseTypes = useCallback(async () => {
setIsLoading(true)
try {
const res = await fetch('/api/hose-types')
if (res.ok) {
const data = await res.json()
setHoseTypes(data.hoseTypes || [])
}
} catch (err) {
console.error('Failed to load hose types:', err)
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
if (open) fetchHoseTypes()
}, [open, fetchHoseTypes])
const resetForm = () => {
setFormData({ name: '', diameterMm: '', lengthPerPieceM: '10', flowRateLpm: '', frictionCoeff: '', description: '', isDefault: false })
setEditingId(null)
setShowNewForm(false)
}
const startEdit = (ht: HoseType) => {
setFormData({
name: ht.name,
diameterMm: String(ht.diameterMm),
lengthPerPieceM: String(ht.lengthPerPieceM),
flowRateLpm: String(ht.flowRateLpm),
frictionCoeff: String(ht.frictionCoeff),
description: ht.description || '',
isDefault: ht.isDefault,
})
setEditingId(ht.id)
setShowNewForm(false)
}
const handleSave = async () => {
if (!formData.name || !formData.diameterMm || !formData.flowRateLpm || !formData.frictionCoeff) return
setIsSaving(true)
try {
const url = editingId ? `/api/hose-types/${editingId}` : '/api/hose-types'
const method = editingId ? 'PUT' : 'POST'
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
})
if (res.ok) {
await fetchHoseTypes()
resetForm()
}
} catch (err) {
console.error('Save failed:', err)
} finally {
setIsSaving(false)
}
}
const handleDelete = async (id: string) => {
if (!confirm('Schlauchtyp wirklich löschen?')) return
try {
await fetch(`/api/hose-types/${id}`, { method: 'DELETE' })
await fetchHoseTypes()
if (editingId === id) resetForm()
} catch (err) {
console.error('Delete failed:', err)
}
}
const handleSetDefault = async (id: string) => {
try {
await fetch(`/api/hose-types/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isDefault: true }),
})
await fetchHoseTypes()
} catch (err) {
console.error('Set default failed:', err)
}
}
const frictionPer100m = (coeff: string, flow: string) => {
const c = parseFloat(coeff)
const q = parseFloat(flow)
if (!c || !q) return '-'
return (c * Math.pow(q / 100, 2)).toFixed(2)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-lg">
🚒 Schlauchtypen verwalten
</DialogTitle>
</DialogHeader>
<div className="text-sm text-muted-foreground mb-2">
Konfiguriere die Schlauchtypen für die Druckberechnung im Messwerkzeug. Der Standard-Schlauch wird automatisch für neue Berechnungen verwendet.
</div>
<ScrollArea className="flex-1 max-h-[50vh]">
{isLoading ? (
<div className="flex items-center justify-center py-8 gap-2 text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" /> Laden...
</div>
) : hoseTypes.length === 0 ? (
<div className="text-center text-muted-foreground py-8 text-sm">
Keine Schlauchtypen vorhanden. Erstelle den ersten!
</div>
) : (
<div className="space-y-2">
{hoseTypes.map((ht) => (
<div
key={ht.id}
className={`p-3 border rounded-lg cursor-pointer transition-colors ${
editingId === ht.id ? 'border-primary bg-primary/5' : 'hover:bg-accent'
}`}
onClick={() => startEdit(ht)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm">{ht.name}</span>
{ht.isDefault && (
<span className="text-xs bg-primary/20 text-primary px-2 py-0.5 rounded-full font-medium">Standard</span>
)}
</div>
<div className="flex items-center gap-1">
{!ht.isDefault && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => { e.stopPropagation(); handleSetDefault(ht.id) }}
title="Als Standard setzen"
>
<Star className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={(e) => { e.stopPropagation(); handleDelete(ht.id) }}
title="Löschen"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-4 gap-2 mt-2 text-xs text-muted-foreground">
<div><span className="font-medium"></span> {ht.diameterMm}mm</div>
<div><span className="font-medium">L</span> {ht.lengthPerPieceM}m/Stk</div>
<div><span className="font-medium">Q</span> {ht.flowRateLpm} l/min</div>
<div><span className="font-medium">R</span> {frictionPer100m(String(ht.frictionCoeff), String(ht.flowRateLpm))} bar/100m</div>
</div>
{ht.description && (
<p className="text-xs text-muted-foreground mt-1">{ht.description}</p>
)}
</div>
))}
</div>
)}
</ScrollArea>
{/* Edit/Create Form */}
{(editingId || showNewForm) && (
<div className="border-t pt-3 mt-2">
<h4 className="text-sm font-semibold mb-2">{editingId ? 'Bearbeiten' : 'Neuer Schlauchtyp'}</h4>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-muted-foreground">Name</label>
<Input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="z.B. 55mm Transportleitung"
className="h-9 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Durchmesser (mm)</label>
<Input
type="number"
value={formData.diameterMm}
onChange={(e) => setFormData({ ...formData, diameterMm: e.target.value })}
placeholder="55"
className="h-9 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Schlauchlänge pro Stück (m)</label>
<Input
type="number"
value={formData.lengthPerPieceM}
onChange={(e) => setFormData({ ...formData, lengthPerPieceM: e.target.value })}
placeholder="10"
className="h-9 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Durchfluss (l/min)</label>
<Input
type="number"
value={formData.flowRateLpm}
onChange={(e) => setFormData({ ...formData, flowRateLpm: e.target.value })}
placeholder="500"
className="h-9 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Reibungskoeffizient (c)</label>
<Input
type="number"
step="0.001"
value={formData.frictionCoeff}
onChange={(e) => setFormData({ ...formData, frictionCoeff: e.target.value })}
placeholder="0.034"
className="h-9 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">
Reibung/100m: <strong>{frictionPer100m(formData.frictionCoeff, formData.flowRateLpm)} bar</strong>
</label>
<Input
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Beschreibung (optional)"
className="h-9 text-sm"
/>
</div>
</div>
<div className="flex items-center gap-2 mt-3">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={formData.isDefault}
onChange={(e) => setFormData({ ...formData, isDefault: e.target.checked })}
className="w-4 h-4"
/>
Als Standard setzen
</label>
<div className="flex-1" />
<Button variant="outline" size="sm" onClick={resetForm}>Abbrechen</Button>
<Button size="sm" onClick={handleSave} disabled={isSaving}>
<Save className="w-4 h-4 mr-1" />
{isSaving ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</div>
)}
{/* Add button */}
{!showNewForm && !editingId && (
<Button
variant="outline"
className="w-full mt-2"
onClick={() => { resetForm(); setShowNewForm(true) }}
>
<Plus className="w-4 h-4 mr-2" /> Neuen Schlauchtyp hinzufügen
</Button>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,88 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
interface LineLabelDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onConfirm: (label: string) => void
onSkip: () => void
lineType: 'linestring' | 'arrow' | 'polygon'
}
export function LineLabelDialog({ open, onOpenChange, onConfirm, onSkip, lineType }: LineLabelDialogProps) {
const [label, setLabel] = useState('')
useEffect(() => {
if (open) setLabel('')
}, [open])
const handleConfirm = () => {
onConfirm(label.trim())
setLabel('')
}
const handleSkip = () => {
onSkip()
setLabel('')
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
if (label.trim()) {
handleConfirm()
} else {
handleSkip()
}
}
}
const title = lineType === 'polygon' ? 'Fläche beschriften' : 'Leitung beschriften'
const placeholder = lineType === 'polygon'
? 'z.B. Brandzone, Sperrgebiet...'
: 'z.B. 1, L2, Zuleitung...'
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm top-[12%] translate-y-0">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<div className="space-y-1.5">
<Label htmlFor="line-label">Bezeichnung (optional)</Label>
<Input
id="line-label"
value={label}
onChange={(e) => setLabel(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
autoFocus
/>
</div>
<p className="text-xs text-muted-foreground">
Wird am Mittelpunkt der Leitung auf der Karte angezeigt. Leer lassen für keine Beschriftung.
</p>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={handleSkip}>
Ohne Label
</Button>
<Button onClick={handleConfirm} disabled={!label.trim()}>
Beschriften
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,348 @@
'use client'
import { useState, useRef, useEffect, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { MapPin, Loader2, X } from 'lucide-react'
import type { Project } from '@/app/app/page'
interface NominatimResult {
place_id: number
display_name: string
lat: string
lon: string
type: string
address?: {
road?: string
house_number?: string
postcode?: string
city?: string
town?: string
village?: string
municipality?: string
state?: string
}
}
interface ProjectDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onProjectCreated: (project: Project) => void
}
export function ProjectDialog({
open,
onOpenChange,
onProjectCreated,
}: ProjectDialogProps) {
const [title, setTitle] = useState('')
const [location, setLocation] = useState('')
const [description, setDescription] = useState('')
const [einsatzleiter, setEinsatzleiter] = useState('')
const [journalfuehrer, setJournalfuehrer] = useState('')
const [isCreating, setIsCreating] = useState(false)
const { toast } = useToast()
// Address autocomplete state
const [suggestions, setSuggestions] = useState<NominatimResult[]>([])
const [isSearching, setIsSearching] = useState(false)
const [showSuggestions, setShowSuggestions] = useState(false)
const [selectedCoords, setSelectedCoords] = useState<{ lat: number; lng: number } | null>(null)
const debounceRef = useRef<NodeJS.Timeout | null>(null)
const suggestionsRef = useRef<HTMLDivElement>(null)
// Debounced Nominatim search
const searchAddress = useCallback((query: string) => {
if (debounceRef.current) clearTimeout(debounceRef.current)
if (query.length < 3) {
setSuggestions([])
setShowSuggestions(false)
return
}
debounceRef.current = setTimeout(async () => {
setIsSearching(true)
try {
const res = await fetch(
`https://nominatim.openstreetmap.org/search?` +
`q=${encodeURIComponent(query)}&format=json&addressdetails=1&limit=5&countrycodes=ch,de,at,li,fr,it`
)
if (res.ok) {
const data: NominatimResult[] = await res.json()
setSuggestions(data)
setShowSuggestions(data.length > 0)
}
} catch (e) {
console.warn('Nominatim search failed:', e)
} finally {
setIsSearching(false)
}
}, 350)
}, [])
const handleLocationChange = (value: string) => {
setLocation(value)
setSelectedCoords(null) // Clear coords when typing
searchAddress(value)
}
const handleSelectSuggestion = (result: NominatimResult) => {
// Build a clean display name
const addr = result.address
let displayName = result.display_name
if (addr) {
const parts: string[] = []
if (addr.road) {
parts.push(addr.road + (addr.house_number ? ' ' + addr.house_number : ''))
}
const city = addr.city || addr.town || addr.village || addr.municipality
if (addr.postcode && city) {
parts.push(`${addr.postcode} ${city}`)
} else if (city) {
parts.push(city)
}
if (parts.length > 0) displayName = parts.join(', ')
}
setLocation(displayName)
setSelectedCoords({ lat: parseFloat(result.lat), lng: parseFloat(result.lon) })
setSuggestions([])
setShowSuggestions(false)
}
// Close suggestions on click outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (suggestionsRef.current && !suggestionsRef.current.contains(e.target as Node)) {
setShowSuggestions(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleCreate = async () => {
if (!title.trim()) {
toast({
title: 'Fehler',
description: 'Bitte geben Sie einen Titel ein.',
variant: 'destructive',
})
return
}
setIsCreating(true)
try {
const body: any = {
title: title.trim(),
location: location.trim() || undefined,
description: description.trim() || undefined,
einsatzleiter: einsatzleiter.trim() || undefined,
journalfuehrer: journalfuehrer.trim() || undefined,
}
// If an address was selected with coordinates, set mapCenter
if (selectedCoords) {
body.mapCenter = { lng: selectedCoords.lng, lat: selectedCoords.lat }
body.mapZoom = 17
}
const res = await fetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || 'Einsatz konnte nicht erstellt werden')
}
const data = await res.json()
onProjectCreated(data.project)
// Reset form
setTitle('')
setLocation('')
setDescription('')
setEinsatzleiter('')
setJournalfuehrer('')
setSelectedCoords(null)
setSuggestions([])
} catch (error) {
toast({
title: 'Fehler',
description: error instanceof Error ? error.message : 'Unbekannter Fehler',
variant: 'destructive',
})
} finally {
setIsCreating(false)
}
}
const handleClose = () => {
if (!isCreating) {
setTitle('')
setLocation('')
setDescription('')
setEinsatzleiter('')
setJournalfuehrer('')
setSelectedCoords(null)
setSuggestions([])
onOpenChange(false)
}
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Neuer Einsatz</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="project-title">Titel *</Label>
<Input
id="project-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="z.B. Wohnungsbrand Musterstrasse"
disabled={isCreating}
/>
</div>
<div className="space-y-2">
<Label htmlFor="project-location">
Einsatzort
{selectedCoords && (
<span className="ml-2 text-xs text-green-600 font-normal inline-flex items-center gap-1">
<MapPin className="w-3 h-3" /> Koordinaten gesetzt
</span>
)}
</Label>
<div className="relative" ref={suggestionsRef}>
<div className="relative">
<Input
id="project-location"
value={location}
onChange={(e) => handleLocationChange(e.target.value)}
placeholder="Adresse eingeben — z.B. Bahnhofstrasse 1, Zürich"
disabled={isCreating}
autoComplete="off"
className={selectedCoords ? 'pr-8 border-green-300 focus:ring-green-500' : ''}
/>
{isSearching && (
<Loader2 className="absolute right-2.5 top-2.5 w-4 h-4 animate-spin text-muted-foreground" />
)}
{selectedCoords && !isSearching && (
<button
type="button"
onClick={() => { setSelectedCoords(null); setLocation('') }}
className="absolute right-2.5 top-2.5 text-muted-foreground hover:text-foreground"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Autocomplete dropdown */}
{showSuggestions && suggestions.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-60 overflow-auto">
{suggestions.map((s) => {
const addr = s.address
const city = addr?.city || addr?.town || addr?.village || addr?.municipality || ''
return (
<button
key={s.place_id}
type="button"
onClick={() => handleSelectSuggestion(s)}
className="w-full text-left px-3 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-800 border-b border-gray-100 dark:border-gray-800 last:border-0 transition-colors"
>
<div className="flex items-start gap-2">
<MapPin className="w-4 h-4 text-red-500 mt-0.5 shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{addr?.road
? `${addr.road}${addr.house_number ? ' ' + addr.house_number : ''}`
: s.display_name.split(',')[0]
}
</p>
<p className="text-xs text-gray-500 truncate">
{addr?.postcode ? `${addr.postcode} ` : ''}{city}
{addr?.state ? `, ${addr.state}` : ''}
</p>
</div>
</div>
</button>
)
})}
</div>
)}
</div>
<p className="text-xs text-muted-foreground">
Adresse suchen die Karte springt automatisch zum Einsatzort
</p>
</div>
<div className="space-y-2">
<Label htmlFor="project-description">Beschreibung</Label>
<Input
id="project-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optionale Notizen zum Einsatz"
disabled={isCreating}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="project-einsatzleiter">Einsatzleiter</Label>
<Input
id="project-einsatzleiter"
value={einsatzleiter}
onChange={(e) => setEinsatzleiter(e.target.value)}
placeholder="Name"
disabled={isCreating}
/>
</div>
<div className="space-y-2">
<Label htmlFor="project-journalfuehrer">Journalführer</Label>
<Input
id="project-journalfuehrer"
value={journalfuehrer}
onChange={(e) => setJournalfuehrer(e.target.value)}
placeholder="Name"
disabled={isCreating}
/>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleClose}
disabled={isCreating}
>
Abbrechen
</Button>
<Button
onClick={handleCreate}
disabled={isCreating || !title.trim()}
>
{isCreating ? 'Erstellen...' : 'Erstellen'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,90 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
interface TextDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onConfirm: (text: string, fontSize: number) => void
selectedColor: string
}
export function TextDialog({ open, onOpenChange, onConfirm, selectedColor }: TextDialogProps) {
const [text, setText] = useState('')
const [fontSize, setFontSize] = useState(16)
const handleConfirm = () => {
if (text.trim()) {
onConfirm(text.trim(), fontSize)
setText('')
setFontSize(16)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleConfirm()
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md top-[12%] translate-y-0">
<DialogHeader>
<DialogTitle>Text platzieren</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="text-input">Text</Label>
<Input
id="text-input"
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="z.B. Zugang Süd, Brandstelle, etc."
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="font-size">Schriftgrösse: {fontSize}px</Label>
<input
id="font-size"
type="range"
min="10"
max="32"
value={fontSize}
onChange={(e) => setFontSize(Number(e.target.value))}
className="w-full"
/>
</div>
<div className="flex items-center gap-2">
<Label>Vorschau:</Label>
<span
style={{ fontSize: `${fontSize}px`, color: selectedColor, fontWeight: 'bold' }}
>
{text || 'Beispieltext'}
</span>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Abbrechen
</Button>
<Button onClick={handleConfirm} disabled={!text.trim()}>
Platzieren
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,134 @@
'use client'
import { useState } from 'react'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Button } from '@/components/ui/button'
import {
Eye,
EyeOff,
Trash2,
GripVertical,
MapPin,
Minus,
Square,
Circle,
Hexagon,
Type,
ArrowRight,
} from 'lucide-react'
export interface LayerItem {
id: string
kind: string
name?: string
isVisible: boolean
iconId?: string
properties?: Record<string, unknown>
}
interface LayerListProps {
items: LayerItem[]
selectedId: string | null
onSelect: (id: string) => void
onToggleVisibility: (id: string) => void
onDelete: (id: string) => void
canEdit: boolean
}
const kindIcons: Record<string, React.ReactNode> = {
SYMBOL: <MapPin className="w-4 h-4" />,
LINE: <Minus className="w-4 h-4" />,
POLYGON: <Hexagon className="w-4 h-4" />,
RECTANGLE: <Square className="w-4 h-4" />,
CIRCLE: <Circle className="w-4 h-4" />,
ARROW: <ArrowRight className="w-4 h-4" />,
TEXT: <Type className="w-4 h-4" />,
}
const kindLabels: Record<string, string> = {
SYMBOL: 'Symbol',
LINE: 'Linie',
POLYGON: 'Polygon',
RECTANGLE: 'Rechteck',
CIRCLE: 'Kreis',
ARROW: 'Pfeil',
TEXT: 'Text',
}
export function LayerList({
items,
selectedId,
onSelect,
onToggleVisibility,
onDelete,
canEdit,
}: LayerListProps) {
return (
<div className="flex flex-col h-full">
<div className="p-3 border-b border-border">
<h3 className="font-semibold text-sm">Objekte ({items.length})</h3>
</div>
<ScrollArea className="flex-1">
<div className="p-2 space-y-1">
{items.length === 0 ? (
<p className="text-xs text-muted-foreground text-center py-4">
Keine Objekte vorhanden
</p>
) : (
items.map((item) => (
<div
key={item.id}
className={`flex items-center gap-2 p-2 rounded-md cursor-pointer transition-colors ${
selectedId === item.id
? 'bg-primary/10 border border-primary/30'
: 'hover:bg-accent'
} ${!item.isVisible ? 'opacity-50' : ''}`}
onClick={() => onSelect(item.id)}
>
<GripVertical className="w-3 h-3 text-muted-foreground shrink-0" />
<div className="shrink-0">
{kindIcons[item.kind] || <MapPin className="w-4 h-4" />}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm truncate">
{item.name || kindLabels[item.kind] || item.kind}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={(e) => {
e.stopPropagation()
onToggleVisibility(item.id)
}}
>
{item.isVisible ? (
<Eye className="w-3 h-3" />
) : (
<EyeOff className="w-3 h-3" />
)}
</Button>
{canEdit && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive"
onClick={(e) => {
e.stopPropagation()
onDelete(item.id)
}}
>
<Trash2 className="w-3 h-3" />
</Button>
)}
</div>
</div>
))
)}
</div>
</ScrollArea>
</div>
)
}

View File

@@ -0,0 +1,164 @@
'use client'
import { Button } from '@/components/ui/button'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
MousePointer2,
Circle,
CircleDot,
Square,
Minus,
Pentagon,
Type,
Pencil,
Undo2,
Redo2,
MoveRight,
Ruler,
Eraser,
} from 'lucide-react'
import type { DrawMode } from '@/app/app/page'
import { cn } from '@/lib/utils'
interface LeftToolbarProps {
drawMode: DrawMode
onDrawModeChange: (mode: DrawMode) => void
selectedColor: string
onColorChange: (color: string) => void
selectedWidth: number
onWidthChange: (width: number) => void
onUndo: () => void
onRedo: () => void
canEdit: boolean
}
const colors = [
{ value: '#ef4444', name: 'Rot' },
{ value: '#f97316', name: 'Orange' },
{ value: '#eab308', name: 'Gelb' },
{ value: '#22c55e', name: 'Grün' },
{ value: '#3b82f6', name: 'Blau' },
{ value: '#8b5cf6', name: 'Violett' },
{ value: '#000000', name: 'Schwarz' },
{ value: '#ffffff', name: 'Weiss' },
]
const drawTools: { mode: DrawMode; icon: typeof MousePointer2; label: string }[] = [
{ mode: 'select', icon: MousePointer2, label: 'Auswählen' },
{ mode: 'point', icon: CircleDot, label: 'Punkt' },
{ mode: 'linestring', icon: Minus, label: 'Linie' },
{ mode: 'polygon', icon: Pentagon, label: 'Polygon' },
{ mode: 'rectangle', icon: Square, label: 'Rechteck' },
{ mode: 'circle', icon: Circle, label: 'Kreis' },
{ mode: 'freehand', icon: Pencil, label: 'Freihand' },
{ mode: 'arrow', icon: MoveRight, label: 'Pfeil / Route' },
{ mode: 'text', icon: Type, label: 'Text' },
{ mode: 'eraser', icon: Eraser, label: 'Radiergummi' },
{ mode: 'measure', icon: Ruler, label: 'Messen' },
]
export function LeftToolbar({
drawMode,
onDrawModeChange,
selectedColor,
onColorChange,
selectedWidth,
onWidthChange,
onUndo,
onRedo,
canEdit,
}: LeftToolbarProps) {
return (
<TooltipProvider delayDuration={300}>
<aside className="w-11 md:w-14 lg:w-20 border-r border-border bg-card flex flex-col items-center py-1 md:py-2 shrink-0 overflow-y-auto overflow-x-hidden z-10">
{/* Draw Tools */}
<div className="flex flex-col gap-px md:gap-0.5">
{drawTools.map((tool) => (
<Tooltip key={tool.mode}>
<TooltipTrigger asChild>
<Button
variant={drawMode === tool.mode ? 'default' : 'ghost'}
size="icon"
className="w-8 h-8 md:w-10 md:h-10 lg:w-12 lg:h-12"
onClick={() => onDrawModeChange(tool.mode)}
disabled={!canEdit && tool.mode !== 'select'}
>
<tool.icon className="w-4 h-4 md:w-5 md:h-5 lg:w-6 lg:h-6" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>{tool.label}</p>
</TooltipContent>
</Tooltip>
))}
</div>
<div className="w-6 md:w-8 lg:w-12 h-px bg-border my-1" />
{/* Undo/Redo - 2-col grid to save vertical space */}
<div className="grid grid-cols-2 gap-px md:gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="w-5 h-5 md:w-6 md:h-6 lg:w-8 lg:h-8"
onClick={onUndo}
disabled={!canEdit}
>
<Undo2 className="w-3.5 h-3.5 md:w-4 md:h-4 lg:w-5 lg:h-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Rückgängig</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="w-5 h-5 md:w-6 md:h-6 lg:w-8 lg:h-8"
onClick={onRedo}
disabled={!canEdit}
>
<Redo2 className="w-3.5 h-3.5 md:w-4 md:h-4 lg:w-5 lg:h-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Wiederholen</p>
</TooltipContent>
</Tooltip>
</div>
<div className="w-6 md:w-8 lg:w-12 h-px bg-border my-1" />
{/* Color Picker - 2-col grid */}
<div className="grid grid-cols-2 gap-0.5 md:gap-1">
{colors.map((color) => (
<button
key={color.value}
className={cn(
'w-4 h-4 md:w-5 md:h-5 lg:w-7 lg:h-7 rounded border-2 transition-all',
selectedColor === color.value
? 'border-primary ring-1 ring-primary ring-offset-1 scale-110'
: 'border-muted hover:border-muted-foreground'
)}
style={{ backgroundColor: color.value }}
onClick={() => onColorChange(color.value)}
disabled={!canEdit}
title={color.name}
/>
))}
</div>
</aside>
</TooltipProvider>
)
}

View File

@@ -0,0 +1,130 @@
'use client'
import { ScrollArea } from '@/components/ui/scroll-area'
export interface LegendItem {
id: string
iconId?: string
iconUrl?: string
name: string
kind: string
count: number
properties?: {
unNummer?: string
gefahrennummer?: string
anzahlOpfer?: number
}
}
interface LegendProps {
items: LegendItem[]
}
export function Legend({ items }: LegendProps) {
if (items.length === 0) return null
// Group by kind and iconId
const grouped = items.reduce((acc, item) => {
const key = item.iconId || item.kind
if (!acc[key]) {
acc[key] = { ...item, count: 1 }
} else {
acc[key].count++
}
return acc
}, {} as Record<string, LegendItem>)
const legendItems = Object.values(grouped)
return (
<div className="bg-card border border-border rounded-lg shadow-lg p-3 min-w-[200px] max-w-[300px]">
<h4 className="font-semibold text-sm mb-2 pb-2 border-b border-border">Legende</h4>
<ScrollArea className="max-h-[300px]">
<div className="space-y-2">
{legendItems.map((item, index) => (
<div key={index} className="flex items-start gap-2">
{item.iconUrl ? (
<img
src={item.iconUrl}
alt={item.name}
className="w-5 h-5 object-contain shrink-0 mt-0.5"
/>
) : (
<div className="w-5 h-5 bg-primary/20 rounded shrink-0 mt-0.5" />
)}
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">
{item.name}
{item.count > 1 && (
<span className="text-muted-foreground ml-1">({item.count})</span>
)}
</p>
{item.properties?.unNummer && (
<p className="text-xs text-orange-600">
UN {item.properties.unNummer}
{item.properties.gefahrennummer && ` / ${item.properties.gefahrennummer}`}
</p>
)}
{item.properties?.anzahlOpfer !== undefined && (
<p className="text-xs text-yellow-600">
{item.properties.anzahlOpfer} Opfer
</p>
)}
</div>
</div>
))}
</div>
</ScrollArea>
</div>
)
}
export function LegendExport({ items }: LegendProps) {
if (items.length === 0) return null
const grouped = items.reduce((acc, item) => {
const key = item.iconId || item.kind
if (!acc[key]) {
acc[key] = { ...item, count: 1 }
} else {
acc[key].count++
}
return acc
}, {} as Record<string, LegendItem>)
const legendItems = Object.values(grouped)
return (
<div className="legend-export" style={{ fontFamily: 'Arial, sans-serif' }}>
<h4 style={{ fontSize: '14px', fontWeight: 'bold', marginBottom: '8px', borderBottom: '1px solid #ccc', paddingBottom: '4px' }}>
Legende
</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{legendItems.map((item, index) => (
<div key={index} style={{ display: 'flex', alignItems: 'flex-start', gap: '8px' }}>
{item.iconUrl ? (
<img
src={item.iconUrl}
alt={item.name}
style={{ width: '16px', height: '16px', objectFit: 'contain' }}
/>
) : (
<div style={{ width: '16px', height: '16px', background: '#ddd', borderRadius: '2px' }} />
)}
<div>
<span style={{ fontSize: '11px' }}>
{item.name}
{item.count > 1 && ` (${item.count})`}
</span>
{item.properties?.unNummer && (
<span style={{ fontSize: '10px', color: '#c2410c', marginLeft: '4px' }}>
UN {item.properties.unNummer}
</span>
)}
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,215 @@
'use client'
import { useState, useEffect } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { X } from 'lucide-react'
export interface ItemProperties {
id: string
kind: string
name: string
note: string
status: string
iconType?: string
// Rettung fields
anzahlOpfer?: number
// Gefahrstoff fields
unNummer?: string
gefahrennummer?: string
// Generic
[key: string]: unknown
}
interface PropertiesPanelProps {
item: ItemProperties | null
onUpdate: (id: string, properties: Partial<ItemProperties>) => void
onClose: () => void
canEdit: boolean
}
const STATUS_OPTIONS = [
{ value: 'aktiv', label: 'Aktiv' },
{ value: 'inaktiv', label: 'Inaktiv' },
{ value: 'erledigt', label: 'Erledigt' },
{ value: 'geplant', label: 'Geplant' },
]
export function PropertiesPanel({
item,
onUpdate,
onClose,
canEdit,
}: PropertiesPanelProps) {
const [name, setName] = useState('')
const [note, setNote] = useState('')
const [status, setStatus] = useState('aktiv')
const [anzahlOpfer, setAnzahlOpfer] = useState('')
const [unNummer, setUnNummer] = useState('')
const [gefahrennummer, setGefahrennummer] = useState('')
useEffect(() => {
if (item) {
setName(item.name || '')
setNote(item.note || '')
setStatus(item.status || 'aktiv')
setAnzahlOpfer(item.anzahlOpfer?.toString() || '')
setUnNummer(item.unNummer || '')
setGefahrennummer(item.gefahrennummer || '')
}
}, [item])
if (!item) return null
const handleSave = () => {
const updates: Partial<ItemProperties> = {
name,
note,
status,
}
if (item.iconType === 'RETTUNG') {
updates.anzahlOpfer = anzahlOpfer ? parseInt(anzahlOpfer) : undefined
}
if (item.iconType === 'GEFAHRSTOFF') {
updates.unNummer = unNummer || undefined
updates.gefahrennummer = gefahrennummer || undefined
}
onUpdate(item.id, updates)
}
const isRettung = item.iconType === 'RETTUNG'
const isGefahrstoff = item.iconType === 'GEFAHRSTOFF'
return (
<div className="w-72 border-l border-border bg-card flex flex-col shrink-0">
<div className="p-3 border-b border-border flex items-center justify-between">
<h3 className="font-semibold text-sm">Eigenschaften</h3>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onClose}>
<X className="w-4 h-4" />
</Button>
</div>
<ScrollArea className="flex-1 p-3">
<div className="space-y-4">
<div>
<Label className="text-xs">Typ</Label>
<p className="text-sm text-muted-foreground">{item.kind}</p>
</div>
<div>
<Label className="text-xs">Name / Bezeichnung</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="z.B. Einsatzleiter"
disabled={!canEdit}
className="h-8 text-sm"
/>
</div>
<div>
<Label className="text-xs">Notiz</Label>
<Input
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Zusätzliche Informationen"
disabled={!canEdit}
className="h-8 text-sm"
/>
</div>
<div>
<Label className="text-xs">Status</Label>
<Select value={status} onValueChange={setStatus} disabled={!canEdit}>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Rettung specific fields */}
{isRettung && (
<div className="pt-2 border-t border-border">
<div className="bg-yellow-500/10 p-2 rounded-md mb-3">
<p className="text-xs font-medium text-yellow-600">Rettungs-Symbol</p>
</div>
<div>
<Label className="text-xs">
Anzahl vermuteter Opfer <span className="text-destructive">*</span>
</Label>
<Input
type="number"
min="0"
value={anzahlOpfer}
onChange={(e) => setAnzahlOpfer(e.target.value)}
placeholder="0"
disabled={!canEdit}
className="h-8 text-sm"
/>
</div>
</div>
)}
{/* Gefahrstoff specific fields */}
{isGefahrstoff && (
<div className="pt-2 border-t border-border">
<div className="bg-orange-500/10 p-2 rounded-md mb-3">
<p className="text-xs font-medium text-orange-600">Gefahrstoff-Symbol</p>
</div>
<div className="space-y-3">
<div>
<Label className="text-xs">
UN-Nummer <span className="text-destructive">*</span>
</Label>
<Input
value={unNummer}
onChange={(e) => setUnNummer(e.target.value)}
placeholder="z.B. 1203"
disabled={!canEdit}
className="h-8 text-sm"
/>
</div>
<div>
<Label className="text-xs">
Gefahrennummer <span className="text-destructive">*</span>
</Label>
<Input
value={gefahrennummer}
onChange={(e) => setGefahrennummer(e.target.value)}
placeholder="z.B. 33"
disabled={!canEdit}
className="h-8 text-sm"
/>
</div>
</div>
</div>
)}
{canEdit && (
<Button onClick={handleSave} className="w-full" size="sm">
Speichern
</Button>
)}
</div>
</ScrollArea>
</div>
)
}

View File

@@ -0,0 +1,393 @@
'use client'
import { useState, useEffect } from 'react'
import { useDrag } from 'react-dnd'
import { getEmptyImage } from 'react-dnd-html5-backend'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Search, Flame, Droplets, AlertTriangle, Car, Users,
Truck, Building, Target, Upload, Loader2, X, LayoutGrid,
ChevronLeft, ChevronRight, Map, ClipboardList, PanelRightClose, PanelRightOpen,
Shield, Wrench, Radio, MoreHorizontal, Heart,
} from 'lucide-react'
interface DisplaySymbol {
id: string
name: string
imageUrl: string
}
interface DisplayCategory {
id: string
name: string
symbols: DisplaySymbol[]
}
interface RightSidebarProps {
onSymbolDrop: (iconId: string, coordinates: [number, number], imageUrl?: string) => void
canEdit: boolean
isOpen?: boolean
onToggle?: () => void
activeTab?: 'map' | 'journal'
onTabChange?: (tab: 'map' | 'journal') => void
isCollapsed?: boolean
onToggleCollapse?: () => void
tenantId?: string | null
}
const categoryIcons: Record<string, typeof Flame> = {
'Feuer / Brand': Flame,
'Wasser': Droplets,
'Gefahren / Stoffe': AlertTriangle,
'Rettung / Personen': Heart,
'Leitern / Geräte': Wrench,
'Gebäude / Schäden': Building,
'Einsatzführung': Radio,
'Organisationen': Shield,
'Entwicklung / Taktik': Target,
'Verschiedenes': MoreHorizontal,
'Eigene': Upload,
}
function DraggableSymbol({ symbol, canEdit }: {
symbol: DisplaySymbol
canEdit: boolean
}) {
const [{ isDragging }, drag, preview] = useDrag(() => ({
type: 'SYMBOL',
item: { iconId: symbol.id, imageUrl: symbol.imageUrl },
canDrag: canEdit,
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
}), [symbol.id, symbol.imageUrl, canEdit])
// Suppress native HTML5 drag ghost — we use CustomDragLayer instead
useEffect(() => {
preview(getEmptyImage(), { captureDraggingState: true })
}, [preview])
return (
<div
ref={drag as unknown as React.LegacyRef<HTMLDivElement>}
className={`
flex flex-col items-center gap-1 p-1.5 md:p-2 lg:p-2.5 rounded-lg border-2 transition-all
border-transparent hover:border-border hover:bg-accent
cursor-grab active:cursor-grabbing active:scale-95
${isDragging ? 'opacity-0' : ''}
${!canEdit ? 'opacity-50 cursor-not-allowed' : ''}
`}
title={symbol.name}
>
<div className="w-10 h-10 md:w-11 md:h-11 lg:w-14 lg:h-14 flex items-center justify-center rounded-lg bg-white border border-gray-200 dark:border-gray-600">
<img
src={symbol.imageUrl}
alt={symbol.name}
className="w-8 h-8 md:w-9 md:h-9 lg:w-12 lg:h-12 object-contain"
crossOrigin="anonymous"
/>
</div>
<span className="text-[10px] md:text-[11px] text-center truncate w-full font-medium text-muted-foreground">
{symbol.name}
</span>
</div>
)
}
export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTab, onTabChange, isCollapsed, onToggleCollapse, tenantId }: RightSidebarProps) {
const [searchQuery, setSearchQuery] = useState('')
const [activeCategory, setActiveCategory] = useState<string>('')
const [categories, setCategories] = useState<DisplayCategory[]>([])
const [tenantIcons, setTenantIcons] = useState<DisplaySymbol[]>([])
const [isLoading, setIsLoading] = useState(true)
const [showTenantSection, setShowTenantSection] = useState(true)
const [showLibrarySection, setShowLibrarySection] = useState(true)
useEffect(() => {
async function fetchIcons() {
setIsLoading(true)
try {
const res = await fetch('/api/icons')
if (res.ok) {
const data = await res.json()
const allCats: DisplayCategory[] = (data.categories || [])
.filter((cat: any) => cat.icons && cat.icons.length > 0)
.map((cat: any) => ({
id: cat.id,
name: cat.name,
symbols: cat.icons.map((icon: any) => ({
id: icon.id,
name: icon.name,
imageUrl: icon.url || `/api/icons/${icon.id}/image`,
})),
}))
// Separate tenant-specific icons ("Eigene" category) from global library
const eigene = allCats.find(c => c.name === 'Eigene')
const globalCats = allCats.filter(c => c.name !== 'Eigene')
setTenantIcons(eigene?.symbols || [])
setCategories(globalCats)
if (globalCats.length > 0) setActiveCategory(globalCats[0].id)
}
} catch (err) {
console.error('Failed to load icons:', err)
} finally {
setIsLoading(false)
}
}
fetchIcons()
}, [])
const filteredCategories = categories.map((cat) => ({
...cat,
symbols: cat.symbols.filter((s) =>
s.name.toLowerCase().includes(searchQuery.toLowerCase())
),
}))
const filteredTenantIcons = tenantIcons.filter(s =>
s.name.toLowerCase().includes(searchQuery.toLowerCase())
)
const currentCategory = filteredCategories.find((c) => c.id === activeCategory)
const totalSymbols = categories.reduce((sum, c) => sum + c.symbols.length, 0) + tenantIcons.length
return (
<>
{/* Mobile toggle button */}
{!isOpen && onToggle && (
<button
onClick={onToggle}
className="md:hidden fixed bottom-4 right-4 z-50 w-14 h-14 bg-primary text-primary-foreground rounded-full shadow-lg flex items-center justify-center active:scale-95 transition-transform"
title="Symbole"
>
<LayoutGrid className="w-6 h-6" />
</button>
)}
{/* Backdrop on mobile */}
{isOpen && onToggle && (
<div className="md:hidden fixed inset-0 bg-black/40 z-40" onClick={onToggle} />
)}
{/* Collapsed state: thin strip with toggle + tab icons */}
{isCollapsed && (
<aside className="hidden md:flex w-10 border-l border-border bg-card flex-col items-center py-2 gap-2 shrink-0">
<button
onClick={onToggleCollapse}
className="p-1.5 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
title="Seitenleiste einblenden"
>
<PanelRightOpen className="w-4 h-4" />
</button>
<div className="w-6 h-px bg-border" />
{onTabChange && (
<>
<button
onClick={() => onTabChange('map')}
className={`p-1.5 rounded-md transition-colors ${
activeTab === 'map' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground'
}`}
title="Karte"
>
<Map className="w-4 h-4" />
</button>
<button
onClick={() => onTabChange('journal')}
className={`p-1.5 rounded-md transition-colors ${
activeTab === 'journal' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground'
}`}
title="Journal"
>
<ClipboardList className="w-4 h-4" />
</button>
</>
)}
</aside>
)}
<aside className={`
w-72 md:w-48 lg:w-56 xl:w-72 border-l border-border bg-card flex flex-col shrink-0
md:relative md:translate-x-0 md:z-auto
fixed right-0 top-0 bottom-0 z-50 transition-transform duration-200
${isOpen ? 'translate-x-0' : 'translate-x-full md:translate-x-0'}
${isCollapsed ? 'md:hidden' : ''}
`}>
{/* Tab switcher: Karte / Journal */}
{onTabChange && (
<div className="flex items-center border-b border-border shrink-0">
<button
onClick={() => onTabChange('map')}
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors ${
activeTab === 'map'
? 'bg-primary/10 text-primary border-b-2 border-primary'
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
}`}
>
<Map className="w-3.5 h-3.5" />
Karte
</button>
<button
onClick={() => onTabChange('journal')}
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors ${
activeTab === 'journal'
? 'bg-primary/10 text-primary border-b-2 border-primary'
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
}`}
>
<ClipboardList className="w-3.5 h-3.5" />
Journal
</button>
</div>
)}
{/* Symbol panel — only shown on map tab */}
{activeTab === 'map' && (
<>
<div className="p-2 md:p-2 lg:p-3 border-b border-border">
<div className="flex items-center justify-between mb-1.5">
<h3 className="font-semibold text-sm md:text-base">Symbole</h3>
<div className="flex items-center gap-1">
{onToggleCollapse && (
<button onClick={onToggleCollapse} className="hidden md:block p-1 text-muted-foreground hover:text-foreground" title="Seitenleiste einklappen">
<PanelRightClose className="w-4 h-4" />
</button>
)}
{onToggle && (
<button onClick={onToggle} className="md:hidden p-1 text-muted-foreground hover:text-foreground">
<X className="w-5 h-5" />
</button>
)}
</div>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 md:h-5 md:w-5 text-muted-foreground" />
<Input
placeholder="Suchen..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 md:pl-10 h-9 md:h-10 lg:h-11 text-sm md:text-base"
/>
</div>
<p className="text-xs text-muted-foreground mt-1.5">{totalSymbols} Symbole verfügbar</p>
</div>
{isLoading && (
<div className="flex items-center justify-center gap-2 p-6 text-muted-foreground text-xs">
<Loader2 className="w-4 h-4 animate-spin" />
Lade Symbole...
</div>
)}
<ScrollArea className="flex-1">
{/* ─── Section 1: Meine Symbole (Tenant-specific) ─── */}
{tenantId && (
<div className="border-b border-border">
<button
onClick={() => setShowTenantSection(!showTenantSection)}
className="w-full flex items-center justify-between px-2 py-1.5 text-xs font-semibold text-muted-foreground hover:bg-accent/50 transition-colors"
>
<span className="flex items-center gap-1.5">
<Upload className="w-3.5 h-3.5" />
Meine Symbole ({filteredTenantIcons.length})
</span>
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${showTenantSection ? 'rotate-90' : ''}`} />
</button>
{showTenantSection && (
<div className="p-2 pt-0">
{filteredTenantIcons.length === 0 ? (
<div className="text-center text-muted-foreground py-4 text-xs">
Keine eigenen Symbole vorhanden.
<br />
<span className="text-[10px]">Symbole können unter Einstellungen Symbole hochgeladen werden.</span>
</div>
) : (
<div className="grid grid-cols-3 md:grid-cols-2 lg:grid-cols-3 gap-1">
{filteredTenantIcons.map((symbol) => (
<DraggableSymbol key={symbol.id} symbol={symbol} canEdit={canEdit} />
))}
</div>
)}
</div>
)}
</div>
)}
{/* ─── Section 2: Bibliothek (Global categories) ─── */}
<div>
<button
onClick={() => setShowLibrarySection(!showLibrarySection)}
className="w-full flex items-center justify-between px-2 py-1.5 text-xs font-semibold text-muted-foreground hover:bg-accent/50 transition-colors"
>
<span className="flex items-center gap-1.5">
<LayoutGrid className="w-3.5 h-3.5" />
Bibliothek ({categories.reduce((s, c) => s + c.symbols.length, 0)})
</span>
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${showLibrarySection ? 'rotate-90' : ''}`} />
</button>
{showLibrarySection && (
<>
{/* Category tabs */}
<div className="px-1.5 pb-1.5 md:px-2 md:pb-2">
<div className="flex flex-wrap gap-0.5 md:gap-1">
{categories.map((cat) => {
const IconComponent = categoryIcons[cat.name] || Target
return (
<button
key={cat.id}
onClick={() => setActiveCategory(cat.id)}
className={`
flex items-center gap-1 px-2 py-1.5 md:px-2.5 md:py-1.5 lg:px-3 lg:py-2 rounded-md md:rounded-lg text-xs md:text-sm font-medium transition-colors
${activeCategory === cat.id
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-accent text-muted-foreground'}
`}
title={`${cat.name} (${cat.symbols.length})`}
>
<IconComponent className="w-3.5 h-3.5 md:w-4 md:h-4" />
<span className="hidden lg:inline max-w-[70px] truncate">{cat.name}</span>
</button>
)
})}
</div>
</div>
{currentCategory && (
<div className="p-2 pt-0">
<h4 className="text-xs md:text-sm font-medium text-muted-foreground mb-1.5">
{currentCategory.name} ({currentCategory.symbols.length})
</h4>
{currentCategory.symbols.length === 0 ? (
<div className="text-center text-muted-foreground py-8 text-sm">
Keine Symbole gefunden
</div>
) : (
<div className="grid grid-cols-3 md:grid-cols-2 lg:grid-cols-3 gap-1">
{currentCategory.symbols.map((symbol) => (
<DraggableSymbol key={symbol.id} symbol={symbol} canEdit={canEdit} />
))}
</div>
)}
</div>
)}
</>
)}
</div>
</ScrollArea>
</>
)}
{/* Journal tab: collapse button only, no symbols */}
{activeTab === 'journal' && onToggleCollapse && (
<div className="p-2 border-b border-border flex items-center justify-between">
<span className="text-xs text-muted-foreground">Journal aktiv</span>
<button onClick={onToggleCollapse} className="hidden md:block p-1 text-muted-foreground hover:text-foreground" title="Seitenleiste einklappen">
<PanelRightClose className="w-4 h-4" />
</button>
</div>
)}
</aside>
</>
)
}

View File

@@ -0,0 +1,542 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Plus,
Save,
FolderOpen,
Download,
Clock,
Sun,
Moon,
Maximize,
Minimize,
ClipboardList,
Settings,
LogOut,
User,
MoreVertical,
Trash2,
AlertTriangle,
List,
Eraser,
ImagePlus,
Key,
Shield,
Building2,
} from 'lucide-react'
import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog'
import type { Project, DrawFeature } from '@/app/app/page'
import { formatDateTime } from '@/lib/utils'
import { Logo } from '@/components/ui/logo'
interface TopbarProps {
project: Project | null
onNewProject: () => void
onSaveProject: () => void
onLoadProject: (project: Project, features: DrawFeature[]) => void
onDeleteProject?: (projectId: string) => void
onExport: (format: 'png' | 'pdf') => void
onClearAll?: () => void
onPlanUpload?: () => void
isSaving: boolean
isDarkMode: boolean
onToggleTheme: () => void
isFullscreen: boolean
onToggleFullscreen: () => void
auditLog: { time: string; action: string }[]
isAuditOpen: boolean
onToggleAudit: () => void
userName?: string
userRole?: string
onLogout?: () => void
}
export function Topbar({
project,
onNewProject,
onSaveProject,
onLoadProject,
onDeleteProject,
onExport,
onClearAll,
onPlanUpload,
isSaving,
isDarkMode,
onToggleTheme,
isFullscreen,
onToggleFullscreen,
auditLog,
isAuditOpen,
onToggleAudit,
userName,
userRole,
onLogout,
}: TopbarProps) {
const [isLoadDialogOpen, setIsLoadDialogOpen] = useState(false)
const [isHoseSettingsOpen, setIsHoseSettingsOpen] = useState(false)
const [showPasswordDialog, setShowPasswordDialog] = useState(false)
const [pwOld, setPwOld] = useState('')
const [pwNew, setPwNew] = useState('')
const [pwConfirm, setPwConfirm] = useState('')
const [pwLoading, setPwLoading] = useState(false)
const [pwStatus, setPwStatus] = useState<'success' | 'error' | null>(null)
const [pwError, setPwError] = useState('')
const [projects, setProjects] = useState<Project[]>([])
const [isLoadingProjects, setIsLoadingProjects] = useState(false)
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
// Live clock
const [now, setNow] = useState(new Date())
useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 1000)
return () => clearInterval(timer)
}, [])
const handleOpenLoadDialog = async () => {
setIsLoadDialogOpen(true)
setIsLoadingProjects(true)
try {
const res = await fetch('/api/projects')
if (res.ok) {
const data = await res.json()
setProjects(data.projects)
}
} catch (error) {
console.error('Error loading projects:', error)
} finally {
setIsLoadingProjects(false)
}
}
const handleLoadProject = async (projectId: string) => {
try {
const res = await fetch(`/api/projects/${projectId}`)
if (res.ok) {
const data = await res.json()
onLoadProject(data.project, data.project.features || [])
setIsLoadDialogOpen(false)
}
} catch (error) {
console.error('Error loading project:', error)
}
}
const handleExport = (format: 'png' | 'pdf') => {
onExport(format)
}
return (
<header className="h-14 md:h-16 border-b border-border bg-card flex items-center justify-between px-2 md:px-4 shrink-0 overflow-x-auto print:hidden">
<div className="flex items-center gap-1.5 md:gap-4 shrink-0">
<div className="flex items-center gap-1.5 md:gap-2">
<Logo size={32} />
<span className="font-semibold text-lg hidden md:inline">Lageplan</span>
</div>
<div className="h-6 w-px bg-border hidden md:block" />
<div className="flex items-center gap-1 md:gap-2">
<Button
variant="outline"
className="h-9 md:h-10 px-2 md:px-4 text-sm"
onClick={onSaveProject}
disabled={!project || isSaving}
title="Speichern"
>
<Save className="w-5 h-5 md:mr-1.5" />
<span className="hidden lg:inline">{isSaving ? 'Speichern...' : 'Speichern'}</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="h-9 md:h-10 px-2 md:px-3 text-sm" title="Menü">
<MoreVertical className="w-5 h-5 md:mr-1" />
<span className="hidden lg:inline">Menü</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-52">
<DropdownMenuItem onClick={onNewProject} className="py-2.5 px-3">
<Plus className="w-4 h-4 mr-2" />
Neuer Einsatz
</DropdownMenuItem>
<DropdownMenuItem onClick={handleOpenLoadDialog} className="py-2.5 px-3">
<List className="w-4 h-4 mr-2" />
Einsätze verwalten
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport('png')} className="py-2.5 px-3">
<Download className="w-4 h-4 mr-2" />
Als PNG exportieren
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport('pdf')} className="py-2.5 px-3">
<Download className="w-4 h-4 mr-2" />
Als PDF exportieren
</DropdownMenuItem>
{onClearAll && (
<DropdownMenuItem onClick={onClearAll} className="py-2.5 px-3 text-destructive focus:text-destructive">
<Eraser className="w-4 h-4 mr-2" />
Zeichnung leeren
</DropdownMenuItem>
)}
{(userRole === 'SERVER_ADMIN' || userRole === 'TENANT_ADMIN') && (
<DropdownMenuItem onClick={() => window.location.href = '/admin'} className="py-2.5 px-3">
<Settings className="w-4 h-4 mr-2" />
Einstellungen
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="flex items-center gap-1 md:gap-2 shrink-0">
{/* Project info - desktop only */}
{project && (
<div className="hidden xl:flex items-center gap-2 text-sm border border-border rounded-lg px-3 py-1.5 bg-muted/30">
{(project as any).einsatzNr && (
<span className="font-mono text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded">{(project as any).einsatzNr}</span>
)}
<span className="font-semibold truncate max-w-[180px]">{project.title}</span>
{project.location && (
<><span className="text-muted-foreground">|</span><span className="text-muted-foreground truncate max-w-[150px]">{project.location}</span></>
)}
</div>
)}
{/* Clock - desktop only */}
<div className="hidden lg:flex items-center gap-2 text-sm font-medium tabular-nums">
<Clock className="w-4 h-4 text-muted-foreground" />
<span className="text-primary font-bold">{now.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })}</span>
</div>
{/* Desktop: individual buttons */}
<Button
variant={isDarkMode ? 'default' : 'outline'}
className="hidden md:flex h-9 px-2 gap-1.5 text-sm font-semibold"
onClick={onToggleTheme}
title={isDarkMode ? 'Tagmodus' : 'Nachtmodus'}
>
{isDarkMode ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</Button>
<Button
variant="outline"
className="hidden md:flex h-9 px-2"
onClick={onToggleFullscreen}
title={isFullscreen ? 'Vollbild verlassen' : 'Vollbild'}
>
{isFullscreen ? <Minimize className="w-4 h-4" /> : <Maximize className="w-4 h-4" />}
</Button>
<Button
variant={isAuditOpen ? 'default' : 'outline'}
className="hidden md:flex h-9 px-2 relative"
onClick={onToggleAudit}
title="Audit Trail"
>
<ClipboardList className="w-4 h-4" />
{auditLog.length > 0 && (
<span className="absolute -top-1 -right-1 w-4 h-4 bg-primary text-primary-foreground text-[10px] rounded-full flex items-center justify-center font-bold">
{auditLog.length > 99 ? '99' : auditLog.length}
</span>
)}
</Button>
{/* Desktop: User menu dropdown */}
{userName && onLogout && (
<div className="hidden md:flex items-center ml-1 pl-2 border-l border-border">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-9 px-2 gap-1.5 text-sm text-muted-foreground hover:text-foreground">
<User className="w-4 h-4" />
<span className="hidden lg:inline max-w-[100px] truncate">{userName}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<div className="px-3 py-2 border-b">
<p className="text-sm font-medium">{userName}</p>
{userRole && <p className="text-xs text-muted-foreground">{userRole}</p>}
</div>
<DropdownMenuItem onClick={() => setShowPasswordDialog(true)}>
<Key className="w-4 h-4 mr-2" />
Kennwort ändern
</DropdownMenuItem>
{userRole === 'TENANT_ADMIN' && (
<DropdownMenuItem onClick={() => window.location.href = '/settings'}>
<Building2 className="w-4 h-4 mr-2" />
Organisation
</DropdownMenuItem>
)}
{(userRole === 'SERVER_ADMIN' || userRole === 'TENANT_ADMIN') && (
<DropdownMenuItem onClick={() => window.location.href = '/admin'}>
<Shield className="w-4 h-4 mr-2" />
Administration
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={onLogout} className="text-destructive focus:text-destructive">
<LogOut className="w-4 h-4 mr-2" />
Abmelden
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{/* Mobile: overflow menu with all secondary actions */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="md:hidden h-9 px-2">
<MoreVertical className="w-5 h-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={onToggleTheme}>
{isDarkMode ? <Sun className="w-4 h-4 mr-2" /> : <Moon className="w-4 h-4 mr-2" />}
{isDarkMode ? 'Tagmodus' : 'Nachtmodus'}
</DropdownMenuItem>
<DropdownMenuItem onClick={onToggleFullscreen}>
{isFullscreen ? <Minimize className="w-4 h-4 mr-2" /> : <Maximize className="w-4 h-4 mr-2" />}
{isFullscreen ? 'Vollbild verlassen' : 'Vollbild'}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setIsHoseSettingsOpen(true)}>
<Settings className="w-4 h-4 mr-2" />
Einstellungen
</DropdownMenuItem>
<DropdownMenuItem onClick={onToggleAudit}>
<ClipboardList className="w-4 h-4 mr-2" />
Audit Trail {auditLog.length > 0 && `(${auditLog.length})`}
</DropdownMenuItem>
{onLogout && (
<DropdownMenuItem onClick={onLogout} className="text-destructive">
<LogOut className="w-4 h-4 mr-2" />
Abmelden {userName && `(${userName})`}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Audit Trail Panel */}
{isAuditOpen && (
<div className="absolute right-0 top-16 w-96 max-h-[60vh] z-50 bg-card border border-border rounded-bl-lg shadow-xl overflow-hidden flex flex-col">
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-muted/50">
<span className="font-semibold text-sm flex items-center gap-2">
<ClipboardList className="w-4 h-4" /> Audit Trail
</span>
<button onClick={onToggleAudit} className="text-muted-foreground hover:text-foreground text-lg leading-none"></button>
</div>
<ScrollArea className="flex-1 max-h-[calc(60vh-48px)]">
<div className="p-2">
{auditLog.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">Noch keine Aktionen</p>
) : (
auditLog.map((entry, i) => (
<div key={i} className="flex gap-3 px-3 py-2 text-sm border-b border-border/50 last:border-0">
<span className="text-muted-foreground font-mono text-xs whitespace-nowrap pt-0.5">{entry.time}</span>
<span>{entry.action}</span>
</div>
))
)}
</div>
</ScrollArea>
</div>
)}
{/* Project Management Dialog */}
<Dialog open={isLoadDialogOpen} onOpenChange={setIsLoadDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<List className="w-5 h-5" />
Einsätze verwalten
</DialogTitle>
</DialogHeader>
<ScrollArea className="h-[450px] pr-4">
{isLoadingProjects ? (
<div className="flex items-center justify-center h-32">
<span className="text-muted-foreground">Laden...</span>
</div>
) : projects.length === 0 ? (
<div className="flex items-center justify-center h-32">
<span className="text-muted-foreground">Keine Einsätze vorhanden</span>
</div>
) : (
<div className="space-y-2">
{projects.map((p) => (
<div
key={p.id}
className={`p-4 border rounded-lg transition-colors ${
project?.id === p.id ? 'border-primary bg-primary/5' : 'hover:bg-accent'
}`}
>
<div className="flex items-start justify-between gap-3">
<button
onClick={() => handleLoadProject(p.id)}
className="flex-1 text-left"
>
<div className="flex items-center gap-2">
<h4 className="font-medium">{p.title}</h4>
{project?.id === p.id && (
<span className="text-xs bg-primary text-primary-foreground px-1.5 py-0.5 rounded">Aktiv</span>
)}
</div>
{p.location && (
<p className="text-sm text-muted-foreground mt-1">
<MapPin className="w-3 h-3 inline mr-1" />{p.location}
</p>
)}
<p className="text-xs text-muted-foreground mt-1">
Erstellt: {formatDateTime(p.createdAt)} | Geändert: {formatDateTime(p.updatedAt)}
</p>
</button>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => handleLoadProject(p.id)}
title="Einsatz öffnen"
>
<FolderOpen className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8 text-destructive hover:bg-destructive hover:text-destructive-foreground"
onClick={() => setDeleteConfirmId(p.id)}
title="Einsatz löschen"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</div>
))}
</div>
)}
</ScrollArea>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={!!deleteConfirmId} onOpenChange={() => setDeleteConfirmId(null)}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="w-5 h-5" />
Einsatz löschen?
</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Dieser Einsatz wird unwiderruflich gelöscht, inklusive aller Zeichnungen, Journal-Einträge und Daten.
</p>
<div className="flex justify-end gap-2 mt-2">
<Button variant="outline" onClick={() => setDeleteConfirmId(null)} disabled={isDeleting}>
Abbrechen
</Button>
<Button
variant="destructive"
disabled={isDeleting}
onClick={async () => {
if (!deleteConfirmId) return
setIsDeleting(true)
try {
const res = await fetch(`/api/projects/${deleteConfirmId}`, { method: 'DELETE' })
if (res.ok) {
setProjects(prev => prev.filter(p => p.id !== deleteConfirmId))
if (onDeleteProject) onDeleteProject(deleteConfirmId)
setDeleteConfirmId(null)
}
} catch (e) {
console.error('Delete failed:', e)
} finally {
setIsDeleting(false)
}
}}
>
{isDeleting ? 'Löschen...' : 'Endgültig löschen'}
</Button>
</div>
</DialogContent>
</Dialog>
<HoseSettingsDialog open={isHoseSettingsOpen} onOpenChange={setIsHoseSettingsOpen} />
{/* Password Change Dialog */}
<Dialog open={showPasswordDialog} onOpenChange={(open) => {
setShowPasswordDialog(open)
if (!open) { setPwOld(''); setPwNew(''); setPwConfirm(''); setPwStatus(null); setPwError('') }
}}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Key className="w-5 h-5" />
Kennwort ändern
</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-muted-foreground">Aktuelles Kennwort</label>
<input type="password" value={pwOld} onChange={e => setPwOld(e.target.value)} className="w-full border rounded-md px-3 py-2 text-sm mt-1" autoComplete="current-password" />
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Neues Kennwort</label>
<input type="password" value={pwNew} onChange={e => setPwNew(e.target.value)} className="w-full border rounded-md px-3 py-2 text-sm mt-1" autoComplete="new-password" />
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Kennwort bestätigen</label>
<input type="password" value={pwConfirm} onChange={e => setPwConfirm(e.target.value)} className="w-full border rounded-md px-3 py-2 text-sm mt-1" autoComplete="new-password" />
</div>
{pwStatus === 'error' && <p className="text-sm text-destructive">{pwError}</p>}
{pwStatus === 'success' && <p className="text-sm text-green-600">Kennwort erfolgreich geändert!</p>}
</div>
<div className="flex justify-end gap-2 mt-2">
<Button variant="outline" size="sm" onClick={() => setShowPasswordDialog(false)}>Abbrechen</Button>
<Button
size="sm"
disabled={pwLoading || !pwOld || !pwNew || pwNew !== pwConfirm || pwNew.length < 6}
onClick={async () => {
setPwLoading(true)
setPwStatus(null)
setPwError('')
try {
const res = await fetch('/api/auth/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword: pwOld, newPassword: pwNew }),
})
const data = await res.json()
if (res.ok) {
setPwStatus('success')
setPwOld(''); setPwNew(''); setPwConfirm('')
} else {
setPwStatus('error')
setPwError(data.error || 'Fehler beim Ändern')
}
} catch { setPwStatus('error'); setPwError('Verbindungsfehler') }
finally { setPwLoading(false) }
}}
>
{pwLoading ? 'Speichern...' : 'Kennwort ändern'}
</Button>
</div>
</DialogContent>
</Dialog>
</header>
)
}

View File

@@ -0,0 +1,76 @@
'use client'
import { useState } from 'react'
import { X, Clock, AlertTriangle, Info } from 'lucide-react'
import type { TenantInfo } from '@/components/providers/auth-provider'
interface TrialBannerProps {
tenant: TenantInfo
}
export function TrialBanner({ tenant }: TrialBannerProps) {
const [dismissed, setDismissed] = useState(false)
if (dismissed) return null
if (tenant.subscriptionStatus !== 'TRIAL') return null
if (!tenant.trialEndsAt) return null
const now = new Date()
const trialEnd = new Date(tenant.trialEndsAt)
const diffMs = trialEnd.getTime() - now.getTime()
const daysLeft = Math.ceil(diffMs / (1000 * 60 * 60 * 24))
// Don't show if trial already expired (handled elsewhere) or more than 14 days left
if (daysLeft > 14) return null
const isExpired = daysLeft <= 0
const isUrgent = daysLeft <= 3
const isWarning = daysLeft <= 7
let bgColor = 'bg-blue-50 border-blue-200 text-blue-800'
let Icon = Info
let message = ''
if (isExpired) {
bgColor = 'bg-red-50 border-red-200 text-red-800'
Icon = AlertTriangle
message = `Ihre Testphase ist abgelaufen. Ihr Konto wurde auf den Free-Plan umgestellt. Upgraden Sie für erweiterte Funktionen.`
} else if (isUrgent) {
bgColor = 'bg-orange-50 border-orange-200 text-orange-800'
Icon = AlertTriangle
message = `Ihre Testphase endet ${daysLeft === 1 ? 'morgen' : `in ${daysLeft} Tagen`}. Danach wechselt Ihr Konto automatisch zum Free-Plan.`
} else if (isWarning) {
bgColor = 'bg-yellow-50 border-yellow-200 text-yellow-800'
Icon = Clock
message = `Noch ${daysLeft} Tage in Ihrer Testphase. Upgraden Sie rechtzeitig, um alle Funktionen zu behalten.`
} else {
bgColor = 'bg-blue-50 border-blue-200 text-blue-800'
Icon = Info
message = `Testphase: Noch ${daysLeft} Tage. Sie nutzen aktuell alle Funktionen kostenlos.`
}
const endDateStr = trialEnd.toLocaleDateString('de-CH', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
return (
<div className={`${bgColor} border-b px-4 py-2 flex items-center gap-3 text-sm shrink-0 print:hidden`}>
<Icon className="w-4 h-4 shrink-0" />
<div className="flex-1 min-w-0">
<span className="font-medium">{message}</span>
<span className="text-xs opacity-75 ml-2">(Ablauf: {endDateStr})</span>
</div>
{!isExpired && (
<button
onClick={() => setDismissed(true)}
className="p-1 rounded hover:bg-black/10 transition-colors shrink-0"
title="Ausblenden"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,37 @@
'use client'
import { useDragLayer } from 'react-dnd'
export function CustomDragLayer() {
const { isDragging, item, currentOffset } = useDragLayer((monitor) => ({
isDragging: monitor.isDragging(),
item: monitor.getItem() as { iconId: string; imageUrl?: string } | null,
currentOffset: monitor.getClientOffset(),
}))
if (!isDragging || !currentOffset || !item?.imageUrl) {
return null
}
return (
<div
style={{
position: 'fixed',
pointerEvents: 'none',
zIndex: 9999,
left: 0,
top: 0,
transform: `translate(${currentOffset.x - 24}px, ${currentOffset.y - 24}px)`,
}}
>
<div className="w-12 h-12 bg-white/90 rounded-lg shadow-lg border-2 border-primary flex items-center justify-center backdrop-blur-sm">
<img
src={item.imageUrl}
alt=""
className="w-10 h-10 object-contain"
crossOrigin="anonymous"
/>
</div>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More