Initial commit: Lageplan v1.0 - Next.js 15.5, React 19
This commit is contained in:
96
src/app/[slug]/page.tsx
Normal file
96
src/app/[slug]/page.tsx
Normal 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
1854
src/app/admin/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
86
src/app/api/admin/categories/[id]/route.ts
Normal file
86
src/app/api/admin/categories/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
77
src/app/api/admin/categories/route.ts
Normal file
77
src/app/api/admin/categories/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
103
src/app/api/admin/icons/[id]/route.ts
Normal file
103
src/app/api/admin/icons/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
18
src/app/api/admin/icons/route.ts
Normal file
18
src/app/api/admin/icons/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
91
src/app/api/admin/icons/upload/route.ts
Normal file
91
src/app/api/admin/icons/upload/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
156
src/app/api/admin/settings/route.ts
Normal file
156
src/app/api/admin/settings/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
77
src/app/api/admin/tenants/[id]/logo/route.ts
Normal file
77
src/app/api/admin/tenants/[id]/logo/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
43
src/app/api/admin/tenants/[id]/logo/serve/route.ts
Normal file
43
src/app/api/admin/tenants/[id]/logo/serve/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
178
src/app/api/admin/tenants/[id]/members/route.ts
Normal file
178
src/app/api/admin/tenants/[id]/members/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
88
src/app/api/admin/tenants/[id]/route.ts
Normal file
88
src/app/api/admin/tenants/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
65
src/app/api/admin/tenants/route.ts
Normal file
65
src/app/api/admin/tenants/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
205
src/app/api/admin/trial-reminders/route.ts
Normal file
205
src/app/api/admin/trial-reminders/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
89
src/app/api/admin/users/[id]/reset-password/route.ts
Normal file
89
src/app/api/admin/users/[id]/reset-password/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
135
src/app/api/admin/users/[id]/route.ts
Normal file
135
src/app/api/admin/users/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
101
src/app/api/admin/users/route.ts
Normal file
101
src/app/api/admin/users/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
46
src/app/api/auth/change-password/route.ts
Normal file
46
src/app/api/auth/change-password/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
73
src/app/api/auth/forgot-password/route.ts
Normal file
73
src/app/api/auth/forgot-password/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
55
src/app/api/auth/login/route.ts
Normal file
55
src/app/api/auth/login/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
7
src/app/api/auth/logout/route.ts
Normal file
7
src/app/api/auth/logout/route.ts
Normal 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 })
|
||||
}
|
||||
33
src/app/api/auth/me/route.ts
Normal file
33
src/app/api/auth/me/route.ts
Normal 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 })
|
||||
}
|
||||
175
src/app/api/auth/register/route.ts
Normal file
175
src/app/api/auth/register/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
43
src/app/api/auth/reset-password/route.ts
Normal file
43
src/app/api/auth/reset-password/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
49
src/app/api/auth/verify-email/route.ts
Normal file
49
src/app/api/auth/verify-email/route.ts
Normal 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`)
|
||||
}
|
||||
}
|
||||
75
src/app/api/contact/route.ts
Normal file
75
src/app/api/contact/route.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
72
src/app/api/demo/route.ts
Normal file
72
src/app/api/demo/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
37
src/app/api/dictionary/[id]/route.ts
Normal file
37
src/app/api/dictionary/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
80
src/app/api/dictionary/route.ts
Normal file
80
src/app/api/dictionary/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
56
src/app/api/donate/checkout/route.ts
Normal file
56
src/app/api/donate/checkout/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
27
src/app/api/donate/config/route.ts
Normal file
27
src/app/api/donate/config/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
70
src/app/api/donate/webhook/route.ts
Normal file
70
src/app/api/donate/webhook/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
74
src/app/api/hose-types/[id]/route.ts
Normal file
74
src/app/api/hose-types/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
74
src/app/api/hose-types/route.ts
Normal file
74
src/app/api/hose-types/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
70
src/app/api/icons/[id]/image/route.ts
Normal file
70
src/app/api/icons/[id]/image/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
54
src/app/api/icons/[id]/toggle-visibility/route.ts
Normal file
54
src/app/api/icons/[id]/toggle-visibility/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
66
src/app/api/icons/route.ts
Normal file
66
src/app/api/icons/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
79
src/app/api/icons/upload/route.ts
Normal file
79
src/app/api/icons/upload/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
173
src/app/api/projects/[id]/editing/route.ts
Normal file
173
src/app/api/projects/[id]/editing/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
85
src/app/api/projects/[id]/export/route.ts
Normal file
85
src/app/api/projects/[id]/export/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
139
src/app/api/projects/[id]/features/route.ts
Normal file
139
src/app/api/projects/[id]/features/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
57
src/app/api/projects/[id]/journal/check-items/route.ts
Normal file
57
src/app/api/projects/[id]/journal/check-items/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
101
src/app/api/projects/[id]/journal/entries/[entryId]/route.ts
Normal file
101
src/app/api/projects/[id]/journal/entries/[entryId]/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
31
src/app/api/projects/[id]/journal/entries/route.ts
Normal file
31
src/app/api/projects/[id]/journal/entries/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
30
src/app/api/projects/[id]/journal/pendenzen/route.ts
Normal file
30
src/app/api/projects/[id]/journal/pendenzen/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
35
src/app/api/projects/[id]/journal/route.ts
Normal file
35
src/app/api/projects/[id]/journal/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
168
src/app/api/projects/[id]/journal/send-report/route.ts
Normal file
168
src/app/api/projects/[id]/journal/send-report/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
121
src/app/api/projects/[id]/plan-image/route.ts
Normal file
121
src/app/api/projects/[id]/plan-image/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
40
src/app/api/projects/[id]/plan-image/serve/route.ts
Normal file
40
src/app/api/projects/[id]/plan-image/serve/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
110
src/app/api/projects/[id]/route.ts
Normal file
110
src/app/api/projects/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
94
src/app/api/projects/route.ts
Normal file
94
src/app/api/projects/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
86
src/app/api/rapports/[token]/pdf/route.ts
Normal file
86
src/app/api/rapports/[token]/pdf/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
77
src/app/api/rapports/[token]/route.ts
Normal file
77
src/app/api/rapports/[token]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
71
src/app/api/rapports/[token]/send/route.ts
Normal file
71
src/app/api/rapports/[token]/send/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
140
src/app/api/rapports/route.ts
Normal file
140
src/app/api/rapports/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
17
src/app/api/settings/public/route.ts
Normal file
17
src/app/api/settings/public/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
102
src/app/api/tenant/delete/route.ts
Normal file
102
src/app/api/tenant/delete/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
41
src/app/api/tenant/info/route.ts
Normal file
41
src/app/api/tenant/info/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
77
src/app/api/tenants/[tenantId]/suggestions/route.ts
Normal file
77
src/app/api/tenants/[tenantId]/suggestions/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
44
src/app/api/tenants/by-slug/[slug]/route.ts
Normal file
44
src/app/api/tenants/by-slug/[slug]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
185
src/app/api/upgrade-requests/[id]/route.ts
Normal file
185
src/app/api/upgrade-requests/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
204
src/app/api/upgrade-requests/route.ts
Normal file
204
src/app/api/upgrade-requests/route.ts
Normal 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
1384
src/app/app/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
187
src/app/datenschutz/page.tsx
Normal file
187
src/app/datenschutz/page.tsx
Normal 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
610
src/app/demo/page.tsx
Normal 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: '© 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>
|
||||
)
|
||||
}
|
||||
129
src/app/forgot-password/page.tsx
Normal file
129
src/app/forgot-password/page.tsx
Normal 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
228
src/app/globals.css
Normal 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%);
|
||||
}
|
||||
}
|
||||
11
src/app/impressum/layout.tsx
Normal file
11
src/app/impressum/layout.tsx
Normal 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
147
src/app/impressum/page.tsx
Normal 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>{' '}
|
||||
(© 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">© {new Date().getFullYear()} Lageplan — Purepixel / Kingstickers Ziberi</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
116
src/app/layout.tsx
Normal file
116
src/app/layout.tsx
Normal 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
11
src/app/login/layout.tsx
Normal 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
180
src/app/login/page.tsx
Normal 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
120
src/app/opengraph-image.tsx
Normal 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
673
src/app/page.tsx
Normal 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">© {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>
|
||||
)
|
||||
}
|
||||
311
src/app/rapport/[token]/page.tsx
Normal file
311
src/app/rapport/[token]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
11
src/app/register/layout.tsx
Normal file
11
src/app/register/layout.tsx
Normal 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
242
src/app/register/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
166
src/app/reset-password/page.tsx
Normal file
166
src/app/reset-password/page.tsx
Normal 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
16
src/app/robots.ts
Normal 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
192
src/app/settings/page.tsx
Normal 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
38
src/app/sitemap.ts
Normal 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,
|
||||
},
|
||||
]
|
||||
}
|
||||
64
src/app/spenden/danke/page.tsx
Normal file
64
src/app/spenden/danke/page.tsx
Normal 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">
|
||||
«Jede Spende motiviert mich, weiterzumachen. Danke von Herzen!»
|
||||
</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>
|
||||
)
|
||||
}
|
||||
11
src/app/spenden/layout.tsx
Normal file
11
src/app/spenden/layout.tsx
Normal 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
230
src/app/spenden/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user