Initial commit: Lageplan v1.0 - Next.js 15.5, React 19
This commit is contained in:
128
src/lib/auth.ts
Normal file
128
src/lib/auth.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { SignJWT, jwtVerify } from 'jose'
|
||||
import { cookies } from 'next/headers'
|
||||
import { prisma } from './db'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
const secretValue = process.env.NEXTAUTH_SECRET
|
||||
if (!secretValue || secretValue.length < 32) {
|
||||
console.warn('[AUTH] WARNING: NEXTAUTH_SECRET is missing or too short (<32 chars). Set a strong secret in production!')
|
||||
}
|
||||
const JWT_SECRET = new TextEncoder().encode(
|
||||
secretValue || 'dev-only-fallback-do-not-use-in-production-' + Date.now()
|
||||
)
|
||||
|
||||
export interface UserPayload {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
role: 'SERVER_ADMIN' | 'TENANT_ADMIN' | 'OPERATOR' | 'VIEWER'
|
||||
tenantId?: string
|
||||
tenantSlug?: string
|
||||
}
|
||||
|
||||
export async function createToken(user: UserPayload): Promise<string> {
|
||||
return await new SignJWT({ user })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('24h')
|
||||
.sign(JWT_SECRET)
|
||||
}
|
||||
|
||||
export async function verifyToken(token: string): Promise<UserPayload | null> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, JWT_SECRET)
|
||||
return payload.user as UserPayload
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSession(): Promise<UserPayload | null> {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get('auth-token')?.value
|
||||
|
||||
if (!token) return null
|
||||
|
||||
return await verifyToken(token)
|
||||
}
|
||||
|
||||
export async function login(
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<{ success: boolean; user?: UserPayload; error?: string }> {
|
||||
const user = await (prisma.user.findUnique({
|
||||
where: { email },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
password: true,
|
||||
role: true,
|
||||
emailVerified: true,
|
||||
},
|
||||
}) as any)
|
||||
|
||||
if (!user) {
|
||||
return { success: false, error: 'Benutzer nicht gefunden' }
|
||||
}
|
||||
|
||||
const isValidPassword = await bcrypt.compare(password, user.password)
|
||||
if (!isValidPassword) {
|
||||
return { success: false, error: 'Ungültiges Passwort' }
|
||||
}
|
||||
|
||||
// Check email verification (skip for SERVER_ADMIN and users created before verification was added)
|
||||
if ((user as any).emailVerified === false && (user.role as string) !== 'SERVER_ADMIN') {
|
||||
return { success: false, error: 'Bitte bestätigen Sie zuerst Ihre E-Mail-Adresse. Prüfen Sie Ihren Posteingang.' }
|
||||
}
|
||||
|
||||
// Get first tenant membership for non-server-admins
|
||||
let tenantId: string | undefined
|
||||
let tenantSlug: string | undefined
|
||||
if ((user.role as string) !== 'SERVER_ADMIN') {
|
||||
const membership = await (prisma as any).tenantMembership.findFirst({
|
||||
where: { userId: user.id },
|
||||
include: { tenant: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
if (membership) {
|
||||
// Check if tenant is active
|
||||
if (!membership.tenant.isActive) {
|
||||
return { success: false, error: 'Ihr Mandant wurde gesperrt. Bitte kontaktieren Sie den Administrator.' }
|
||||
}
|
||||
tenantId = membership.tenantId
|
||||
tenantSlug = membership.tenant.slug
|
||||
}
|
||||
}
|
||||
|
||||
const userPayload: UserPayload = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: (user.role === 'ADMIN' ? 'SERVER_ADMIN' : user.role) as UserPayload['role'],
|
||||
tenantId,
|
||||
tenantSlug,
|
||||
}
|
||||
|
||||
return { success: true, user: userPayload }
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return await bcrypt.hash(password, 12)
|
||||
}
|
||||
|
||||
export function canEdit(role: string): boolean {
|
||||
return role === 'SERVER_ADMIN' || role === 'TENANT_ADMIN' || role === 'OPERATOR'
|
||||
}
|
||||
|
||||
export function isAdmin(role: string): boolean {
|
||||
return role === 'SERVER_ADMIN' || role === 'TENANT_ADMIN'
|
||||
}
|
||||
|
||||
export function isServerAdmin(role: string): boolean {
|
||||
return role === 'SERVER_ADMIN'
|
||||
}
|
||||
|
||||
export function isTenantAdmin(role: string): boolean {
|
||||
return role === 'TENANT_ADMIN'
|
||||
}
|
||||
11
src/lib/db.ts
Normal file
11
src/lib/db.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined
|
||||
}
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||
|
||||
export default prisma
|
||||
111
src/lib/email.ts
Normal file
111
src/lib/email.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
import { prisma } from './db'
|
||||
|
||||
export interface SmtpConfig {
|
||||
host: string
|
||||
port: number
|
||||
secure: boolean
|
||||
user: string
|
||||
pass: string
|
||||
fromName: string
|
||||
fromEmail: string
|
||||
}
|
||||
|
||||
// Get SMTP settings from DB (SystemSetting table)
|
||||
export async function getSmtpConfig(): Promise<SmtpConfig | null> {
|
||||
try {
|
||||
const settings = await (prisma as any).systemSetting.findMany({
|
||||
where: { category: 'smtp' },
|
||||
})
|
||||
if (!settings || settings.length === 0) return null
|
||||
|
||||
const map: Record<string, string> = {}
|
||||
for (const s of settings) {
|
||||
map[s.key] = s.value
|
||||
}
|
||||
|
||||
if (!map['smtp_host'] || !map['smtp_user']) return null
|
||||
|
||||
return {
|
||||
host: map['smtp_host'],
|
||||
port: parseInt(map['smtp_port'] || '587'),
|
||||
secure: map['smtp_secure'] === 'true',
|
||||
user: map['smtp_user'],
|
||||
pass: map['smtp_pass'] || '',
|
||||
fromName: map['smtp_from_name'] || 'Lageplan',
|
||||
fromEmail: map['smtp_from_email'] || map['smtp_user'],
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Save SMTP settings to DB
|
||||
export async function saveSmtpConfig(config: Partial<SmtpConfig>): Promise<void> {
|
||||
const entries: { key: string; value: string; isSecret: boolean }[] = []
|
||||
if (config.host !== undefined) entries.push({ key: 'smtp_host', value: config.host, isSecret: false })
|
||||
if (config.port !== undefined) entries.push({ key: 'smtp_port', value: String(config.port), isSecret: false })
|
||||
if (config.secure !== undefined) entries.push({ key: 'smtp_secure', value: String(config.secure), isSecret: false })
|
||||
if (config.user !== undefined) entries.push({ key: 'smtp_user', value: config.user, isSecret: false })
|
||||
if (config.pass !== undefined) entries.push({ key: 'smtp_pass', value: config.pass, isSecret: true })
|
||||
if (config.fromName !== undefined) entries.push({ key: 'smtp_from_name', value: config.fromName, isSecret: false })
|
||||
if (config.fromEmail !== undefined) entries.push({ key: 'smtp_from_email', value: config.fromEmail, isSecret: false })
|
||||
|
||||
for (const entry of entries) {
|
||||
await (prisma as any).systemSetting.upsert({
|
||||
where: { key: entry.key },
|
||||
update: { value: entry.value, isSecret: entry.isSecret },
|
||||
create: { key: entry.key, value: entry.value, isSecret: entry.isSecret, category: 'smtp' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Create nodemailer transport from DB settings
|
||||
async function createTransport() {
|
||||
const config = await getSmtpConfig()
|
||||
if (!config) throw new Error('SMTP nicht konfiguriert')
|
||||
|
||||
return nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
auth: {
|
||||
user: config.user,
|
||||
pass: config.pass,
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Send email
|
||||
export async function sendEmail(to: string, subject: string, html: string): Promise<boolean> {
|
||||
try {
|
||||
const config = await getSmtpConfig()
|
||||
if (!config) throw new Error('SMTP nicht konfiguriert')
|
||||
const transport = await createTransport()
|
||||
|
||||
await transport.sendMail({
|
||||
from: `"${config.fromName}" <${config.fromEmail}>`,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Email send error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Test SMTP connection
|
||||
export async function testSmtpConnection(): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const transport = await createTransport()
|
||||
await transport.verify()
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : 'Verbindung fehlgeschlagen' }
|
||||
}
|
||||
}
|
||||
184
src/lib/export.ts
Normal file
184
src/lib/export.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import html2canvas from 'html2canvas'
|
||||
import jsPDF from 'jspdf'
|
||||
import type { Project, DrawFeature } from '@/app/app/page'
|
||||
import { formatDateTime } from './utils'
|
||||
|
||||
export interface ExportOptions {
|
||||
includeTitle?: boolean
|
||||
includeLegend?: boolean
|
||||
includeTimestamp?: boolean
|
||||
author?: string
|
||||
}
|
||||
|
||||
export async function exportToPNG(
|
||||
mapElement: HTMLElement,
|
||||
project: Project,
|
||||
options: ExportOptions = {}
|
||||
): Promise<void> {
|
||||
try {
|
||||
const canvas = await html2canvas(mapElement, {
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
scale: 2,
|
||||
logging: false,
|
||||
backgroundColor: '#ffffff',
|
||||
})
|
||||
|
||||
const link = document.createElement('a')
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
||||
link.download = `Lageplan_${project.title.replace(/[^a-z0-9äöüß]/gi, '_')}_${timestamp}.png`
|
||||
link.href = canvas.toDataURL('image/png', 1.0)
|
||||
link.click()
|
||||
} catch (error) {
|
||||
console.error('PNG export failed:', error)
|
||||
throw new Error('PNG Export fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
export interface LegendEntry {
|
||||
name: string
|
||||
count: number
|
||||
iconUrl?: string
|
||||
unNummer?: string
|
||||
gefahrennummer?: string
|
||||
}
|
||||
|
||||
export async function exportToPDF(
|
||||
mapElement: HTMLElement,
|
||||
project: Project,
|
||||
features: DrawFeature[],
|
||||
legendEntries: LegendEntry[] = [],
|
||||
options: ExportOptions = {}
|
||||
): Promise<void> {
|
||||
try {
|
||||
const canvas = await html2canvas(mapElement, {
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
scale: 2,
|
||||
logging: false,
|
||||
backgroundColor: '#ffffff',
|
||||
})
|
||||
|
||||
const imgWidth = 190
|
||||
const imgHeight = (canvas.height * imgWidth) / canvas.width
|
||||
|
||||
const pdf = new jsPDF('p', 'mm', 'a4')
|
||||
|
||||
// Title Block
|
||||
pdf.setFillColor(220, 38, 38) // Red header
|
||||
pdf.rect(0, 0, 210, 8, 'F')
|
||||
|
||||
pdf.setTextColor(255, 255, 255)
|
||||
pdf.setFontSize(12)
|
||||
pdf.setFont('helvetica', 'bold')
|
||||
pdf.text('LAGEPLAN - FEUERWEHR', 10, 5.5)
|
||||
|
||||
pdf.setTextColor(0, 0, 0)
|
||||
|
||||
// Project info box
|
||||
pdf.setFillColor(245, 245, 245)
|
||||
pdf.rect(10, 12, 190, 28, 'F')
|
||||
pdf.setDrawColor(200, 200, 200)
|
||||
pdf.rect(10, 12, 190, 28, 'S')
|
||||
|
||||
pdf.setFontSize(14)
|
||||
pdf.setFont('helvetica', 'bold')
|
||||
pdf.text(project.title, 15, 22)
|
||||
|
||||
pdf.setFontSize(10)
|
||||
pdf.setFont('helvetica', 'normal')
|
||||
|
||||
let infoY = 28
|
||||
if (project.location) {
|
||||
pdf.text(`Einsatzort: ${project.location}`, 15, infoY)
|
||||
infoY += 5
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
pdf.text(`Datum: ${now.toLocaleDateString('de-DE')}`, 15, infoY)
|
||||
pdf.text(`Uhrzeit: ${now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`, 80, infoY)
|
||||
|
||||
if (options.author) {
|
||||
pdf.text(`Ersteller: ${options.author}`, 130, infoY)
|
||||
}
|
||||
|
||||
// Map image
|
||||
const mapY = 45
|
||||
const maxMapHeight = legendEntries.length > 0 ? 180 : 220
|
||||
const actualMapHeight = Math.min(imgHeight, maxMapHeight)
|
||||
|
||||
pdf.addImage(canvas.toDataURL('image/png'), 'PNG', 10, mapY, imgWidth, actualMapHeight)
|
||||
pdf.setDrawColor(100, 100, 100)
|
||||
pdf.rect(10, mapY, imgWidth, actualMapHeight, 'S')
|
||||
|
||||
// Legend
|
||||
if (legendEntries.length > 0) {
|
||||
const legendY = mapY + actualMapHeight + 8
|
||||
|
||||
pdf.setFillColor(245, 245, 245)
|
||||
pdf.rect(10, legendY, 190, 6, 'F')
|
||||
|
||||
pdf.setFontSize(10)
|
||||
pdf.setFont('helvetica', 'bold')
|
||||
pdf.text('Legende', 15, legendY + 4.5)
|
||||
|
||||
pdf.setFontSize(8)
|
||||
pdf.setFont('helvetica', 'normal')
|
||||
|
||||
let y = legendY + 12
|
||||
const colWidth = 63
|
||||
let col = 0
|
||||
|
||||
legendEntries.forEach((entry, i) => {
|
||||
if (y < 280) {
|
||||
const x = 15 + (col * colWidth)
|
||||
let text = `• ${entry.name}`
|
||||
if (entry.count > 1) text += ` (${entry.count})`
|
||||
if (entry.unNummer) text += ` [UN ${entry.unNummer}]`
|
||||
|
||||
pdf.text(text, x, y)
|
||||
|
||||
col++
|
||||
if (col >= 3) {
|
||||
col = 0
|
||||
y += 5
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Footer
|
||||
pdf.setFontSize(7)
|
||||
pdf.setTextColor(128, 128, 128)
|
||||
pdf.text(
|
||||
`Erstellt mit Lageplan-App | ${now.toLocaleDateString('de-DE')} ${now.toLocaleTimeString('de-DE')}`,
|
||||
10,
|
||||
292
|
||||
)
|
||||
pdf.text('Seite 1 von 1', 180, 292)
|
||||
|
||||
const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
||||
pdf.save(`Lageplan_${project.title.replace(/[^a-z0-9äöüß]/gi, '_')}_${timestamp}.pdf`)
|
||||
} catch (error) {
|
||||
console.error('PDF export failed:', error)
|
||||
throw new Error('PDF Export fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportToGeoJSON(projectId: string): Promise<void> {
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/export?format=geojson`)
|
||||
if (!res.ok) throw new Error('Export fehlgeschlagen')
|
||||
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `project_${projectId}.geojson`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
console.error('GeoJSON export failed:', error)
|
||||
throw new Error('GeoJSON Export fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
35
src/lib/fw-symbols.ts
Normal file
35
src/lib/fw-symbols.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// Feuerwehr-Signaturen nach Schweizer Standard (FKS/BABS)
|
||||
// Icons are now loaded from public/signaturen/ via the database (seed.js)
|
||||
// This file is kept for backward compatibility with old saved features
|
||||
|
||||
export interface FWSymbol {
|
||||
id: string
|
||||
name: string
|
||||
category: string
|
||||
svg: string
|
||||
}
|
||||
|
||||
export interface FWCategory {
|
||||
id: string
|
||||
name: string
|
||||
symbols: FWSymbol[]
|
||||
}
|
||||
|
||||
const svgToDataUri = (svg: string): string => {
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
|
||||
}
|
||||
|
||||
// All symbols are now served from the database / public/signaturen/
|
||||
export const fwCategories: FWCategory[] = []
|
||||
|
||||
export function getSymbolById(id: string): FWSymbol | undefined {
|
||||
for (const cat of fwCategories) {
|
||||
const found = cat.symbols.find((s) => s.id === id)
|
||||
if (found) return found
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function getSymbolDataUri(symbol: FWSymbol): string {
|
||||
return svgToDataUri(symbol.svg)
|
||||
}
|
||||
54
src/lib/minio.ts
Normal file
54
src/lib/minio.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import * as Minio from 'minio'
|
||||
|
||||
const minioClient = new Minio.Client({
|
||||
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
|
||||
port: parseInt(process.env.MINIO_PORT || '9000'),
|
||||
useSSL: process.env.MINIO_USE_SSL === 'true',
|
||||
accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin',
|
||||
secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin123',
|
||||
})
|
||||
|
||||
const BUCKET = process.env.MINIO_BUCKET || 'lageplan-icons'
|
||||
|
||||
export async function ensureBucket(): Promise<void> {
|
||||
const exists = await minioClient.bucketExists(BUCKET)
|
||||
if (!exists) {
|
||||
await minioClient.makeBucket(BUCKET)
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadFile(
|
||||
fileKey: string,
|
||||
buffer: Buffer,
|
||||
mimeType: string
|
||||
): Promise<string> {
|
||||
await ensureBucket()
|
||||
await minioClient.putObject(BUCKET, fileKey, buffer, buffer.length, {
|
||||
'Content-Type': mimeType,
|
||||
})
|
||||
return fileKey
|
||||
}
|
||||
|
||||
export async function getFileUrl(fileKey: string): Promise<string> {
|
||||
const publicUrl = process.env.MINIO_PUBLIC_URL || 'http://localhost:9000'
|
||||
return `${publicUrl}/${BUCKET}/${fileKey}`
|
||||
}
|
||||
|
||||
export async function getFileStream(fileKey: string): Promise<{ stream: NodeJS.ReadableStream; contentType: string }> {
|
||||
const stat = await minioClient.statObject(BUCKET, fileKey)
|
||||
const stream = await minioClient.getObject(BUCKET, fileKey)
|
||||
return { stream, contentType: stat.metaData['content-type'] || 'application/octet-stream' }
|
||||
}
|
||||
|
||||
export async function deleteFile(fileKey: string): Promise<void> {
|
||||
await minioClient.removeObject(BUCKET, fileKey)
|
||||
}
|
||||
|
||||
export async function getPresignedUrl(
|
||||
fileKey: string,
|
||||
expirySeconds: number = 3600
|
||||
): Promise<string> {
|
||||
return await minioClient.presignedGetObject(BUCKET, fileKey, expirySeconds)
|
||||
}
|
||||
|
||||
export { minioClient, BUCKET }
|
||||
319
src/lib/rapport-pdf.tsx
Normal file
319
src/lib/rapport-pdf.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
import React from 'react'
|
||||
import { Document, Page, Text, View, StyleSheet, Font, Image } from '@react-pdf/renderer'
|
||||
|
||||
// Register default font (Helvetica is built-in)
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
padding: '12mm 15mm 15mm',
|
||||
fontFamily: 'Helvetica',
|
||||
fontSize: 10,
|
||||
lineHeight: 1.4,
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
// Header
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
paddingBottom: 8,
|
||||
borderBottomWidth: 2.5,
|
||||
borderBottomColor: '#1a1a1a',
|
||||
marginBottom: 4,
|
||||
},
|
||||
headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 12 },
|
||||
headerTitle: { fontSize: 18, fontFamily: 'Helvetica-Bold', letterSpacing: -0.5 },
|
||||
headerSubtitle: { fontSize: 8, color: '#555', marginTop: 2, fontFamily: 'Helvetica-Bold' },
|
||||
headerRight: { textAlign: 'right' },
|
||||
eventId: { fontSize: 16, fontFamily: 'Helvetica-Bold', color: '#c0392b' },
|
||||
eventDate: { fontSize: 8, color: '#555', marginTop: 2 },
|
||||
headerStripe: { height: 3, marginBottom: 10, backgroundColor: '#c0392b' },
|
||||
// Sections
|
||||
section: { marginBottom: 10 },
|
||||
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 5 },
|
||||
sectionNumber: {
|
||||
width: 18, height: 18, backgroundColor: '#1a1a1a', borderRadius: 3,
|
||||
color: '#fff', fontSize: 8, fontFamily: 'Helvetica-Bold',
|
||||
textAlign: 'center', paddingTop: 3,
|
||||
},
|
||||
sectionTitle: { fontSize: 9, fontFamily: 'Helvetica-Bold', textTransform: 'uppercase', letterSpacing: 0.8 },
|
||||
sectionLine: { flex: 1, height: 1, backgroundColor: '#d0d0d0', marginLeft: 6 },
|
||||
// Field grid
|
||||
fieldGrid: { borderWidth: 1, borderColor: '#d0d0d0', borderRadius: 4 },
|
||||
fieldRow: { flexDirection: 'row' },
|
||||
field: { padding: '5 8', borderBottomWidth: 1, borderBottomColor: '#e8e8e8', borderRightWidth: 1, borderRightColor: '#e8e8e8', minHeight: 32 },
|
||||
fieldLabel: { fontSize: 6.5, fontFamily: 'Helvetica-Bold', textTransform: 'uppercase', letterSpacing: 0.5, color: '#888', marginBottom: 1 },
|
||||
fieldValue: { fontSize: 9, fontFamily: 'Helvetica-Bold', color: '#1a1a1a', minHeight: 14 },
|
||||
fieldValueMono: { fontSize: 8.5, fontFamily: 'Courier', color: '#1a1a1a', minHeight: 14 },
|
||||
fieldHighlight: { backgroundColor: '#f5f5f5' },
|
||||
// Resource table
|
||||
tableHeader: { flexDirection: 'row', backgroundColor: '#1a1a1a' },
|
||||
tableHeaderCell: { padding: '4 8', fontSize: 7, fontFamily: 'Helvetica-Bold', textTransform: 'uppercase', letterSpacing: 0.5, color: '#fff' },
|
||||
tableRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#e8e8e8' },
|
||||
tableRowEven: { backgroundColor: '#f5f5f5' },
|
||||
tableCell: { padding: '4 8', fontSize: 8.5 },
|
||||
// Notes
|
||||
notesBox: { borderWidth: 1, borderColor: '#d0d0d0', borderRadius: 4, padding: 8, minHeight: 50 },
|
||||
notesText: { fontSize: 9, color: '#1a1a1a' },
|
||||
// Signatures
|
||||
signatureBlock: { flexDirection: 'row', gap: 30, marginTop: 8, marginBottom: 20 },
|
||||
signatureField: { flex: 1 },
|
||||
signatureLine: { borderBottomWidth: 1, borderBottomColor: '#1a1a1a', height: 25, marginBottom: 3 },
|
||||
signatureLabel: { fontSize: 7, color: '#888' },
|
||||
// Footer
|
||||
footer: {
|
||||
position: 'absolute', bottom: '10mm', left: '15mm', right: '15mm',
|
||||
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-end',
|
||||
paddingTop: 6, borderTopWidth: 1, borderTopColor: '#d0d0d0', fontSize: 7, color: '#888',
|
||||
},
|
||||
// Map placeholder
|
||||
mapContainer: {
|
||||
borderWidth: 1, borderColor: '#d0d0d0', borderRadius: 4,
|
||||
height: '80mm', backgroundColor: '#f5f5f5',
|
||||
justifyContent: 'center', alignItems: 'center',
|
||||
},
|
||||
mapPlaceholder: { fontSize: 9, color: '#888', textAlign: 'center' },
|
||||
// Priority badges
|
||||
priorityHigh: { backgroundColor: '#fde8e8', color: '#991b1b', padding: '2 8', borderRadius: 3, fontSize: 7.5, fontFamily: 'Helvetica-Bold' },
|
||||
priorityMedium: { backgroundColor: '#fef3cd', color: '#856404', padding: '2 8', borderRadius: 3, fontSize: 7.5, fontFamily: 'Helvetica-Bold' },
|
||||
priorityLow: { backgroundColor: '#d4edda', color: '#155724', padding: '2 8', borderRadius: 3, fontSize: 7.5, fontFamily: 'Helvetica-Bold' },
|
||||
})
|
||||
|
||||
export interface RapportData {
|
||||
reportNumber: string
|
||||
organisation: string
|
||||
abteilung: string
|
||||
datum: string
|
||||
uhrzeit: string
|
||||
einsatzNr: string
|
||||
alarmzeit: string
|
||||
prioritaet: string
|
||||
einsatzort: string
|
||||
koordinaten: string
|
||||
objekt: string
|
||||
alarmierungsart: string
|
||||
stichwort: string
|
||||
zeitAlarm: string
|
||||
zeitAusruecken: string
|
||||
zeitEintreffen: string
|
||||
zeitBereit: string
|
||||
zeitKontrolle: string
|
||||
zeitAus: string
|
||||
zeitEinruecken: string
|
||||
zeitEnde: string
|
||||
lageEintreffen: string
|
||||
massnahmen: string | string[]
|
||||
fahrzeuge: { name: string; pers: string; ausruecken: string; eintreffen: string; auftrag: string }[]
|
||||
bemerkungen: string
|
||||
einsatzleiter: string
|
||||
rapporteur: string
|
||||
qrCodeUrl?: string
|
||||
mapScreenshot?: string
|
||||
logoUrl?: string
|
||||
}
|
||||
|
||||
function PriorityBadge({ priority }: { priority: string }) {
|
||||
const p = priority.toLowerCase()
|
||||
const style = p === 'hoch' || p === 'high' ? styles.priorityHigh
|
||||
: p === 'mittel' || p === 'medium' ? styles.priorityMedium
|
||||
: styles.priorityLow
|
||||
return <Text style={style}>{priority.toUpperCase()}</Text>
|
||||
}
|
||||
|
||||
function FieldCell({ label, value, mono, highlight, width }: { label: string; value: string; mono?: boolean; highlight?: boolean; width: string }) {
|
||||
return (
|
||||
<View style={[styles.field, highlight ? styles.fieldHighlight : {}, { width }]}>
|
||||
<Text style={styles.fieldLabel}>{label}</Text>
|
||||
<Text style={mono ? styles.fieldValueMono : styles.fieldValue}>{value || '—'}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function RapportDocument({ data }: { data: RapportData }) {
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={styles.page}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerLeft}>
|
||||
{data.logoUrl ? (
|
||||
<Image src={data.logoUrl} style={{ width: 40, height: 40, marginRight: 10, objectFit: 'contain' }} />
|
||||
) : null}
|
||||
<View>
|
||||
<Text style={styles.headerTitle}>Einsatzrapport</Text>
|
||||
<Text style={styles.headerSubtitle}>{data.organisation} · {data.abteilung}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.headerRight}>
|
||||
<Text style={styles.eventId}>{data.reportNumber}</Text>
|
||||
<Text style={styles.eventDate}>{data.datum} · {data.uhrzeit}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.headerStripe} />
|
||||
|
||||
{/* 1. Einsatzdaten */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionNumber}>1</Text>
|
||||
<Text style={styles.sectionTitle}>Einsatzdaten</Text>
|
||||
<View style={styles.sectionLine} />
|
||||
</View>
|
||||
<View style={styles.fieldGrid}>
|
||||
<View style={styles.fieldRow}>
|
||||
<FieldCell label="Einsatz-Nr." value={data.einsatzNr} mono width="25%" />
|
||||
<FieldCell label="Datum" value={data.datum} width="25%" />
|
||||
<FieldCell label="Alarmzeit" value={data.alarmzeit} mono width="25%" />
|
||||
<FieldCell label="Priorität" value={data.prioritaet} width="25%" />
|
||||
</View>
|
||||
<View style={styles.fieldRow}>
|
||||
<FieldCell label="Einsatzort / Adresse" value={data.einsatzort} width="50%" />
|
||||
<FieldCell label="Koordinaten" value={data.koordinaten} mono width="25%" />
|
||||
<FieldCell label="Objekt / Gebäude" value={data.objekt} width="25%" />
|
||||
</View>
|
||||
<View style={styles.fieldRow}>
|
||||
<FieldCell label="Alarmierungsart" value={data.alarmierungsart} width="50%" />
|
||||
<FieldCell label="Stichwort / Meldebild" value={data.stichwort} width="50%" />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 2. Zeitverlauf */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionNumber}>2</Text>
|
||||
<Text style={styles.sectionTitle}>Zeitverlauf</Text>
|
||||
<View style={styles.sectionLine} />
|
||||
</View>
|
||||
<View style={styles.fieldGrid}>
|
||||
<View style={styles.fieldRow}>
|
||||
<FieldCell label="Alarmierung" value={data.zeitAlarm} mono highlight width="25%" />
|
||||
<FieldCell label="Ausrücken" value={data.zeitAusruecken} mono highlight width="25%" />
|
||||
<FieldCell label="Eintreffen" value={data.zeitEintreffen} mono highlight width="25%" />
|
||||
<FieldCell label="Einsatzbereit" value={data.zeitBereit} mono highlight width="25%" />
|
||||
</View>
|
||||
<View style={styles.fieldRow}>
|
||||
<FieldCell label="Feuer unter Kontrolle" value={data.zeitKontrolle} mono highlight width="25%" />
|
||||
<FieldCell label="Feuer aus" value={data.zeitAus} mono highlight width="25%" />
|
||||
<FieldCell label="Einrücken" value={data.zeitEinruecken} mono highlight width="25%" />
|
||||
<FieldCell label="Einsatzende" value={data.zeitEnde} mono highlight width="25%" />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 3. Lagebild */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionNumber}>3</Text>
|
||||
<Text style={styles.sectionTitle}>Lagebild</Text>
|
||||
<View style={styles.sectionLine} />
|
||||
</View>
|
||||
<View style={styles.fieldGrid}>
|
||||
<View style={styles.fieldRow}>
|
||||
<FieldCell label="Lage bei Eintreffen" value={data.lageEintreffen} width="100%" />
|
||||
</View>
|
||||
<View style={styles.fieldRow}>
|
||||
<View style={[styles.field, { width: '100%' }]}>
|
||||
<Text style={styles.fieldLabel}>Getroffene Massnahmen</Text>
|
||||
{Array.isArray(data.massnahmen) ? (
|
||||
data.massnahmen.map((m, i) => (
|
||||
<Text key={i} style={[styles.fieldValue, { fontSize: 8, marginBottom: 1.5 }]}>• {m}</Text>
|
||||
))
|
||||
) : (
|
||||
<Text style={styles.fieldValue}>{data.massnahmen || '—'}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 4. Lageplan-Karte */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionNumber}>4</Text>
|
||||
<Text style={styles.sectionTitle}>Lageplan / Skizze</Text>
|
||||
<View style={styles.sectionLine} />
|
||||
</View>
|
||||
{data.mapScreenshot ? (
|
||||
<Image src={data.mapScreenshot} style={{ width: '100%', height: 'auto', maxHeight: '80mm', borderWidth: 1, borderColor: '#d0d0d0', borderRadius: 4 }} />
|
||||
) : (
|
||||
<View style={styles.mapContainer}>
|
||||
<Text style={styles.mapPlaceholder}>Lageplan aus app.lageplan.ch{'\n'}({data.einsatzort})</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 5. Eingesetzte Mittel */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionNumber}>5</Text>
|
||||
<Text style={styles.sectionTitle}>Eingesetzte Mittel</Text>
|
||||
<View style={styles.sectionLine} />
|
||||
</View>
|
||||
<View style={styles.fieldGrid}>
|
||||
<View style={styles.tableHeader}>
|
||||
<Text style={[styles.tableHeaderCell, { width: '25%' }]}>Fahrzeug / Mittel</Text>
|
||||
<Text style={[styles.tableHeaderCell, { width: '10%' }]}>Pers.</Text>
|
||||
<Text style={[styles.tableHeaderCell, { width: '15%' }]}>Ausrücken</Text>
|
||||
<Text style={[styles.tableHeaderCell, { width: '15%' }]}>Eintreffen</Text>
|
||||
<Text style={[styles.tableHeaderCell, { width: '35%' }]}>Auftrag / Bemerkung</Text>
|
||||
</View>
|
||||
{(data.fahrzeuge.length > 0 ? data.fahrzeuge : [{ name: '', pers: '', ausruecken: '', eintreffen: '', auftrag: '' }]).map((fz, i) => (
|
||||
<View key={i} style={[styles.tableRow, i % 2 === 1 ? styles.tableRowEven : {}]}>
|
||||
<Text style={[styles.tableCell, { width: '25%' }]}>{fz.name}</Text>
|
||||
<Text style={[styles.tableCell, { width: '10%' }]}>{fz.pers}</Text>
|
||||
<Text style={[styles.tableCell, { width: '15%' }]}>{fz.ausruecken}</Text>
|
||||
<Text style={[styles.tableCell, { width: '15%' }]}>{fz.eintreffen}</Text>
|
||||
<Text style={[styles.tableCell, { width: '35%' }]}>{fz.auftrag}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 6. Bemerkungen */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionNumber}>6</Text>
|
||||
<Text style={styles.sectionTitle}>Bemerkungen / Besondere Vorkommnisse</Text>
|
||||
<View style={styles.sectionLine} />
|
||||
</View>
|
||||
<View style={styles.notesBox}>
|
||||
<Text style={styles.notesText}>{data.bemerkungen || ''}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Unterschriften */}
|
||||
<View style={styles.signatureBlock}>
|
||||
<View style={styles.signatureField}>
|
||||
<View style={styles.signatureLine} />
|
||||
<Text style={styles.signatureLabel}>Einsatzleiter/in · {data.einsatzleiter}</Text>
|
||||
</View>
|
||||
<View style={styles.signatureField}>
|
||||
<View style={styles.signatureLine} />
|
||||
<Text style={styles.signatureLabel}>Rapport erstellt durch · {data.rapporteur}</Text>
|
||||
</View>
|
||||
<View style={styles.signatureField}>
|
||||
<View style={styles.signatureLine} />
|
||||
<Text style={styles.signatureLabel}>Datum / Visum Kdt</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Footer with QR code */}
|
||||
<View style={styles.footer} fixed>
|
||||
<View>
|
||||
<Text>Erstellt: {data.datum} {data.uhrzeit} · {data.organisation}</Text>
|
||||
<Text>Projekt: {data.reportNumber} · Standort: {data.einsatzort}</Text>
|
||||
</View>
|
||||
{data.qrCodeUrl ? (
|
||||
<View style={{ alignItems: 'center' }}>
|
||||
<Image src={data.qrCodeUrl} style={{ width: 45, height: 45 }} />
|
||||
<Text style={{ fontSize: 5, color: '#888', marginTop: 1 }}>Online-Rapport</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={{ textAlign: 'right' }}>
|
||||
<Text>app.lageplan.ch</Text>
|
||||
<Text render={({ pageNumber, totalPages }: { pageNumber: number; totalPages: number }) => `Seite ${pageNumber}/${totalPages}`} />
|
||||
</View>
|
||||
</View>
|
||||
</Page>
|
||||
</Document>
|
||||
)
|
||||
}
|
||||
25
src/lib/socket.ts
Normal file
25
src/lib/socket.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import { io, Socket } from 'socket.io-client'
|
||||
|
||||
let socket: Socket | null = null
|
||||
|
||||
export function getSocket(): Socket {
|
||||
if (!socket) {
|
||||
socket = io({
|
||||
path: '/socket.io',
|
||||
transports: ['polling', 'websocket'],
|
||||
upgrade: true,
|
||||
reconnectionAttempts: 10,
|
||||
reconnectionDelay: 2000,
|
||||
timeout: 10000,
|
||||
})
|
||||
socket.on('connect', () => {
|
||||
console.log('[Socket.io] Connected:', socket?.id)
|
||||
})
|
||||
socket.on('connect_error', (err) => {
|
||||
console.warn('[Socket.io] Connection error:', err.message)
|
||||
})
|
||||
}
|
||||
return socket
|
||||
}
|
||||
61
src/lib/stripe.ts
Normal file
61
src/lib/stripe.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import Stripe from 'stripe'
|
||||
import { prisma } from '@/lib/db'
|
||||
|
||||
let stripeInstance: Stripe | null = null
|
||||
|
||||
export async function getStripeConfig(): Promise<{ secretKey: string; publicKey: string; webhookSecret: string } | null> {
|
||||
try {
|
||||
const keys = await (prisma as any).systemSetting.findMany({
|
||||
where: {
|
||||
key: { in: ['stripe_secret_key', 'stripe_public_key', 'stripe_webhook_secret'] },
|
||||
},
|
||||
})
|
||||
|
||||
const secretKey = keys.find((k: any) => k.key === 'stripe_secret_key')?.value
|
||||
const publicKey = keys.find((k: any) => k.key === 'stripe_public_key')?.value
|
||||
const webhookSecret = keys.find((k: any) => k.key === 'stripe_webhook_secret')?.value
|
||||
|
||||
if (!secretKey || !publicKey) {
|
||||
console.log('[Stripe] Config missing:', { hasSecret: !!secretKey, hasPublic: !!publicKey, keysFound: keys.length })
|
||||
return null
|
||||
}
|
||||
|
||||
return { secretKey, publicKey, webhookSecret: webhookSecret || '' }
|
||||
} catch (error) {
|
||||
console.error('[Stripe] Error loading config:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStripe(): Promise<Stripe | null> {
|
||||
const config = await getStripeConfig()
|
||||
if (!config) return null
|
||||
|
||||
if (!stripeInstance || (stripeInstance as any)._api?.auth !== config.secretKey) {
|
||||
stripeInstance = new Stripe(config.secretKey, {
|
||||
apiVersion: '2024-06-20' as any,
|
||||
})
|
||||
}
|
||||
|
||||
return stripeInstance
|
||||
}
|
||||
|
||||
export async function saveStripeConfig(config: {
|
||||
secretKey?: string
|
||||
publicKey?: string
|
||||
webhookSecret?: string
|
||||
}) {
|
||||
const entries = [
|
||||
{ key: 'stripe_secret_key', value: config.secretKey, isSecret: true },
|
||||
{ key: 'stripe_public_key', value: config.publicKey, isSecret: false },
|
||||
{ key: 'stripe_webhook_secret', value: config.webhookSecret, isSecret: true },
|
||||
].filter(e => e.value !== undefined && e.value !== '••••••••')
|
||||
|
||||
for (const entry of entries) {
|
||||
await (prisma as any).systemSetting.upsert({
|
||||
where: { key: entry.key },
|
||||
update: { value: entry.value },
|
||||
create: { key: entry.key, value: entry.value, isSecret: entry.isSecret, category: 'stripe' },
|
||||
})
|
||||
}
|
||||
}
|
||||
44
src/lib/tenant.ts
Normal file
44
src/lib/tenant.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { prisma } from './db'
|
||||
import { UserPayload } from './auth'
|
||||
|
||||
/**
|
||||
* Get the tenantId filter for data queries.
|
||||
* - SERVER_ADMIN: no filter (sees everything)
|
||||
* - All others: filter by their tenantId
|
||||
*/
|
||||
export function getTenantFilter(user: UserPayload): Record<string, any> {
|
||||
if (user.role === 'SERVER_ADMIN') return {}
|
||||
if (!user.tenantId) return { ownerId: user.id } // no tenant → only own projects
|
||||
// Tenant user: see tenant projects + own legacy projects (tenantId=null)
|
||||
return {
|
||||
OR: [
|
||||
{ tenantId: user.tenantId },
|
||||
{ ownerId: user.id, tenantId: null },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has access to a specific project.
|
||||
* Returns the project if access is granted, null otherwise.
|
||||
*/
|
||||
export async function getProjectWithTenantCheck(projectId: string, user: UserPayload) {
|
||||
const project = await (prisma as any).project.findUnique({
|
||||
where: { id: projectId },
|
||||
})
|
||||
if (!project) return null
|
||||
|
||||
// SERVER_ADMIN can access all projects
|
||||
if (user.role === 'SERVER_ADMIN') return project
|
||||
|
||||
// Projects without tenantId (legacy): allow if user is the owner
|
||||
if (!project.tenantId) {
|
||||
if (project.ownerId === user.id) return project
|
||||
return null
|
||||
}
|
||||
|
||||
// All others: must belong to same tenant
|
||||
if (project.tenantId !== user.tenantId) return null
|
||||
|
||||
return project
|
||||
}
|
||||
38
src/lib/utils.ts
Normal file
38
src/lib/utils.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatDate(date: Date | string): string {
|
||||
const d = new Date(date)
|
||||
return d.toLocaleDateString('de-CH', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
export function formatDateTime(date: Date | string): string {
|
||||
const d = new Date(date)
|
||||
return d.toLocaleDateString('de-CH', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export function formatTime(date: Date | string): string {
|
||||
const d = new Date(date)
|
||||
return d.toLocaleTimeString('de-CH', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export function generateId(): string {
|
||||
return Math.random().toString(36).substring(2, 15)
|
||||
}
|
||||
38
src/lib/validations.ts
Normal file
38
src/lib/validations.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.string().email('Ungültige E-Mail-Adresse'),
|
||||
password: z.string().min(1, 'Passwort erforderlich'),
|
||||
})
|
||||
|
||||
export const projectSchema = z.object({
|
||||
title: z.string().min(1, 'Titel erforderlich').max(200, 'Titel zu lang'),
|
||||
location: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
einsatzleiter: z.string().optional(),
|
||||
journalfuehrer: z.string().optional(),
|
||||
mapCenter: z.object({
|
||||
lng: z.number(),
|
||||
lat: z.number(),
|
||||
}).optional(),
|
||||
mapZoom: z.number().min(1).max(22).optional(),
|
||||
})
|
||||
|
||||
export const featureSchema = z.object({
|
||||
type: z.string(),
|
||||
geometry: z.object({
|
||||
type: z.string(),
|
||||
coordinates: z.any(),
|
||||
}),
|
||||
properties: z.record(z.any()).optional(),
|
||||
})
|
||||
|
||||
export const iconUploadSchema = z.object({
|
||||
name: z.string().min(1, 'Name erforderlich').max(100, 'Name zu lang'),
|
||||
categoryId: z.string().uuid('Ungültige Kategorie'),
|
||||
})
|
||||
|
||||
export type LoginInput = z.infer<typeof loginSchema>
|
||||
export type ProjectInput = z.infer<typeof projectSchema>
|
||||
export type FeatureInput = z.infer<typeof featureSchema>
|
||||
export type IconUploadInput = z.infer<typeof iconUploadSchema>
|
||||
Reference in New Issue
Block a user