v1.3.0: Refactoring Phase 3+4, Symbol-Verwaltung Redesign, Schlauch-Labels Fix
- Refactoring: Error Boundaries, apiFetch Wrapper, Socket Status-Tracking - Refactoring: UI Kontrast (theme-aware colors), unused imports bereinigt - Symbol-Verwaltung: Neues Split-Panel (Meine Symbole + Bibliothek) - Symbol-Verwaltung: Umbenennen (TLF rot/blau), Duplikate erlaubt - Symbol-Verwaltung: Karten-Sidebar zeigt eigene Symbole bevorzugt - Schlauch-Labels: Groessere Schrift (13px/10px), verschiebbar (Drag) - Schema: TenantSymbol customName, sortOrder, unique constraint entfernt - Open Source Referenz entfernt (kostenloses Projekt)
This commit is contained in:
36
package-lock.json
generated
36
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "lageplan",
|
"name": "lageplan",
|
||||||
"version": "1.0.1",
|
"version": "1.2.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "lageplan",
|
"name": "lageplan",
|
||||||
"version": "1.0.1",
|
"version": "1.2.2",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.1.0",
|
"@dnd-kit/core": "^6.1.0",
|
||||||
@@ -53,7 +53,8 @@
|
|||||||
"tailwind-merge": "^2.2.1",
|
"tailwind-merge": "^2.2.1",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4",
|
||||||
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
@@ -10566,6 +10567,35 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zustand": {
|
||||||
|
"version": "5.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
|
||||||
|
"integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=18.0.0",
|
||||||
|
"immer": ">=9.0.6",
|
||||||
|
"react": ">=18.0.0",
|
||||||
|
"use-sync-external-store": ">=1.2.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"immer": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"use-sync-external-store": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,8 @@
|
|||||||
"tailwind-merge": "^2.2.1",
|
"tailwind-merge": "^2.2.1",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4",
|
||||||
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "node prisma/seed.js"
|
"seed": "node prisma/seed.js"
|
||||||
|
|||||||
@@ -378,11 +378,13 @@ model UpgradeRequest {
|
|||||||
@@map("upgrade_requests")
|
@@map("upgrade_requests")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Tenant Symbol Visibility ─────────────────────────────
|
// ─── Tenant Symbol Collection ─────────────────────────────
|
||||||
|
|
||||||
model TenantSymbol {
|
model TenantSymbol {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
isActive Boolean @default(true)
|
customName String?
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
tenantId String
|
tenantId String
|
||||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
@@ -390,7 +392,7 @@ model TenantSymbol {
|
|||||||
iconId String
|
iconId String
|
||||||
icon IconAsset @relation(fields: [iconId], references: [id], onDelete: Cascade)
|
icon IconAsset @relation(fields: [iconId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([tenantId, iconId])
|
@@index([tenantId])
|
||||||
@@map("tenant_symbols")
|
@@map("tenant_symbols")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
35
src/app/admin/error.tsx
Normal file
35
src/app/admin/error.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { AlertTriangle, RotateCcw, Home } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
export default function AdminError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string }
|
||||||
|
reset: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-background p-8 text-center">
|
||||||
|
<AlertTriangle className="w-12 h-12 text-destructive mb-4" />
|
||||||
|
<h2 className="text-xl font-bold mb-2">Fehler im Admin-Bereich</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6 max-w-md">
|
||||||
|
{error.message || 'Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut.'}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button variant="outline" onClick={reset}>
|
||||||
|
<RotateCcw className="w-4 h-4 mr-2" />
|
||||||
|
Erneut versuchen
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/app">
|
||||||
|
<Home className="w-4 h-4 mr-2" />
|
||||||
|
Zur App
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -6,20 +6,6 @@ export async function GET() {
|
|||||||
try {
|
try {
|
||||||
const user = await getSession()
|
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
|
// Filter categories: global (tenantId=null) + tenant-specific
|
||||||
const categoryWhere: any = user?.tenantId
|
const categoryWhere: any = user?.tenantId
|
||||||
? { OR: [{ tenantId: null }, { tenantId: user.tenantId }] }
|
? { OR: [{ tenantId: null }, { tenantId: user.tenantId }] }
|
||||||
@@ -38,35 +24,46 @@ export async function GET() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Get tenant's hidden icon IDs (legacy) + TenantSymbol overrides
|
// Get tenant's hidden icon IDs (legacy)
|
||||||
let hiddenIconIds: string[] = []
|
let hiddenIconIds: string[] = []
|
||||||
let deactivatedIconIds = new Set<string>()
|
|
||||||
if (user?.tenantId) {
|
if (user?.tenantId) {
|
||||||
const [tenant, tenantSymbols] = await Promise.all([
|
const tenant = await (prisma as any).tenant.findUnique({
|
||||||
(prisma as any).tenant.findUnique({
|
where: { id: user.tenantId },
|
||||||
where: { id: user.tenantId },
|
select: { hiddenIconIds: true },
|
||||||
select: { hiddenIconIds: true },
|
})
|
||||||
}),
|
|
||||||
(prisma as any).tenantSymbol.findMany({
|
|
||||||
where: { tenantId: user.tenantId, isActive: false },
|
|
||||||
select: { iconId: true },
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
hiddenIconIds = tenant?.hiddenIconIds || []
|
hiddenIconIds = tenant?.hiddenIconIds || []
|
||||||
deactivatedIconIds = new Set(tenantSymbols.map((ts: any) => ts.iconId))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoriesWithUrls = categories.map((cat: any) => ({
|
const categoriesWithUrls = categories.map((cat: any) => ({
|
||||||
...cat,
|
...cat,
|
||||||
icons: cat.icons
|
icons: cat.icons
|
||||||
.filter((icon: any) => !hiddenIconIds.includes(icon.id) && !deactivatedIconIds.has(icon.id))
|
.filter((icon: any) => !hiddenIconIds.includes(icon.id))
|
||||||
.map((icon: any) => ({
|
.map((icon: any) => ({
|
||||||
...icon,
|
...icon,
|
||||||
url: `/api/icons/${icon.id}/image`,
|
url: `/api/icons/${icon.id}/image`,
|
||||||
})),
|
})),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return NextResponse.json({ categories: categoriesWithUrls })
|
// Get tenant's custom symbol collection (with custom names)
|
||||||
|
let mySymbols: any[] = []
|
||||||
|
if (user?.tenantId) {
|
||||||
|
const tenantSymbols = await (prisma as any).tenantSymbol.findMany({
|
||||||
|
where: { tenantId: user.tenantId },
|
||||||
|
include: { icon: { select: { id: true, name: true, mimeType: true, iconType: true } } },
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
})
|
||||||
|
mySymbols = tenantSymbols.map((ts: any) => ({
|
||||||
|
id: ts.icon.id,
|
||||||
|
tenantSymbolId: ts.id,
|
||||||
|
name: ts.customName || ts.icon.name,
|
||||||
|
customName: ts.customName,
|
||||||
|
mimeType: ts.icon.mimeType,
|
||||||
|
iconType: ts.icon.iconType,
|
||||||
|
url: `/api/icons/${ts.icon.id}/image`,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ categories: categoriesWithUrls, mySymbols })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching icons:', error)
|
console.error('Error fetching icons:', error)
|
||||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||||
|
|||||||
@@ -2,82 +2,151 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { prisma } from '@/lib/db'
|
import { prisma } from '@/lib/db'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
|
|
||||||
// GET: List all icons with their tenant-specific active status
|
async function getTenantId() {
|
||||||
|
const user = await getSession()
|
||||||
|
if (!user) return { error: 'Nicht autorisiert', status: 401 }
|
||||||
|
if (user.role !== 'TENANT_ADMIN' && user.role !== 'SERVER_ADMIN') {
|
||||||
|
return { error: 'Keine Berechtigung', status: 403 }
|
||||||
|
}
|
||||||
|
if (!user.tenantId) return { error: 'Kein Mandant zugeordnet', status: 400 }
|
||||||
|
return { tenantId: user.tenantId }
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: Returns library (all system icons) + tenant's own symbol collection
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const user = await getSession()
|
const auth = await getTenantId()
|
||||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
||||||
if (user.role !== 'TENANT_ADMIN' && user.role !== 'SERVER_ADMIN') {
|
const { tenantId } = auth
|
||||||
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const tenantId = user.tenantId
|
// All system icons grouped by category (the library)
|
||||||
if (!tenantId) return NextResponse.json({ error: 'Kein Mandant zugeordnet' }, { status: 400 })
|
|
||||||
|
|
||||||
// Get all system icons (active ones)
|
|
||||||
const icons = await (prisma as any).iconAsset.findMany({
|
const icons = await (prisma as any).iconAsset.findMany({
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
include: { category: { select: { id: true, name: true } } },
|
include: { category: { select: { id: true, name: true } } },
|
||||||
orderBy: [{ category: { sortOrder: 'asc' } }, { name: 'asc' }],
|
orderBy: [{ category: { sortOrder: 'asc' } }, { name: 'asc' }],
|
||||||
})
|
})
|
||||||
|
|
||||||
// Get tenant-specific overrides
|
const library = icons.map((icon: any) => ({
|
||||||
const overrides = await (prisma as any).tenantSymbol.findMany({
|
|
||||||
where: { tenantId },
|
|
||||||
})
|
|
||||||
|
|
||||||
const overrideMap = new Map(overrides.map((o: any) => [o.iconId, o.isActive]))
|
|
||||||
|
|
||||||
// Merge: default is active (true) unless override says otherwise
|
|
||||||
const symbols = icons.map((icon: any) => ({
|
|
||||||
id: icon.id,
|
id: icon.id,
|
||||||
name: icon.name,
|
name: icon.name,
|
||||||
fileKey: icon.fileKey,
|
|
||||||
mimeType: icon.mimeType,
|
mimeType: icon.mimeType,
|
||||||
iconType: icon.iconType,
|
iconType: icon.iconType,
|
||||||
categoryId: icon.categoryId,
|
categoryId: icon.categoryId,
|
||||||
categoryName: icon.category?.name || 'Ohne Kategorie',
|
categoryName: icon.category?.name || 'Ohne Kategorie',
|
||||||
isActive: overrideMap.has(icon.id) ? overrideMap.get(icon.id) : true,
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return NextResponse.json({ symbols })
|
// Tenant's own symbol collection
|
||||||
|
const tenantSymbols = await (prisma as any).tenantSymbol.findMany({
|
||||||
|
where: { tenantId },
|
||||||
|
include: { icon: { select: { id: true, name: true, mimeType: true, iconType: true, category: { select: { name: true } } } } },
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const mySymbols = tenantSymbols.map((ts: any) => ({
|
||||||
|
id: ts.id,
|
||||||
|
iconId: ts.iconId,
|
||||||
|
name: ts.customName || ts.icon.name,
|
||||||
|
customName: ts.customName,
|
||||||
|
baseName: ts.icon.name,
|
||||||
|
mimeType: ts.icon.mimeType,
|
||||||
|
iconType: ts.icon.iconType,
|
||||||
|
categoryName: ts.icon.category?.name || 'Ohne Kategorie',
|
||||||
|
sortOrder: ts.sortOrder,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return NextResponse.json({ library, mySymbols })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching tenant symbols:', error)
|
console.error('Error fetching tenant symbols:', error)
|
||||||
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
|
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH: Update symbol visibility for the tenant (bulk)
|
// POST: Add a symbol from the library to "my symbols"
|
||||||
export async function PATCH(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await getSession()
|
const auth = await getTenantId()
|
||||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
||||||
if (user.role !== 'TENANT_ADMIN' && user.role !== 'SERVER_ADMIN') {
|
const { tenantId } = auth
|
||||||
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const tenantId = user.tenantId
|
const { iconId, customName } = await req.json()
|
||||||
if (!tenantId) return NextResponse.json({ error: 'Kein Mandant zugeordnet' }, { status: 400 })
|
if (!iconId) return NextResponse.json({ error: 'iconId erforderlich' }, { status: 400 })
|
||||||
|
|
||||||
const { updates } = await req.json()
|
// Get max sortOrder for this tenant
|
||||||
if (!Array.isArray(updates)) {
|
const maxSort = await (prisma as any).tenantSymbol.aggregate({
|
||||||
return NextResponse.json({ error: 'updates Array erforderlich' }, { status: 400 })
|
where: { tenantId },
|
||||||
}
|
_max: { sortOrder: true },
|
||||||
|
})
|
||||||
|
|
||||||
// Upsert each symbol override
|
const symbol = await (prisma as any).tenantSymbol.create({
|
||||||
await Promise.all(
|
data: {
|
||||||
updates.map((u: { iconId: string; isActive: boolean }) =>
|
tenantId,
|
||||||
(prisma as any).tenantSymbol.upsert({
|
iconId,
|
||||||
where: { tenantId_iconId: { tenantId, iconId: u.iconId } },
|
customName: customName || null,
|
||||||
update: { isActive: u.isActive },
|
sortOrder: (maxSort._max.sortOrder ?? -1) + 1,
|
||||||
create: { tenantId, iconId: u.iconId, isActive: u.isActive },
|
},
|
||||||
})
|
include: { icon: { select: { name: true, mimeType: true, iconType: true, category: { select: { name: true } } } } },
|
||||||
)
|
})
|
||||||
)
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({
|
||||||
|
id: symbol.id,
|
||||||
|
iconId: symbol.iconId,
|
||||||
|
name: symbol.customName || symbol.icon.name,
|
||||||
|
customName: symbol.customName,
|
||||||
|
baseName: symbol.icon.name,
|
||||||
|
mimeType: symbol.icon.mimeType,
|
||||||
|
iconType: symbol.icon.iconType,
|
||||||
|
categoryName: symbol.icon.category?.name || 'Ohne Kategorie',
|
||||||
|
sortOrder: symbol.sortOrder,
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating tenant symbols:', error)
|
console.error('Error adding tenant symbol:', error)
|
||||||
|
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH: Rename a symbol or update sortOrder
|
||||||
|
export async function PATCH(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const auth = await getTenantId()
|
||||||
|
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
||||||
|
const { tenantId } = auth
|
||||||
|
|
||||||
|
const { id, customName, sortOrder } = await req.json()
|
||||||
|
if (!id) return NextResponse.json({ error: 'id erforderlich' }, { status: 400 })
|
||||||
|
|
||||||
|
const data: any = {}
|
||||||
|
if (customName !== undefined) data.customName = customName || null
|
||||||
|
if (sortOrder !== undefined) data.sortOrder = sortOrder
|
||||||
|
|
||||||
|
await (prisma as any).tenantSymbol.updateMany({
|
||||||
|
where: { id, tenantId },
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating tenant symbol:', error)
|
||||||
|
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE: Remove a symbol from "my symbols"
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const auth = await getTenantId()
|
||||||
|
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
||||||
|
const { tenantId } = auth
|
||||||
|
|
||||||
|
const { id } = await req.json()
|
||||||
|
if (!id) return NextResponse.json({ error: 'id erforderlich' }, { status: 400 })
|
||||||
|
|
||||||
|
await (prisma as any).tenantSymbol.deleteMany({
|
||||||
|
where: { id, tenantId },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting tenant symbol:', error)
|
||||||
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
|
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
src/app/app/error.tsx
Normal file
35
src/app/app/error.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { AlertTriangle, RotateCcw, Home } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
export default function AppError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string }
|
||||||
|
reset: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-background p-8 text-center">
|
||||||
|
<AlertTriangle className="w-12 h-12 text-destructive mb-4" />
|
||||||
|
<h2 className="text-xl font-bold mb-2">Fehler in der Krokier-App</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6 max-w-md">
|
||||||
|
{error.message || 'Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut.'}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button variant="outline" onClick={reset}>
|
||||||
|
<RotateCcw className="w-4 h-4 mr-2" />
|
||||||
|
Erneut versuchen
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/">
|
||||||
|
<Home className="w-4 h-4 mr-2" />
|
||||||
|
Startseite
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -18,68 +18,32 @@ import { useAuth } from '@/components/providers/auth-provider'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { JournalView } from '@/components/journal/journal-view'
|
import { JournalView } from '@/components/journal/journal-view'
|
||||||
import { jsPDF } from 'jspdf'
|
|
||||||
import { Lock, Unlock, Eye, AlertTriangle, WifiOff } from 'lucide-react'
|
import { Lock, Unlock, Eye, AlertTriangle, WifiOff } from 'lucide-react'
|
||||||
import { getSocket, setSocketRoom } from '@/lib/socket'
|
|
||||||
import { CustomDragLayer } from '@/components/map/custom-drag-layer'
|
import { CustomDragLayer } from '@/components/map/custom-drag-layer'
|
||||||
import { OnboardingTour, resetOnboardingTour } from '@/components/onboarding/onboarding-tour'
|
import { OnboardingTour, resetOnboardingTour } from '@/components/onboarding/onboarding-tour'
|
||||||
import { addToSyncQueue, flushSyncQueue, getSyncQueue, isOnline as checkOnline } from '@/lib/offline-sync'
|
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts'
|
||||||
|
import { useMapExport } from '@/hooks/use-map-export'
|
||||||
|
import { useAutoSave } from '@/hooks/use-auto-save'
|
||||||
|
import { useOfflineSync } from '@/hooks/use-offline-sync'
|
||||||
|
import { useRealtimeSync } from '@/hooks/use-realtime-sync'
|
||||||
|
import type { Project, DrawFeature, Feature, JournalEntry, DrawMode } from '@/types'
|
||||||
|
import { useToolStore } from '@/stores/tool-store'
|
||||||
|
import { useUIStore } from '@/stores/ui-store'
|
||||||
|
|
||||||
export interface Project {
|
export type { DrawMode }
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
location?: string
|
|
||||||
description?: string
|
|
||||||
einsatzleiter?: string
|
|
||||||
journalfuehrer?: string
|
|
||||||
mapCenter: { lng: number; lat: number }
|
|
||||||
mapZoom: number
|
|
||||||
isLocked: boolean
|
|
||||||
editingById?: string | null
|
|
||||||
editingUserName?: string | null
|
|
||||||
editingStartedAt?: string | null
|
|
||||||
planImageKey?: string | null
|
|
||||||
planBounds?: { north: number; south: number; east: number; west: number } | null
|
|
||||||
createdAt: string
|
|
||||||
updatedAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DrawFeature {
|
|
||||||
id: string
|
|
||||||
type: string
|
|
||||||
geometry: {
|
|
||||||
type: string
|
|
||||||
coordinates: number[] | number[][] | number[][][]
|
|
||||||
}
|
|
||||||
properties: Record<string, unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DrawMode =
|
|
||||||
| 'select'
|
|
||||||
| 'point'
|
|
||||||
| 'linestring'
|
|
||||||
| 'polygon'
|
|
||||||
| 'rectangle'
|
|
||||||
| 'circle'
|
|
||||||
| 'freehand'
|
|
||||||
| 'text'
|
|
||||||
| 'arrow'
|
|
||||||
| 'measure'
|
|
||||||
| 'dangerzone'
|
|
||||||
| 'eraser'
|
|
||||||
|
|
||||||
export default function AppPage() {
|
export default function AppPage() {
|
||||||
|
const router = useRouter()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const { user, tenant, loading: authLoading, logout } = useAuth()
|
const { user, tenant, loading: authLoading, logout } = useAuth()
|
||||||
|
|
||||||
|
// Zustand Stores
|
||||||
|
const { activeTool: drawMode, setActiveTool: setDrawMode, activeColor: selectedColor, setActiveColor: setSelectedColor, lineWidth: selectedWidth, setLineWidth: setSelectedWidth } = useToolStore()
|
||||||
|
const { sidebarOpen: isSidebarOpen, setSidebarOpen: setIsSidebarOpen, sidebarTab: activeTab, setSidebarTab: setActiveTab } = useUIStore()
|
||||||
|
|
||||||
const [currentProject, setCurrentProject] = useState<Project | null>(null)
|
const [currentProject, setCurrentProject] = useState<Project | null>(null)
|
||||||
const [features, setFeatures] = useState<DrawFeature[]>([])
|
const [features, setFeatures] = useState<DrawFeature[]>([])
|
||||||
const [drawMode, setDrawModeRaw] = useState<DrawMode>('select')
|
|
||||||
const setDrawMode = useCallback((mode: DrawMode) => {
|
|
||||||
setDrawModeRaw(mode)
|
|
||||||
}, [])
|
|
||||||
const [selectedColor, setSelectedColor] = useState('#000000')
|
|
||||||
const [selectedWidth, setSelectedWidth] = useState(3)
|
|
||||||
const [isProjectDialogOpen, setIsProjectDialogOpen] = useState(false)
|
const [isProjectDialogOpen, setIsProjectDialogOpen] = useState(false)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [isDeleteAllConfirmOpen, setIsDeleteAllConfirmOpen] = useState(false)
|
const [isDeleteAllConfirmOpen, setIsDeleteAllConfirmOpen] = useState(false)
|
||||||
@@ -87,25 +51,39 @@ export default function AppPage() {
|
|||||||
const [auditLog, setAuditLog] = useState<{ time: string; action: string }[]>([])
|
const [auditLog, setAuditLog] = useState<{ time: string; action: string }[]>([])
|
||||||
const [isAuditOpen, setIsAuditOpen] = useState(false)
|
const [isAuditOpen, setIsAuditOpen] = useState(false)
|
||||||
|
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
|
|
||||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
|
||||||
const [activeTab, setActiveTab] = useState<'map' | 'journal'>('map')
|
|
||||||
const [lastMapScreenshot, setLastMapScreenshot] = useState<string>('')
|
const [lastMapScreenshot, setLastMapScreenshot] = useState<string>('')
|
||||||
const [defaultSymbolScale, setDefaultSymbolScale] = useState(1.5)
|
const [defaultSymbolScale, setDefaultSymbolScale] = useState(1.5)
|
||||||
|
|
||||||
// Onboarding tour
|
// Onboarding tour
|
||||||
const [showTour, setShowTour] = useState(false)
|
const [showTour, setShowTour] = useState(false)
|
||||||
|
|
||||||
// Live editing lock state
|
// Ref to access the map for export
|
||||||
const [editingBy, setEditingBy] = useState<{ id: string; name: string; since: string } | null>(null)
|
const mapRef = useRef<any>(null)
|
||||||
const [isEditingByMe, setIsEditingByMe] = useState(false)
|
|
||||||
const [editingLoading, setEditingLoading] = useState(false)
|
|
||||||
|
|
||||||
// Unique session ID per browser tab (survives re-renders, not page reload)
|
// Undo/Redo history
|
||||||
const sessionIdRef = useRef<string>('')
|
const undoStackRef = useRef<DrawFeature[][]>([])
|
||||||
if (!sessionIdRef.current) {
|
const redoStackRef = useRef<DrawFeature[][]>([])
|
||||||
sessionIdRef.current = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
|
||||||
}
|
// Ref to always have latest features (avoids stale closures in callbacks called via refs)
|
||||||
|
const featuresRef = useRef<DrawFeature[]>(features)
|
||||||
|
useEffect(() => { featuresRef.current = features }, [features])
|
||||||
|
|
||||||
|
// Ref for undo-draw-point (removes last point during line drawing)
|
||||||
|
const undoDrawPointRef = useRef<(() => boolean) | null>(null)
|
||||||
|
|
||||||
|
// Realtime sync: editing lock, socket.io, throttled broadcast
|
||||||
|
const {
|
||||||
|
editingBy, isEditingByMe, editingLoading,
|
||||||
|
socketRef, broadcastFeatures,
|
||||||
|
handleStartEditing, handleStopEditing,
|
||||||
|
} = useRealtimeSync({
|
||||||
|
currentProject,
|
||||||
|
user: user ? { id: user.id, name: user.name, role: user.role } : null,
|
||||||
|
featuresRef,
|
||||||
|
setFeatures,
|
||||||
|
toast: toast as any,
|
||||||
|
})
|
||||||
|
|
||||||
// Capture map screenshot when switching to journal tab (coordinate-based rendering)
|
// Capture map screenshot when switching to journal tab (coordinate-based rendering)
|
||||||
const handleTabChange = useCallback(async (tab: 'map' | 'journal') => {
|
const handleTabChange = useCallback(async (tab: 'map' | 'journal') => {
|
||||||
@@ -368,67 +346,8 @@ export default function AppPage() {
|
|||||||
const [isLineLabelDialogOpen, setIsLineLabelDialogOpen] = useState(false)
|
const [isLineLabelDialogOpen, setIsLineLabelDialogOpen] = useState(false)
|
||||||
const [pendingLineFeature, setPendingLineFeature] = useState<DrawFeature | null>(null)
|
const [pendingLineFeature, setPendingLineFeature] = useState<DrawFeature | null>(null)
|
||||||
|
|
||||||
// Ref to access the map for export
|
// Offline detection + sync queue management
|
||||||
const mapRef = useRef<any>(null)
|
const { isOffline, syncQueueCount, setSyncQueueCount } = useOfflineSync({ toast: toast as any })
|
||||||
|
|
||||||
// Offline detection
|
|
||||||
const [isOffline, setIsOffline] = useState(false)
|
|
||||||
const [syncQueueCount, setSyncQueueCount] = useState(0)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsOffline(!checkOnline())
|
|
||||||
setSyncQueueCount(getSyncQueue().length)
|
|
||||||
|
|
||||||
const goOffline = () => {
|
|
||||||
setIsOffline(true)
|
|
||||||
toast({ title: 'Offline-Modus', description: 'Änderungen werden lokal gespeichert und beim Reconnect synchronisiert.' })
|
|
||||||
}
|
|
||||||
const goOnline = async () => {
|
|
||||||
setIsOffline(false)
|
|
||||||
const queue = getSyncQueue()
|
|
||||||
if (queue.length > 0) {
|
|
||||||
toast({ title: 'Verbindung wiederhergestellt', description: `${queue.length} Änderung(en) werden synchronisiert...` })
|
|
||||||
const result = await flushSyncQueue()
|
|
||||||
setSyncQueueCount(getSyncQueue().length)
|
|
||||||
if (result.success > 0) {
|
|
||||||
toast({ title: 'Synchronisiert', description: `${result.success} Änderung(en) erfolgreich gespeichert.` })
|
|
||||||
}
|
|
||||||
if (result.failed > 0) {
|
|
||||||
toast({ title: 'Sync-Fehler', description: `${result.failed} Änderung(en) konnten nicht gespeichert werden.`, variant: 'destructive' })
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast({ title: 'Wieder online' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('offline', goOffline)
|
|
||||||
window.addEventListener('online', goOnline)
|
|
||||||
|
|
||||||
// Listen for SW sync messages
|
|
||||||
if ('serviceWorker' in navigator) {
|
|
||||||
navigator.serviceWorker.addEventListener('message', (event) => {
|
|
||||||
if (event.data?.type === 'FLUSH_SYNC_QUEUE') {
|
|
||||||
flushSyncQueue().then(() => setSyncQueueCount(getSyncQueue().length))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('offline', goOffline)
|
|
||||||
window.removeEventListener('online', goOnline)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Undo/Redo history
|
|
||||||
const undoStackRef = useRef<DrawFeature[][]>([])
|
|
||||||
const redoStackRef = useRef<DrawFeature[][]>([])
|
|
||||||
|
|
||||||
// Ref to always have latest features (avoids stale closures in callbacks called via refs)
|
|
||||||
const featuresRef = useRef<DrawFeature[]>(features)
|
|
||||||
useEffect(() => { featuresRef.current = features }, [features])
|
|
||||||
|
|
||||||
// Ref for undo-draw-point (removes last point during line drawing)
|
|
||||||
const undoDrawPointRef = useRef<(() => boolean) | null>(null)
|
|
||||||
|
|
||||||
// Audit trail helper
|
// Audit trail helper
|
||||||
const addAudit = useCallback((action: string) => {
|
const addAudit = useCallback((action: string) => {
|
||||||
@@ -447,8 +366,6 @@ export default function AppPage() {
|
|||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
// Redirect to login if not authenticated
|
// Redirect to login if not authenticated
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authLoading && !user) {
|
if (!authLoading && !user) {
|
||||||
@@ -461,314 +378,16 @@ export default function AppPage() {
|
|||||||
const canEdit = roleCanEdit && (isEditingByMe || !editingBy)
|
const canEdit = roleCanEdit && (isEditingByMe || !editingBy)
|
||||||
const isReadOnly = !!editingBy && !isEditingByMe
|
const isReadOnly = !!editingBy && !isEditingByMe
|
||||||
|
|
||||||
// ─── Editing Lock: Check status + Heartbeat + Polling ─────────
|
// Auto-save: localStorage persistence + debounced API save + beacon on unload
|
||||||
|
useAutoSave({
|
||||||
const checkEditingStatus = useCallback(async (projectId: string) => {
|
currentProject,
|
||||||
try {
|
features,
|
||||||
const res = await fetch(`/api/projects/${projectId}/editing?sessionId=${sessionIdRef.current}`)
|
featuresRef,
|
||||||
if (!res.ok) return
|
mapRef,
|
||||||
const data = await res.json()
|
socketRef,
|
||||||
if (data.editing) {
|
isEditingByMe,
|
||||||
setEditingBy(data.editingBy)
|
setSyncQueueCount,
|
||||||
setIsEditingByMe(data.isMe)
|
})
|
||||||
} else {
|
|
||||||
setEditingBy(null)
|
|
||||||
setIsEditingByMe(false)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[Editing] Status check failed:', e)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Check editing status when project changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!currentProject?.id) {
|
|
||||||
setEditingBy(null)
|
|
||||||
setIsEditingByMe(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
checkEditingStatus(currentProject.id)
|
|
||||||
}, [currentProject?.id, checkEditingStatus])
|
|
||||||
|
|
||||||
// Heartbeat: keep lock alive every 30s while I'm editing
|
|
||||||
useEffect(() => {
|
|
||||||
if (!currentProject?.id || !isEditingByMe) return
|
|
||||||
const interval = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
await fetch(`/api/projects/${currentProject.id}/editing`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ action: 'heartbeat', sessionId: sessionIdRef.current }),
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[Heartbeat] Failed:', e)
|
|
||||||
}
|
|
||||||
}, 30000)
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [currentProject?.id, isEditingByMe])
|
|
||||||
|
|
||||||
// Socket.io: real-time sync for features, editing status, journal
|
|
||||||
const socketRef = useRef<any>(null)
|
|
||||||
const prevProjectIdRef = useRef<string | null>(null)
|
|
||||||
|
|
||||||
// Throttled socket broadcast for near-real-time sync
|
|
||||||
const lastEmitRef = useRef(0)
|
|
||||||
const emitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
||||||
const currentProjectRef = useRef(currentProject)
|
|
||||||
useEffect(() => { currentProjectRef.current = currentProject }, [currentProject])
|
|
||||||
|
|
||||||
const broadcastFeatures = useCallback((feats: DrawFeature[]) => {
|
|
||||||
const proj = currentProjectRef.current
|
|
||||||
if (!socketRef.current || !proj?.id || !isEditingByMeRef.current) return
|
|
||||||
const now = Date.now()
|
|
||||||
const emit = () => {
|
|
||||||
socketRef.current?.emit('features-updated', {
|
|
||||||
projectId: proj!.id,
|
|
||||||
features: feats,
|
|
||||||
})
|
|
||||||
lastEmitRef.current = Date.now()
|
|
||||||
}
|
|
||||||
// Throttle: emit at most every 800ms for snappier sync
|
|
||||||
if (now - lastEmitRef.current > 800) {
|
|
||||||
emit()
|
|
||||||
} else {
|
|
||||||
if (emitTimerRef.current) clearTimeout(emitTimerRef.current)
|
|
||||||
emitTimerRef.current = setTimeout(emit, 800 - (now - lastEmitRef.current))
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
const isEditingByMeRef = useRef(false)
|
|
||||||
|
|
||||||
// Keep ref in sync with state
|
|
||||||
useEffect(() => {
|
|
||||||
isEditingByMeRef.current = isEditingByMe
|
|
||||||
}, [isEditingByMe])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!currentProject?.id) return
|
|
||||||
|
|
||||||
const socket = getSocket()
|
|
||||||
socketRef.current = socket
|
|
||||||
|
|
||||||
// Leave old room, join new room
|
|
||||||
if (prevProjectIdRef.current && prevProjectIdRef.current !== currentProject.id) {
|
|
||||||
socket.emit('leave-project', prevProjectIdRef.current)
|
|
||||||
}
|
|
||||||
socket.emit('join-project', currentProject.id)
|
|
||||||
setSocketRoom(currentProject.id)
|
|
||||||
prevProjectIdRef.current = currentProject.id
|
|
||||||
|
|
||||||
// Listen for features changes from other clients (only apply if NOT the editor)
|
|
||||||
const onFeaturesChanged = (data: { features: any[] }) => {
|
|
||||||
// Skip if I'm the one editing — my local state is the source of truth
|
|
||||||
if (isEditingByMeRef.current) {
|
|
||||||
console.log('[Socket.io] Ignoring features-changed (I am the editor)')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (data.features && Array.isArray(data.features)) {
|
|
||||||
console.log('[Socket.io] Features updated from another client')
|
|
||||||
setFeatures(data.features)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Listen for editing status changes from other clients
|
|
||||||
const onEditingStatus = (data: { editing: boolean; editingBy: any; sessionId: string }) => {
|
|
||||||
if (data.sessionId === sessionIdRef.current) return // ignore own events
|
|
||||||
if (data.editing && data.editingBy) {
|
|
||||||
setEditingBy(data.editingBy)
|
|
||||||
setIsEditingByMe(false)
|
|
||||||
} else {
|
|
||||||
setEditingBy(null)
|
|
||||||
setIsEditingByMe(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Listen for journal changes — trigger a re-fetch in JournalView
|
|
||||||
const onJournalChanged = () => {
|
|
||||||
console.log('[Socket.io] Journal updated from another client')
|
|
||||||
window.dispatchEvent(new CustomEvent('journal-refresh'))
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.on('features-changed', onFeaturesChanged)
|
|
||||||
socket.on('editing-status', onEditingStatus)
|
|
||||||
socket.on('journal-changed', onJournalChanged)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
socket.off('features-changed', onFeaturesChanged)
|
|
||||||
socket.off('editing-status', onEditingStatus)
|
|
||||||
socket.off('journal-changed', onJournalChanged)
|
|
||||||
}
|
|
||||||
}, [currentProject?.id])
|
|
||||||
|
|
||||||
// Fallback: check editing status on initial load and every 30s
|
|
||||||
useEffect(() => {
|
|
||||||
if (!currentProject?.id) return
|
|
||||||
checkEditingStatus(currentProject.id)
|
|
||||||
const interval = setInterval(() => checkEditingStatus(currentProject.id), 30000)
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [currentProject?.id, checkEditingStatus])
|
|
||||||
|
|
||||||
// Release lock on unmount / page close
|
|
||||||
useEffect(() => {
|
|
||||||
const release = () => {
|
|
||||||
if (currentProject?.id && isEditingByMe) {
|
|
||||||
const blob = new Blob([JSON.stringify({ action: 'stop', sessionId: sessionIdRef.current })], { type: 'application/json' })
|
|
||||||
navigator.sendBeacon(`/api/projects/${currentProject.id}/editing`, blob)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener('beforeunload', release)
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('beforeunload', release)
|
|
||||||
release()
|
|
||||||
}
|
|
||||||
}, [currentProject?.id, isEditingByMe])
|
|
||||||
|
|
||||||
const handleStartEditing = useCallback(async () => {
|
|
||||||
if (!currentProject?.id) return
|
|
||||||
setEditingLoading(true)
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/projects/${currentProject.id}/editing`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ action: 'start', sessionId: sessionIdRef.current }),
|
|
||||||
})
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json()
|
|
||||||
toast({ title: 'Gesperrt', description: data.error || 'Bearbeitung nicht möglich', variant: 'destructive' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setIsEditingByMe(true)
|
|
||||||
const editingInfo = { id: user!.id, name: user!.name, since: new Date().toISOString() }
|
|
||||||
setEditingBy(editingInfo)
|
|
||||||
// Notify other clients
|
|
||||||
socketRef.current?.emit('editing-changed', {
|
|
||||||
projectId: currentProject.id,
|
|
||||||
editing: true,
|
|
||||||
editingBy: editingInfo,
|
|
||||||
sessionId: sessionIdRef.current,
|
|
||||||
})
|
|
||||||
toast({ title: 'Bearbeitung gestartet', description: 'Sie können jetzt zeichnen und Einträge erstellen.' })
|
|
||||||
} catch (e) {
|
|
||||||
toast({ title: 'Fehler', description: 'Konnte Bearbeitung nicht starten.', variant: 'destructive' })
|
|
||||||
} finally {
|
|
||||||
setEditingLoading(false)
|
|
||||||
}
|
|
||||||
}, [currentProject?.id, user, toast])
|
|
||||||
|
|
||||||
const handleStopEditing = useCallback(async () => {
|
|
||||||
if (!currentProject?.id) return
|
|
||||||
setEditingLoading(true)
|
|
||||||
try {
|
|
||||||
// Save features before releasing lock
|
|
||||||
const currentFeatures = featuresRef.current
|
|
||||||
await fetch(`/api/projects/${currentProject.id}/features`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ features: currentFeatures }),
|
|
||||||
})
|
|
||||||
// Release lock
|
|
||||||
await fetch(`/api/projects/${currentProject.id}/editing`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ action: 'stop', sessionId: sessionIdRef.current }),
|
|
||||||
})
|
|
||||||
setIsEditingByMe(false)
|
|
||||||
setEditingBy(null)
|
|
||||||
// Notify other clients: editing stopped + send final features
|
|
||||||
socketRef.current?.emit('editing-changed', {
|
|
||||||
projectId: currentProject.id,
|
|
||||||
editing: false,
|
|
||||||
editingBy: null,
|
|
||||||
sessionId: sessionIdRef.current,
|
|
||||||
})
|
|
||||||
socketRef.current?.emit('features-updated', {
|
|
||||||
projectId: currentProject.id,
|
|
||||||
features: currentFeatures,
|
|
||||||
})
|
|
||||||
toast({ title: 'Bearbeitung beendet', description: 'Änderungen gespeichert. Andere können jetzt bearbeiten.' })
|
|
||||||
} catch (e) {
|
|
||||||
toast({ title: 'Fehler', description: 'Konnte Bearbeitung nicht beenden.', variant: 'destructive' })
|
|
||||||
} finally {
|
|
||||||
setEditingLoading(false)
|
|
||||||
}
|
|
||||||
}, [currentProject?.id, toast])
|
|
||||||
|
|
||||||
// Persist features to localStorage on change (including empty array to reflect deletions)
|
|
||||||
useEffect(() => {
|
|
||||||
localStorage.setItem('lageplan-features', JSON.stringify(features))
|
|
||||||
}, [features])
|
|
||||||
|
|
||||||
// Auto-save to API — debounced 2s after every feature change + fallback interval
|
|
||||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
||||||
const saveFeaturesToApi = useCallback(async () => {
|
|
||||||
if (!currentProject?.id) return
|
|
||||||
const url = `/api/projects/${currentProject.id}/features`
|
|
||||||
const mapInstance = mapRef.current
|
|
||||||
const body: any = { features: featuresRef.current }
|
|
||||||
if (mapInstance) {
|
|
||||||
const c = mapInstance.getCenter()
|
|
||||||
body.mapCenter = { lng: c.lng, lat: c.lat }
|
|
||||||
body.mapZoom = mapInstance.getZoom()
|
|
||||||
}
|
|
||||||
|
|
||||||
// If offline, queue the save for later sync
|
|
||||||
if (!navigator.onLine) {
|
|
||||||
addToSyncQueue(url, 'PUT', body)
|
|
||||||
setSyncQueueCount(getSyncQueue().length)
|
|
||||||
console.log('[Auto-Save] Offline — in Sync-Queue gespeichert')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
console.log('[Auto-Save] Features gespeichert')
|
|
||||||
socketRef.current?.emit('features-updated', {
|
|
||||||
projectId: currentProject.id,
|
|
||||||
features: featuresRef.current,
|
|
||||||
})
|
|
||||||
} else if (res.status === 404) {
|
|
||||||
console.warn('[Auto-Save] Projekt nicht in DB')
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Network error — queue for later
|
|
||||||
addToSyncQueue(url, 'PUT', body)
|
|
||||||
setSyncQueueCount(getSyncQueue().length)
|
|
||||||
console.warn('[Auto-Save] Netzwerkfehler — in Sync-Queue:', e)
|
|
||||||
}
|
|
||||||
}, [currentProject])
|
|
||||||
|
|
||||||
// Debounced save on every feature change (2s delay)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!currentProject || !isEditingByMe) return
|
|
||||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
|
|
||||||
saveTimerRef.current = setTimeout(() => saveFeaturesToApi(), 2000)
|
|
||||||
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) }
|
|
||||||
}, [features, currentProject, isEditingByMe, saveFeaturesToApi])
|
|
||||||
|
|
||||||
// Also save on page unload / tab switch
|
|
||||||
useEffect(() => {
|
|
||||||
const handleBeforeUnload = () => {
|
|
||||||
if (currentProject?.id && featuresRef.current.length > 0) {
|
|
||||||
const payload = JSON.stringify({ features: featuresRef.current })
|
|
||||||
navigator.sendBeacon(`/api/projects/${currentProject.id}/features`, new Blob([payload], { type: 'application/json' }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const handleVisibilityChange = () => {
|
|
||||||
if (document.visibilityState === 'hidden' && currentProject?.id && isEditingByMe) {
|
|
||||||
saveFeaturesToApi()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
|
||||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
|
||||||
}
|
|
||||||
}, [currentProject, isEditingByMe, saveFeaturesToApi])
|
|
||||||
|
|
||||||
// Fullscreen toggle
|
// Fullscreen toggle
|
||||||
const toggleFullscreen = useCallback(() => {
|
const toggleFullscreen = useCallback(() => {
|
||||||
@@ -1066,57 +685,15 @@ export default function AppPage() {
|
|||||||
|
|
||||||
// Keyboard shortcuts for tools
|
// Keyboard shortcuts for tools
|
||||||
const [isShortcutHelpOpen, setIsShortcutHelpOpen] = useState(false)
|
const [isShortcutHelpOpen, setIsShortcutHelpOpen] = useState(false)
|
||||||
useEffect(() => {
|
useKeyboardShortcuts({
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
featuresRef,
|
||||||
// Ignore when typing in inputs/textareas
|
onUndo: handleUndo,
|
||||||
const tag = (e.target as HTMLElement)?.tagName
|
onRedo: handleRedo,
|
||||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (e.target as HTMLElement)?.isContentEditable) return
|
onSave: handleSaveProject,
|
||||||
|
onDelete: handleFeaturesChange,
|
||||||
// ? or F1 → help
|
onToolChange: setDrawMode,
|
||||||
if (e.key === '?' || e.key === 'F1') { e.preventDefault(); setIsShortcutHelpOpen(true); return }
|
onHelpOpen: useCallback(() => setIsShortcutHelpOpen(true), []),
|
||||||
|
})
|
||||||
// DEL / Backspace → delete selected feature(s)
|
|
||||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
||||||
e.preventDefault()
|
|
||||||
// Remove all selected features
|
|
||||||
const current = featuresRef.current
|
|
||||||
const selected = current.filter(f => f.properties?._selected)
|
|
||||||
if (selected.length > 0) {
|
|
||||||
handleFeaturesChange(current.filter(f => !f.properties?._selected))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl/Cmd shortcuts
|
|
||||||
if (e.ctrlKey || e.metaKey) {
|
|
||||||
if (e.key === 'z' && e.shiftKey) { e.preventDefault(); handleRedo(); return }
|
|
||||||
if (e.key === 'z') { e.preventDefault(); handleUndo(); return }
|
|
||||||
if (e.key === 'y') { e.preventDefault(); handleRedo(); return }
|
|
||||||
if (e.key === 's') { e.preventDefault(); handleSaveProject(); return }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tool shortcuts (single key, no modifier)
|
|
||||||
const shortcuts: Record<string, DrawMode> = {
|
|
||||||
'v': 'select', 's': 'select',
|
|
||||||
'p': 'point',
|
|
||||||
'l': 'linestring',
|
|
||||||
'g': 'polygon',
|
|
||||||
'r': 'rectangle',
|
|
||||||
'c': 'circle',
|
|
||||||
'f': 'freehand',
|
|
||||||
'a': 'arrow',
|
|
||||||
't': 'text',
|
|
||||||
'e': 'eraser',
|
|
||||||
'm': 'measure',
|
|
||||||
'd': 'dangerzone',
|
|
||||||
}
|
|
||||||
const mode = shortcuts[e.key.toLowerCase()]
|
|
||||||
if (mode) { e.preventDefault(); setDrawMode(mode); return }
|
|
||||||
}
|
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
||||||
}, [handleUndo, handleRedo, handleSaveProject, setDrawMode, handleFeaturesChange])
|
|
||||||
|
|
||||||
const handlePlanUpload = useCallback(() => {
|
const handlePlanUpload = useCallback(() => {
|
||||||
if (!currentProject) return
|
if (!currentProject) return
|
||||||
@@ -1181,311 +758,14 @@ export default function AppPage() {
|
|||||||
setIsDeleteAllConfirmOpen(false)
|
setIsDeleteAllConfirmOpen(false)
|
||||||
}, [toast, addAudit])
|
}, [toast, addAudit])
|
||||||
|
|
||||||
const handleExport = useCallback(async (format: 'png' | 'pdf') => {
|
const { handleExport } = useMapExport({
|
||||||
const mapInstance = mapRef.current
|
mapRef,
|
||||||
if (!mapInstance) {
|
featuresRef,
|
||||||
toast({ title: 'Fehler', description: 'Karte nicht bereit.', variant: 'destructive' })
|
currentProject,
|
||||||
return
|
tenant: tenant ? { id: tenant.id, name: tenant.name } : null,
|
||||||
}
|
addAudit,
|
||||||
|
toast: toast as any,
|
||||||
try {
|
})
|
||||||
// 1. Get the MapLibre canvas (tiles + vector drawings)
|
|
||||||
const mapCanvas = mapInstance.getCanvas() as HTMLCanvasElement
|
|
||||||
const w = mapCanvas.width
|
|
||||||
const h = mapCanvas.height
|
|
||||||
|
|
||||||
// 2. Create composite canvas
|
|
||||||
const exportCanvas = document.createElement('canvas')
|
|
||||||
exportCanvas.width = w
|
|
||||||
exportCanvas.height = h
|
|
||||||
const ctx = exportCanvas.getContext('2d')!
|
|
||||||
ctx.drawImage(mapCanvas, 0, 0)
|
|
||||||
|
|
||||||
// 3. Draw symbols manually at correct size/rotation
|
|
||||||
const currentFeatures = featuresRef.current
|
|
||||||
// Derive actual pixel ratio from canvas vs container (more reliable than window.devicePixelRatio)
|
|
||||||
const container = mapInstance.getContainer()
|
|
||||||
const dpr = mapCanvas.width / container.offsetWidth
|
|
||||||
const zoom = mapInstance.getZoom()
|
|
||||||
// Symbol sizing: match the map rendering logic exactly
|
|
||||||
// In map-view.tsx: size = baseSize * scale * Math.pow(2, currentZoom - placementZoom)
|
|
||||||
const currentZoom = zoom
|
|
||||||
|
|
||||||
// Helper: load image as promise
|
|
||||||
const loadImage = (src: string): Promise<HTMLImageElement> => new Promise((resolve, reject) => {
|
|
||||||
const img = new Image()
|
|
||||||
img.crossOrigin = 'anonymous'
|
|
||||||
img.onload = () => resolve(img)
|
|
||||||
img.onerror = reject
|
|
||||||
img.src = src
|
|
||||||
})
|
|
||||||
|
|
||||||
// Draw symbol features
|
|
||||||
for (const f of currentFeatures.filter(f => f.type === 'symbol')) {
|
|
||||||
if (f.geometry.type !== 'Point') continue
|
|
||||||
const coords = f.geometry.coordinates as [number, number]
|
|
||||||
const pixel = mapInstance.project(coords)
|
|
||||||
const px = pixel.x * dpr
|
|
||||||
const py = pixel.y * dpr
|
|
||||||
|
|
||||||
const scale = (f.properties.scale as number) || 1
|
|
||||||
const rotation = (f.properties.rotation as number) || 0
|
|
||||||
const baseSize = 32
|
|
||||||
const placementZoom = (f.properties.placementZoom as number) || 17
|
|
||||||
const zoomFactor = Math.pow(2, currentZoom - placementZoom)
|
|
||||||
const size = Math.max(8, Math.min(400, baseSize * scale * zoomFactor)) * dpr
|
|
||||||
|
|
||||||
// Determine image source
|
|
||||||
const iconId = f.properties.iconId as string
|
|
||||||
const imageUrl = f.properties.imageUrl as string
|
|
||||||
let imgSrc = imageUrl || ''
|
|
||||||
if (!imgSrc && iconId) {
|
|
||||||
const { getSymbolById, getSymbolDataUri } = await import('@/lib/fw-symbols')
|
|
||||||
const sym = getSymbolById(iconId)
|
|
||||||
if (sym) imgSrc = getSymbolDataUri(sym)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (imgSrc) {
|
|
||||||
try {
|
|
||||||
const img = await loadImage(imgSrc)
|
|
||||||
// Replicate CSS background-size: contain (preserve aspect ratio)
|
|
||||||
const imgAspect = img.naturalWidth / img.naturalHeight
|
|
||||||
let drawW = size
|
|
||||||
let drawH = size
|
|
||||||
if (imgAspect > 1) {
|
|
||||||
drawH = size / imgAspect
|
|
||||||
} else {
|
|
||||||
drawW = size * imgAspect
|
|
||||||
}
|
|
||||||
ctx.save()
|
|
||||||
ctx.translate(px, py)
|
|
||||||
ctx.rotate((rotation * Math.PI) / 180)
|
|
||||||
ctx.drawImage(img, -drawW / 2, -drawH / 2, drawW, drawH)
|
|
||||||
ctx.restore()
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[Export] Failed to load symbol image:', iconId, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw arrowheads for arrow features
|
|
||||||
for (const f of currentFeatures.filter(f => f.type === 'arrow')) {
|
|
||||||
if (f.geometry.type !== 'LineString') continue
|
|
||||||
const lineCoords = f.geometry.coordinates as number[][]
|
|
||||||
if (lineCoords.length < 2) continue
|
|
||||||
const p1 = lineCoords[lineCoords.length - 2]
|
|
||||||
const p2 = lineCoords[lineCoords.length - 1]
|
|
||||||
const px1 = mapInstance.project(p1 as [number, number])
|
|
||||||
const px2 = mapInstance.project(p2 as [number, number])
|
|
||||||
const angle = Math.atan2(px2.y - px1.y, px2.x - px1.x)
|
|
||||||
const color = (f.properties.color as string) || '#000000'
|
|
||||||
const arrowSize = 14 * dpr
|
|
||||||
|
|
||||||
ctx.save()
|
|
||||||
ctx.translate(px2.x * dpr, px2.y * dpr)
|
|
||||||
ctx.rotate(angle + Math.PI / 2)
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.moveTo(0, -arrowSize)
|
|
||||||
ctx.lineTo(-arrowSize * 0.7, arrowSize * 0.3)
|
|
||||||
ctx.lineTo(arrowSize * 0.7, arrowSize * 0.3)
|
|
||||||
ctx.closePath()
|
|
||||||
ctx.fillStyle = color
|
|
||||||
ctx.fill()
|
|
||||||
ctx.restore()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw line/polygon label markers at midpoints
|
|
||||||
for (const f of currentFeatures.filter(f => f.properties.label && (f.geometry.type === 'LineString' || f.geometry.type === 'Polygon'))) {
|
|
||||||
const label = f.properties.label as string
|
|
||||||
let midpoint: [number, number]
|
|
||||||
|
|
||||||
if (f.geometry.type === 'LineString') {
|
|
||||||
const coords = f.geometry.coordinates as number[][]
|
|
||||||
const midIdx = Math.floor(coords.length / 2)
|
|
||||||
if (coords.length === 2) {
|
|
||||||
midpoint = [(coords[0][0] + coords[1][0]) / 2, (coords[0][1] + coords[1][1]) / 2]
|
|
||||||
} else {
|
|
||||||
midpoint = coords[midIdx] as [number, number]
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Polygon: centroid of first ring
|
|
||||||
const ring = (f.geometry.coordinates as number[][][])[0]
|
|
||||||
const len = ring.length - 1
|
|
||||||
let cx = 0, cy = 0
|
|
||||||
for (let i = 0; i < len; i++) { cx += ring[i][0]; cy += ring[i][1] }
|
|
||||||
midpoint = [cx / len, cy / len]
|
|
||||||
}
|
|
||||||
|
|
||||||
const pixel = mapInstance.project(midpoint)
|
|
||||||
const px = pixel.x * dpr
|
|
||||||
const py = pixel.y * dpr
|
|
||||||
const fontSize = 13 * dpr
|
|
||||||
const isDanger = f.type === 'dangerzone'
|
|
||||||
const bgColor = isDanger ? 'rgba(220,38,38,0.85)' : 'rgba(0,0,0,0.75)'
|
|
||||||
const borderColor = isDanger ? '#dc2626' : 'rgba(255,255,255,0.5)'
|
|
||||||
|
|
||||||
ctx.save()
|
|
||||||
ctx.font = `bold ${fontSize}px system-ui, sans-serif`
|
|
||||||
ctx.textAlign = 'center'
|
|
||||||
ctx.textBaseline = 'middle'
|
|
||||||
const metrics = ctx.measureText(label)
|
|
||||||
const padX = 7 * dpr
|
|
||||||
const padY = 3 * dpr
|
|
||||||
const boxW = metrics.width + padX * 2
|
|
||||||
const boxH = fontSize + padY * 2
|
|
||||||
const radius = 4 * dpr
|
|
||||||
|
|
||||||
// Background pill
|
|
||||||
ctx.fillStyle = bgColor
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.roundRect(px - boxW / 2, py - boxH / 2, boxW, boxH, radius)
|
|
||||||
ctx.fill()
|
|
||||||
|
|
||||||
// Border
|
|
||||||
ctx.strokeStyle = borderColor
|
|
||||||
ctx.lineWidth = 1.5 * dpr
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.roundRect(px - boxW / 2, py - boxH / 2, boxW, boxH, radius)
|
|
||||||
ctx.stroke()
|
|
||||||
|
|
||||||
// Text
|
|
||||||
ctx.fillStyle = '#ffffff'
|
|
||||||
ctx.fillText(label, px, py)
|
|
||||||
ctx.restore()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw text features
|
|
||||||
for (const f of currentFeatures.filter(f => f.type === 'text')) {
|
|
||||||
if (f.geometry.type !== 'Point') continue
|
|
||||||
const coords = f.geometry.coordinates as [number, number]
|
|
||||||
const pixel = mapInstance.project(coords)
|
|
||||||
const px = pixel.x * dpr
|
|
||||||
const py = pixel.y * dpr
|
|
||||||
|
|
||||||
const text = (f.properties.text as string) || ''
|
|
||||||
const fontSize = ((f.properties.fontSize as number) || 14) * dpr
|
|
||||||
const color = (f.properties.color as string) || '#000000'
|
|
||||||
|
|
||||||
ctx.save()
|
|
||||||
ctx.font = `bold ${fontSize}px system-ui, sans-serif`
|
|
||||||
ctx.textAlign = 'center'
|
|
||||||
ctx.textBaseline = 'middle'
|
|
||||||
// White outline
|
|
||||||
ctx.strokeStyle = '#ffffff'
|
|
||||||
ctx.lineWidth = 3 * dpr
|
|
||||||
ctx.lineJoin = 'round'
|
|
||||||
ctx.strokeText(text, px, py)
|
|
||||||
// Fill
|
|
||||||
ctx.fillStyle = color
|
|
||||||
ctx.fillText(text, px, py)
|
|
||||||
ctx.restore()
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = currentProject?.title || 'Lageplan'
|
|
||||||
const safeName = title.replace(/[^a-z0-9äöüÄÖÜß]/gi, '_')
|
|
||||||
|
|
||||||
if (format === 'png') {
|
|
||||||
const link = document.createElement('a')
|
|
||||||
link.download = `${safeName}.png`
|
|
||||||
link.href = exportCanvas.toDataURL('image/png')
|
|
||||||
link.click()
|
|
||||||
addAudit(`Export: ${safeName}.png`)
|
|
||||||
toast({ title: 'Exportiert', description: `${safeName}.png wurde heruntergeladen.` })
|
|
||||||
} else {
|
|
||||||
// PDF Export — rapport-style clean layout
|
|
||||||
const imgData = exportCanvas.toDataURL('image/png')
|
|
||||||
const now = new Date()
|
|
||||||
const dateStr = now.toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
|
||||||
const timeStr = now.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
|
|
||||||
const locationStr = currentProject?.location || ''
|
|
||||||
const einsatzNr = (currentProject as any)?.einsatzNr || ''
|
|
||||||
const tenantLabel = tenant?.name || ''
|
|
||||||
|
|
||||||
// A4 landscape (mm)
|
|
||||||
const pdf = new jsPDF('l', 'mm', 'a4')
|
|
||||||
const pageW = pdf.internal.pageSize.getWidth() // 297
|
|
||||||
const pageH = pdf.internal.pageSize.getHeight() // 210
|
|
||||||
const m = 10 // margin
|
|
||||||
|
|
||||||
// ── Header section ──
|
|
||||||
const headerY = m
|
|
||||||
pdf.setFontSize(18)
|
|
||||||
pdf.setFont('helvetica', 'bold')
|
|
||||||
pdf.setTextColor(26, 26, 26)
|
|
||||||
pdf.text('Einsatz-Lageplan', m, headerY + 6)
|
|
||||||
|
|
||||||
pdf.setFontSize(9)
|
|
||||||
pdf.setFont('helvetica', 'normal')
|
|
||||||
pdf.setTextColor(107, 114, 128) // gray-500
|
|
||||||
pdf.text(`${tenantLabel}${tenantLabel ? ' · ' : ''}${title}`, m, headerY + 12)
|
|
||||||
|
|
||||||
// Right side: Einsatz-Nr + date
|
|
||||||
pdf.setFontSize(14)
|
|
||||||
pdf.setFont('helvetica', 'bold')
|
|
||||||
pdf.setTextColor(185, 28, 28) // red-700
|
|
||||||
if (einsatzNr) {
|
|
||||||
const nrW = pdf.getTextWidth(einsatzNr)
|
|
||||||
pdf.text(einsatzNr, pageW - m - nrW, headerY + 6)
|
|
||||||
}
|
|
||||||
pdf.setFontSize(9)
|
|
||||||
pdf.setFont('helvetica', 'normal')
|
|
||||||
pdf.setTextColor(107, 114, 128)
|
|
||||||
const dateLabel = `${dateStr} · ${timeStr}`
|
|
||||||
const dlW = pdf.getTextWidth(dateLabel)
|
|
||||||
pdf.text(dateLabel, pageW - m - dlW, headerY + 12)
|
|
||||||
|
|
||||||
// Divider line + red accent
|
|
||||||
const divY = headerY + 15
|
|
||||||
pdf.setDrawColor(26, 26, 26)
|
|
||||||
pdf.setLineWidth(0.8)
|
|
||||||
pdf.line(m, divY, pageW - m, divY)
|
|
||||||
pdf.setFillColor(185, 28, 28)
|
|
||||||
pdf.rect(m, divY, (pageW - 2 * m) * 0.3, 1, 'F')
|
|
||||||
|
|
||||||
// ── Map image ──
|
|
||||||
const mapTop = divY + 3
|
|
||||||
const mapBottom = pageH - m - 12 // leave space for footer
|
|
||||||
const mapAreaW = pageW - 2 * m
|
|
||||||
const mapAreaH = mapBottom - mapTop
|
|
||||||
|
|
||||||
// Fit map image into area while preserving aspect ratio
|
|
||||||
const imgAspect = w / h
|
|
||||||
const areaAspect = mapAreaW / mapAreaH
|
|
||||||
let drawW = mapAreaW
|
|
||||||
let drawH = mapAreaH
|
|
||||||
if (imgAspect > areaAspect) {
|
|
||||||
drawH = mapAreaW / imgAspect
|
|
||||||
} else {
|
|
||||||
drawW = mapAreaH * imgAspect
|
|
||||||
}
|
|
||||||
const mapX = m + (mapAreaW - drawW) / 2
|
|
||||||
const mapY = mapTop + (mapAreaH - drawH) / 2
|
|
||||||
|
|
||||||
// Light border around map
|
|
||||||
pdf.setDrawColor(229, 231, 235)
|
|
||||||
pdf.setLineWidth(0.3)
|
|
||||||
pdf.rect(mapX, mapY, drawW, drawH)
|
|
||||||
pdf.addImage(imgData, 'PNG', mapX, mapY, drawW, drawH)
|
|
||||||
|
|
||||||
// ── Footer ──
|
|
||||||
const footerY = pageH - m - 4
|
|
||||||
pdf.setFontSize(7)
|
|
||||||
pdf.setFont('helvetica', 'normal')
|
|
||||||
pdf.setTextColor(156, 163, 175) // gray-400
|
|
||||||
pdf.text(`Erstellt: ${dateStr} ${timeStr}${locationStr ? ' · Standort: ' + locationStr : ''} · Projekt: ${title}`, m, footerY)
|
|
||||||
const footerR = 'app.lageplan.ch'
|
|
||||||
const frW = pdf.getTextWidth(footerR)
|
|
||||||
pdf.text(footerR, pageW - m - frW, footerY)
|
|
||||||
|
|
||||||
pdf.save(`${safeName}.pdf`)
|
|
||||||
addAudit(`Export: ${safeName}.pdf`)
|
|
||||||
toast({ title: 'Exportiert', description: `${safeName}.pdf wurde heruntergeladen.` })
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Export error:', error)
|
|
||||||
toast({ title: 'Fehler', description: 'Export fehlgeschlagen.', variant: 'destructive' })
|
|
||||||
}
|
|
||||||
}, [currentProject, toast])
|
|
||||||
|
|
||||||
// Show loading state while checking auth
|
// Show loading state while checking auth
|
||||||
if (authLoading || !user) {
|
if (authLoading || !user) {
|
||||||
@@ -1603,7 +883,7 @@ export default function AppPage() {
|
|||||||
{/* Map view — always mounted, hidden via CSS to preserve state */}
|
{/* Map view — always mounted, hidden via CSS to preserve state */}
|
||||||
<div data-tour="toolbar" className={`contents ${activeTab !== 'map' ? 'hidden' : ''}`}>
|
<div data-tour="toolbar" className={`contents ${activeTab !== 'map' ? 'hidden' : ''}`}>
|
||||||
<LeftToolbar
|
<LeftToolbar
|
||||||
drawMode={drawMode}
|
drawMode={drawMode || 'select'}
|
||||||
onDrawModeChange={setDrawMode}
|
onDrawModeChange={setDrawMode}
|
||||||
selectedColor={selectedColor}
|
selectedColor={selectedColor}
|
||||||
onColorChange={setSelectedColor}
|
onColorChange={setSelectedColor}
|
||||||
@@ -1620,7 +900,7 @@ export default function AppPage() {
|
|||||||
<MapView
|
<MapView
|
||||||
project={currentProject}
|
project={currentProject}
|
||||||
features={features}
|
features={features}
|
||||||
drawMode={drawMode}
|
drawMode={drawMode || 'select'}
|
||||||
selectedColor={selectedColor}
|
selectedColor={selectedColor}
|
||||||
selectedWidth={selectedWidth}
|
selectedWidth={selectedWidth}
|
||||||
onFeaturesChange={handleFeaturesChange}
|
onFeaturesChange={handleFeaturesChange}
|
||||||
@@ -1634,8 +914,8 @@ export default function AppPage() {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Journal view — always mounted, hidden when map tab is active to preserve state */}
|
{/* Journal view — always mounted, hidden via CSS */}
|
||||||
<main className={`flex-1 relative overflow-auto ${activeTab !== 'journal' ? 'hidden' : ''}`}>
|
<main className={`flex-1 flex flex-col min-h-0 bg-background ${activeTab !== 'journal' ? 'hidden' : ''}`}>
|
||||||
<JournalView
|
<JournalView
|
||||||
projectId={currentProject?.id || null}
|
projectId={currentProject?.id || null}
|
||||||
projectTitle={currentProject?.title || ''}
|
projectTitle={currentProject?.title || ''}
|
||||||
|
|||||||
113
src/components/admin/dictionary-tab.tsx
Normal file
113
src/components/admin/dictionary-tab.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { useToast } from '@/components/ui/use-toast'
|
||||||
|
import { BookOpen, Plus, X } from 'lucide-react'
|
||||||
|
import { apiFetch, ApiError } from '@/lib/api'
|
||||||
|
|
||||||
|
interface DictWord {
|
||||||
|
id: string
|
||||||
|
word: string
|
||||||
|
scope: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DictionaryTab() {
|
||||||
|
const { toast } = useToast()
|
||||||
|
const [globalDictWords, setGlobalDictWords] = useState<DictWord[]>([])
|
||||||
|
const [newGlobalWord, setNewGlobalWord] = useState('')
|
||||||
|
const [dictLoading, setDictLoading] = useState(false)
|
||||||
|
|
||||||
|
const fetchGlobalDict = async () => {
|
||||||
|
try {
|
||||||
|
const data = await apiFetch<{ words: DictWord[] }>('/api/dictionary?scope=GLOBAL', { silent: true })
|
||||||
|
if (data?.words) setGlobalDictWords(data.words)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchGlobalDict()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!newGlobalWord.trim()) return
|
||||||
|
setDictLoading(true)
|
||||||
|
try {
|
||||||
|
await apiFetch('/api/dictionary', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ word: newGlobalWord.trim(), scope: 'GLOBAL' }),
|
||||||
|
})
|
||||||
|
setNewGlobalWord('')
|
||||||
|
fetchGlobalDict()
|
||||||
|
toast({ title: 'Begriff hinzugefügt' })
|
||||||
|
} catch (err) {
|
||||||
|
toast({ title: 'Fehler', description: err instanceof ApiError ? err.message : 'Fehler', variant: 'destructive' })
|
||||||
|
} finally { setDictLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border rounded-lg p-6">
|
||||||
|
<h3 className="font-semibold text-lg mb-2 flex items-center gap-2">
|
||||||
|
<BookOpen className="w-5 h-5" />
|
||||||
|
Globales Wörterbuch
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Globale Begriffe, die allen Mandanten als Journal-Vorschläge zur Verfügung stehen.
|
||||||
|
Mandanten können zusätzlich eigene Begriffe über ihre Wörterliste hinzufügen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Add new word */}
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
<Input
|
||||||
|
placeholder="Neuer globaler Begriff, z.B. 'Leitung aufbauen'..."
|
||||||
|
value={newGlobalWord}
|
||||||
|
onChange={(e) => setNewGlobalWord(e.target.value)}
|
||||||
|
onKeyDown={async (e) => {
|
||||||
|
if (e.key === 'Enter' && newGlobalWord.trim()) handleAdd()
|
||||||
|
}}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={dictLoading}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={!newGlobalWord.trim() || dictLoading}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Hinzufügen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List of global words */}
|
||||||
|
{globalDictWords.length === 0 ? (
|
||||||
|
<div className="text-center text-muted-foreground py-8 text-sm border-2 border-dashed rounded-lg">
|
||||||
|
Noch keine globalen Begriffe hinterlegt.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{globalDictWords.map((w) => (
|
||||||
|
<span key={w.id} className="inline-flex items-center gap-1 px-3 py-1.5 bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-300 rounded-full text-sm border border-green-200 dark:border-green-800">
|
||||||
|
{w.word}
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await apiFetch(`/api/dictionary/${w.id}`, { method: 'DELETE' })
|
||||||
|
fetchGlobalDict()
|
||||||
|
toast({ title: 'Entfernt' })
|
||||||
|
} catch { toast({ title: 'Fehler', variant: 'destructive' }) }
|
||||||
|
}}
|
||||||
|
className="ml-1 hover:text-red-500 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground mt-4">
|
||||||
|
{globalDictWords.length} globale(r) Begriff(e). Diese erscheinen bei allen Mandanten als Vorschläge im Journal.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
450
src/components/admin/settings-tab.tsx
Normal file
450
src/components/admin/settings-tab.tsx
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { useToast } from '@/components/ui/use-toast'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import {
|
||||||
|
Mail, Send, CheckCircle, Ban, CreditCard, Map, MapPin, Settings,
|
||||||
|
Shield, UserPlus, ArrowLeft, Loader2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
interface SettingsTabProps {
|
||||||
|
usersCount: number
|
||||||
|
tenantsCount: number
|
||||||
|
iconsCount: number
|
||||||
|
onNavigateTab: (tab: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsTab({ usersCount, tenantsCount, iconsCount, onNavigateTab }: SettingsTabProps) {
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
|
// SMTP Settings
|
||||||
|
const [smtpHost, setSmtpHost] = useState('')
|
||||||
|
const [smtpPort, setSmtpPort] = useState('587')
|
||||||
|
const [smtpSecure, setSmtpSecure] = useState(false)
|
||||||
|
const [smtpUser, setSmtpUser] = useState('')
|
||||||
|
const [smtpPass, setSmtpPass] = useState('')
|
||||||
|
const [smtpFromName, setSmtpFromName] = useState('Lageplan')
|
||||||
|
const [smtpFromEmail, setSmtpFromEmail] = useState('')
|
||||||
|
const [smtpTestEmail, setSmtpTestEmail] = useState('')
|
||||||
|
const [smtpLoading, setSmtpLoading] = useState(false)
|
||||||
|
const [smtpStatus, setSmtpStatus] = useState<string | null>(null)
|
||||||
|
const [contactEmail, setContactEmail] = useState('app@lageplan.ch')
|
||||||
|
const [notifyRegistrationEmail, setNotifyRegistrationEmail] = useState('')
|
||||||
|
|
||||||
|
// Stripe Settings
|
||||||
|
const [stripePublicKey, setStripePublicKey] = useState('')
|
||||||
|
const [stripeSecretKey, setStripeSecretKey] = useState('')
|
||||||
|
const [stripeWebhookSecret, setStripeWebhookSecret] = useState('')
|
||||||
|
const [stripeLoading, setStripeLoading] = useState(false)
|
||||||
|
const [stripeStatus, setStripeStatus] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Demo Project
|
||||||
|
const [demoProjectId, setDemoProjectId] = useState('')
|
||||||
|
const [allProjects, setAllProjects] = useState<{ id: string; title: string; location?: string }[]>([])
|
||||||
|
const [demoLoading, setDemoLoading] = useState(false)
|
||||||
|
const [demoStatus, setDemoStatus] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Default Symbol Scale
|
||||||
|
const [defaultSymbolScale, setDefaultSymbolScale] = useState('1.5')
|
||||||
|
const [symbolScaleLoading, setSymbolScaleLoading] = useState(false)
|
||||||
|
const [symbolScaleStatus, setSymbolScaleStatus] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Load settings on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/admin/settings').then(r => r.json()).then(data => {
|
||||||
|
if (data.smtp) {
|
||||||
|
setSmtpHost(data.smtp.host || '')
|
||||||
|
setSmtpPort(data.smtp.port?.toString() || '587')
|
||||||
|
setSmtpSecure(data.smtp.secure || false)
|
||||||
|
setSmtpUser(data.smtp.user || '')
|
||||||
|
setSmtpFromName(data.smtp.fromName || 'Lageplan')
|
||||||
|
setSmtpFromEmail(data.smtp.fromEmail || '')
|
||||||
|
}
|
||||||
|
if (data.stripe) {
|
||||||
|
setStripePublicKey(data.stripe.publicKey || '')
|
||||||
|
setStripeSecretKey(data.stripe.secretKey ? '••••••••' : '')
|
||||||
|
setStripeWebhookSecret(data.stripe.webhookSecret ? '••••••••' : '')
|
||||||
|
}
|
||||||
|
if (data.contactEmail) setContactEmail(data.contactEmail)
|
||||||
|
if (data.notifyRegistrationEmail) setNotifyRegistrationEmail(data.notifyRegistrationEmail)
|
||||||
|
if (data.demoProjectId) setDemoProjectId(data.demoProjectId)
|
||||||
|
if (data.defaultSymbolScale) setDefaultSymbolScale(data.defaultSymbolScale.toString())
|
||||||
|
}).catch(() => {})
|
||||||
|
|
||||||
|
// Load projects for demo selector
|
||||||
|
fetch('/api/projects').then(r => r.json()).then(data => {
|
||||||
|
if (data.projects) setAllProjects(data.projects)
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSmtpSave = async () => {
|
||||||
|
setSmtpLoading(true)
|
||||||
|
setSmtpStatus(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'save_smtp',
|
||||||
|
smtp: { host: smtpHost, port: parseInt(smtpPort), secure: smtpSecure, user: smtpUser, pass: smtpPass, fromName: smtpFromName, fromEmail: smtpFromEmail },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.success) {
|
||||||
|
toast({ title: 'SMTP gespeichert' })
|
||||||
|
setSmtpStatus('saved')
|
||||||
|
} else throw new Error(data.error)
|
||||||
|
} catch (error) {
|
||||||
|
toast({ title: 'Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' })
|
||||||
|
} finally { setSmtpLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSmtpTest = async () => {
|
||||||
|
setSmtpLoading(true)
|
||||||
|
setSmtpStatus(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'test_smtp' }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.success) {
|
||||||
|
setSmtpStatus('connected')
|
||||||
|
toast({ title: 'SMTP-Verbindung erfolgreich' })
|
||||||
|
} else {
|
||||||
|
setSmtpStatus('error')
|
||||||
|
toast({ title: 'Verbindung fehlgeschlagen', description: data.error, variant: 'destructive' })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setSmtpStatus('error')
|
||||||
|
toast({ title: 'Fehler', variant: 'destructive' })
|
||||||
|
} finally { setSmtpLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSmtpSendTest = async () => {
|
||||||
|
if (!smtpTestEmail) return
|
||||||
|
setSmtpLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'send_test_email', testEmail: smtpTestEmail }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.success) toast({ title: data.message })
|
||||||
|
else toast({ title: 'Senden fehlgeschlagen', description: data.error, variant: 'destructive' })
|
||||||
|
} catch { toast({ title: 'Fehler', variant: 'destructive' }) }
|
||||||
|
finally { setSmtpLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleContactEmailSave = async () => {
|
||||||
|
setSmtpLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'save_contact_email', contactEmail }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.success) toast({ title: 'Kontakt-E-Mail gespeichert' })
|
||||||
|
else throw new Error(data.error)
|
||||||
|
} catch (error) {
|
||||||
|
toast({ title: 'Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' })
|
||||||
|
} finally { setSmtpLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStripeSave = async () => {
|
||||||
|
setStripeLoading(true)
|
||||||
|
setStripeStatus(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'save_stripe',
|
||||||
|
stripe: {
|
||||||
|
publicKey: stripePublicKey,
|
||||||
|
secretKey: stripeSecretKey,
|
||||||
|
webhookSecret: stripeWebhookSecret,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.success) {
|
||||||
|
setStripeStatus('saved')
|
||||||
|
toast({ title: 'Stripe-Einstellungen gespeichert' })
|
||||||
|
} else throw new Error(data.error)
|
||||||
|
} catch (error) {
|
||||||
|
setStripeStatus('error')
|
||||||
|
toast({ title: 'Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' })
|
||||||
|
} finally { setStripeLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* Contact Email */}
|
||||||
|
<div className="border rounded-lg p-6 md:col-span-2">
|
||||||
|
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
|
||||||
|
<Mail className="w-5 h-5 text-primary" />
|
||||||
|
Kontakt-E-Mail
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">E-Mail-Adresse für das Kontaktformular auf der Landing Page. Hierhin werden Anfragen gesendet.</p>
|
||||||
|
<div className="flex gap-2 items-end">
|
||||||
|
<div className="flex-1"><Label>Empfänger-Adresse</Label><Input value={contactEmail} onChange={e => setContactEmail(e.target.value)} placeholder="app@lageplan.ch" /></div>
|
||||||
|
<Button onClick={handleContactEmailSave} disabled={smtpLoading}>Speichern</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Registration Notification */}
|
||||||
|
<div className="border rounded-lg p-6">
|
||||||
|
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
|
||||||
|
<Mail className="w-5 h-5 text-primary" />
|
||||||
|
Registrierungs-Benachrichtigung
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">E-Mail-Adresse, an die bei neuen Registrierungen eine Benachrichtigung gesendet wird. Leer lassen = keine Benachrichtigung.</p>
|
||||||
|
<div className="flex gap-2 items-end">
|
||||||
|
<div className="flex-1"><Label>Admin-E-Mail</Label><Input value={notifyRegistrationEmail} onChange={e => setNotifyRegistrationEmail(e.target.value)} placeholder="admin@lageplan.ch" /></div>
|
||||||
|
<Button onClick={async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'save_setting', key: 'notify_registration_email', value: notifyRegistrationEmail }),
|
||||||
|
})
|
||||||
|
if ((await res.json()).success) toast({ title: 'Gespeichert' })
|
||||||
|
} catch { toast({ title: 'Fehler', variant: 'destructive' }) }
|
||||||
|
}} disabled={smtpLoading}>Speichern</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SMTP Settings */}
|
||||||
|
<div className="border rounded-lg p-6 md:col-span-2">
|
||||||
|
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
|
||||||
|
<Mail className="w-5 h-5 text-primary" />
|
||||||
|
E-Mail / SMTP Konfiguration
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">SMTP-Server für den E-Mail-Versand konfigurieren. Empfohlen: TLS auf Port 587. Passwörter werden verschlüsselt in der Datenbank gespeichert.</p>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div><Label>SMTP Host</Label><Input value={smtpHost} onChange={e => setSmtpHost(e.target.value)} placeholder="smtp.gmail.com" /></div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div><Label>Port</Label><Input value={smtpPort} onChange={e => setSmtpPort(e.target.value)} placeholder="587" /></div>
|
||||||
|
<div className="flex items-end gap-2 pb-0.5">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input type="checkbox" checked={smtpSecure} onChange={e => setSmtpSecure(e.target.checked)} className="rounded" />
|
||||||
|
SSL/TLS
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div><Label>Benutzername</Label><Input value={smtpUser} onChange={e => setSmtpUser(e.target.value)} placeholder="user@example.com" /></div>
|
||||||
|
<div><Label>Passwort</Label><Input type="password" value={smtpPass} onChange={e => setSmtpPass(e.target.value)} placeholder="App-Passwort oder SMTP-Passwort" /></div>
|
||||||
|
<div><Label>Absender-Name</Label><Input value={smtpFromName} onChange={e => setSmtpFromName(e.target.value)} placeholder="Lageplan" /></div>
|
||||||
|
<div><Label>Absender-E-Mail</Label><Input value={smtpFromEmail} onChange={e => setSmtpFromEmail(e.target.value)} placeholder="noreply@lageplan.ch" /></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-4">
|
||||||
|
<Button onClick={handleSmtpSave} disabled={smtpLoading || !smtpHost || !smtpUser}>
|
||||||
|
{smtpLoading ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : null}
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={handleSmtpTest} disabled={smtpLoading || !smtpHost}>
|
||||||
|
Verbindung testen
|
||||||
|
</Button>
|
||||||
|
{smtpStatus === 'connected' && <span className="flex items-center text-sm text-green-600"><CheckCircle className="w-4 h-4 mr-1" /> Verbunden</span>}
|
||||||
|
{smtpStatus === 'error' && <span className="flex items-center text-sm text-red-600"><Ban className="w-4 h-4 mr-1" /> Fehlgeschlagen</span>}
|
||||||
|
{smtpStatus === 'saved' && <span className="flex items-center text-sm text-green-600"><CheckCircle className="w-4 h-4 mr-1" /> Gespeichert</span>}
|
||||||
|
</div>
|
||||||
|
<div className="border-t mt-4 pt-4">
|
||||||
|
<Label className="text-sm font-medium">Test-E-Mail senden</Label>
|
||||||
|
<div className="flex gap-2 mt-1.5">
|
||||||
|
<Input value={smtpTestEmail} onChange={e => setSmtpTestEmail(e.target.value)} placeholder="empfaenger@example.com" className="max-w-xs" />
|
||||||
|
<Button variant="outline" onClick={handleSmtpSendTest} disabled={smtpLoading || !smtpTestEmail}>
|
||||||
|
<Send className="w-4 h-4 mr-1.5" />
|
||||||
|
Senden
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stripe Settings */}
|
||||||
|
<div className="border rounded-lg p-6 md:col-span-2">
|
||||||
|
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
|
||||||
|
<CreditCard className="w-5 h-5 text-primary" />
|
||||||
|
Stripe / Spenden-Konfiguration
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Stripe API-Keys für die Spendenseite konfigurieren. Unterstützt Kreditkarte, Twint und weitere Zahlungsmethoden.
|
||||||
|
Keys findest du im <a href="https://dashboard.stripe.com/apikeys" target="_blank" rel="noopener noreferrer" className="text-primary underline">Stripe Dashboard</a>.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div><Label>Publishable Key (pk_...)</Label><Input value={stripePublicKey} onChange={e => setStripePublicKey(e.target.value)} placeholder="pk_live_..." /></div>
|
||||||
|
<div><Label>Secret Key (sk_...)</Label><Input type="password" value={stripeSecretKey} onChange={e => setStripeSecretKey(e.target.value)} placeholder="sk_live_..." /></div>
|
||||||
|
<div className="md:col-span-2"><Label>Webhook Secret (whsec_...) — optional</Label><Input type="password" value={stripeWebhookSecret} onChange={e => setStripeWebhookSecret(e.target.value)} placeholder="whsec_..." /></div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
Webhook-Endpoint: <code className="bg-muted px-1.5 py-0.5 rounded text-xs">{typeof window !== 'undefined' ? window.location.origin : ''}/api/donate/webhook</code>
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 mt-4">
|
||||||
|
<Button onClick={handleStripeSave} disabled={stripeLoading || !stripePublicKey || !stripeSecretKey}>
|
||||||
|
{stripeLoading ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : null}
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
{stripeStatus === 'saved' && <span className="flex items-center text-sm text-green-600"><CheckCircle className="w-4 h-4 mr-1" /> Gespeichert</span>}
|
||||||
|
{stripeStatus === 'error' && <span className="flex items-center text-sm text-red-600"><Ban className="w-4 h-4 mr-1" /> Fehlgeschlagen</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Demo Project */}
|
||||||
|
<div className="border rounded-lg p-6 md:col-span-2">
|
||||||
|
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
|
||||||
|
<Map className="w-5 h-5 text-primary" />
|
||||||
|
Live-Demo auf der Startseite
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
Wähle ein Projekt als Demo-Karte für die Landing Page. Besucher können die Karte sehen und zoomen, aber nichts bearbeiten.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 items-end">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label>Demo-Projekt</Label>
|
||||||
|
<select
|
||||||
|
value={demoProjectId}
|
||||||
|
onChange={e => setDemoProjectId(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="">— Keine Demo —</option>
|
||||||
|
{allProjects.map(p => (
|
||||||
|
<option key={p.id} value={p.id}>{p.title}{p.location ? ` (${p.location})` : ''}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
setDemoLoading(true)
|
||||||
|
setDemoStatus(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'save_demo_project', demoProjectId }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.success) {
|
||||||
|
toast({ title: 'Demo-Projekt gespeichert' })
|
||||||
|
setDemoStatus('saved')
|
||||||
|
} else throw new Error(data.error)
|
||||||
|
} catch (error) {
|
||||||
|
toast({ title: 'Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' })
|
||||||
|
setDemoStatus('error')
|
||||||
|
} finally { setDemoLoading(false) }
|
||||||
|
}}
|
||||||
|
disabled={demoLoading}
|
||||||
|
>
|
||||||
|
{demoLoading ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : null}
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
{demoStatus === 'saved' && <span className="flex items-center text-sm text-green-600"><CheckCircle className="w-4 h-4 mr-1" /> Gespeichert</span>}
|
||||||
|
</div>
|
||||||
|
{demoProjectId && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
Vorschau: <a href="/demo" target="_blank" rel="noopener noreferrer" className="text-primary underline">/demo</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Symbol-Grösse */}
|
||||||
|
<div className="border rounded-lg p-6">
|
||||||
|
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
|
||||||
|
<Settings className="w-5 h-5 text-primary" />
|
||||||
|
Standard Symbol-Grösse
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
Bestimmt die Standard-Grösse neuer Symbole auf der Karte. Kleinere Werte = kleinere Symbole.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4 mb-3">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0.5"
|
||||||
|
max="5"
|
||||||
|
step="0.1"
|
||||||
|
value={defaultSymbolScale}
|
||||||
|
onChange={e => setDefaultSymbolScale(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-lg font-bold w-16 text-center">{defaultSymbolScale}x</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-muted-foreground mb-4">
|
||||||
|
<span>0.5x (klein)</span>
|
||||||
|
<span className="flex-1" />
|
||||||
|
<span>5x (gross)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
disabled={symbolScaleLoading}
|
||||||
|
onClick={async () => {
|
||||||
|
setSymbolScaleLoading(true)
|
||||||
|
setSymbolScaleStatus(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'save_setting', key: 'default_symbol_scale', value: defaultSymbolScale }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.success) setSymbolScaleStatus('saved')
|
||||||
|
} catch {} finally { setSymbolScaleLoading(false) }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{symbolScaleLoading ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : null}
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
{symbolScaleStatus === 'saved' && <span className="flex items-center text-sm text-green-600"><CheckCircle className="w-4 h-4 mr-1" /> Gespeichert</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* App Info */}
|
||||||
|
<div className="border rounded-lg p-6">
|
||||||
|
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
|
||||||
|
<MapPin className="w-5 h-5 text-primary" />
|
||||||
|
System-Info
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div className="flex justify-between"><span className="text-muted-foreground">Version</span><span className="font-medium">1.0.0</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-muted-foreground">Framework</span><span className="font-medium">Next.js 14.1</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-muted-foreground">Datenbank</span><span className="font-medium">PostgreSQL 16</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-muted-foreground">Benutzer</span><span className="font-medium">{usersCount}</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-muted-foreground">Mandanten</span><span className="font-medium">{tenantsCount}</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-muted-foreground">Symbole</span><span className="font-medium">{iconsCount}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="border rounded-lg p-6">
|
||||||
|
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
|
||||||
|
<Settings className="w-5 h-5 text-primary" />
|
||||||
|
Schnellaktionen
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Button variant="outline" className="w-full justify-start" onClick={() => onNavigateTab('tenants')}>
|
||||||
|
<Shield className="w-4 h-4 mr-2" />
|
||||||
|
Mandanten verwalten
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="w-full justify-start" onClick={() => onNavigateTab('users')}>
|
||||||
|
<UserPlus className="w-4 h-4 mr-2" />
|
||||||
|
Benutzer anlegen
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="w-full justify-start" asChild>
|
||||||
|
<Link href="/app">
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Zur Krokier-App
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
147
src/components/admin/soma-tab.tsx
Normal file
147
src/components/admin/soma-tab.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { useToast } from '@/components/ui/use-toast'
|
||||||
|
import { AlertTriangle, Eye, EyeOff, Trash2, Plus, Loader2, GripVertical } from 'lucide-react'
|
||||||
|
|
||||||
|
interface SomaTemplate {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
sortOrder: number
|
||||||
|
isActive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SomaTab() {
|
||||||
|
const { toast } = useToast()
|
||||||
|
const [somaTemplates, setSomaTemplates] = useState<SomaTemplate[]>([])
|
||||||
|
const [newSomaLabel, setNewSomaLabel] = useState('')
|
||||||
|
const [somaLoading, setSomaLoading] = useState(false)
|
||||||
|
|
||||||
|
const fetchSomaTemplates = async () => {
|
||||||
|
setSomaLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/tenant/soma-templates')
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setSomaTemplates(data.templates || [])
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
setSomaLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSomaTemplates()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!newSomaLabel.trim()) return
|
||||||
|
try {
|
||||||
|
await fetch('/api/tenant/soma-templates', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ label: newSomaLabel.trim(), sortOrder: somaTemplates.length }),
|
||||||
|
})
|
||||||
|
setNewSomaLabel('')
|
||||||
|
fetchSomaTemplates()
|
||||||
|
toast({ title: 'SOMA-Vorlage hinzugefügt' })
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border rounded-lg p-6">
|
||||||
|
<h3 className="font-semibold text-lg mb-2 flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-red-600" />
|
||||||
|
SOMA-Checkliste verwalten
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Definiere die Sofortmassnahmen (SOMA), die bei jedem neuen Einsatz als Checkliste erscheinen.
|
||||||
|
Bestehende Einsätze werden nicht verändert.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{somaLoading ? (
|
||||||
|
<div className="flex items-center gap-2 py-4 text-muted-foreground">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" /> Laden...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Template list */}
|
||||||
|
<div className="border rounded-lg divide-y">
|
||||||
|
{somaTemplates.length === 0 ? (
|
||||||
|
<div className="px-4 py-6 text-center text-muted-foreground text-sm">
|
||||||
|
Keine SOMA-Vorlagen definiert. Neue Einsätze starten ohne Checkliste.
|
||||||
|
</div>
|
||||||
|
) : somaTemplates.map((tpl, idx) => (
|
||||||
|
<div key={tpl.id} className={`flex items-center gap-3 px-4 py-2.5 ${!tpl.isActive ? 'opacity-50' : ''}`}>
|
||||||
|
<GripVertical className="w-4 h-4 text-muted-foreground/40 shrink-0" />
|
||||||
|
<span className="text-sm font-medium flex-1">{tpl.label}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">#{idx + 1}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/tenant/soma-templates', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ updates: [{ id: tpl.id, isActive: !tpl.isActive }] }),
|
||||||
|
})
|
||||||
|
fetchSomaTemplates()
|
||||||
|
} catch {}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tpl.isActive ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-destructive hover:text-destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!confirm(`"${tpl.label}" wirklich löschen?`)) return
|
||||||
|
try {
|
||||||
|
await fetch('/api/tenant/soma-templates', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: tpl.id }),
|
||||||
|
})
|
||||||
|
fetchSomaTemplates()
|
||||||
|
toast({ title: 'SOMA-Vorlage gelöscht' })
|
||||||
|
} catch {}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add new */}
|
||||||
|
<div className="flex gap-2 mt-4">
|
||||||
|
<Input
|
||||||
|
placeholder="Neue Sofortmassnahme..."
|
||||||
|
value={newSomaLabel}
|
||||||
|
onChange={e => setNewSomaLabel(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter' && newSomaLabel.trim()) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleAdd()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button disabled={!newSomaLabel.trim()} onClick={handleAdd}>
|
||||||
|
<Plus className="w-4 h-4 mr-1" /> Hinzufügen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground mt-3">
|
||||||
|
{somaTemplates.filter(t => t.isActive).length} aktiv / {somaTemplates.length} gesamt —
|
||||||
|
Nur aktive Vorlagen erscheinen bei neuen Einsätzen.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
150
src/components/admin/suggestions-tab.tsx
Normal file
150
src/components/admin/suggestions-tab.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { useToast } from '@/components/ui/use-toast'
|
||||||
|
import { BookOpen, Plus, X, Download, Upload } from 'lucide-react'
|
||||||
|
|
||||||
|
interface SuggestionsTabProps {
|
||||||
|
tenantId: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SuggestionsTab({ tenantId }: SuggestionsTabProps) {
|
||||||
|
const { toast } = useToast()
|
||||||
|
const [journalSuggestions, setJournalSuggestions] = useState<string[]>([])
|
||||||
|
const [newSuggestion, setNewSuggestion] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tenantId) return
|
||||||
|
fetch(`/api/tenants/${tenantId}/suggestions`)
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(data => { if (data?.suggestions) setJournalSuggestions(data.suggestions) })
|
||||||
|
.catch(() => {})
|
||||||
|
}, [tenantId])
|
||||||
|
|
||||||
|
const saveSuggestions = (updated: string[]) => {
|
||||||
|
if (!tenantId) return
|
||||||
|
fetch(`/api/tenants/${tenantId}/suggestions`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ suggestions: updated }),
|
||||||
|
}).then(r => { if (r.ok) toast({ title: 'Gespeichert' }) })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
const trimmed = newSuggestion.trim()
|
||||||
|
if (!trimmed || journalSuggestions.includes(trimmed)) return
|
||||||
|
const updated = [...journalSuggestions, trimmed].sort((a, b) => a.localeCompare(b, 'de'))
|
||||||
|
setJournalSuggestions(updated)
|
||||||
|
setNewSuggestion('')
|
||||||
|
saveSuggestions(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemove = (index: number) => {
|
||||||
|
const updated = journalSuggestions.filter((_, idx) => idx !== index)
|
||||||
|
setJournalSuggestions(updated)
|
||||||
|
saveSuggestions(updated)
|
||||||
|
toast({ title: 'Entfernt' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border rounded-lg p-6">
|
||||||
|
<h3 className="font-semibold text-lg mb-2 flex items-center gap-2">
|
||||||
|
<BookOpen className="w-5 h-5" />
|
||||||
|
Journal-Wörterliste
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Häufige Begriffe und Textbausteine, die beim Erfassen von Journal-Einträgen als Vorschläge erscheinen.
|
||||||
|
Wenn der Benutzer im "Was..."-Feld tippt, werden passende Begriffe vorgeschlagen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Add new suggestion */}
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
<Input
|
||||||
|
placeholder="Neuer Begriff, z.B. 'Leitung aufbauen'..."
|
||||||
|
value={newSuggestion}
|
||||||
|
onChange={(e) => setNewSuggestion(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && newSuggestion.trim()) handleAdd()
|
||||||
|
}}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button onClick={handleAdd} disabled={!newSuggestion.trim()}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Hinzufügen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List of suggestions */}
|
||||||
|
{journalSuggestions.length === 0 ? (
|
||||||
|
<div className="text-center text-muted-foreground py-8 text-sm border-2 border-dashed rounded-lg">
|
||||||
|
Noch keine Begriffe hinterlegt. Fügen Sie häufig verwendete Textbausteine hinzu,<br />
|
||||||
|
z.B. "Leitung aufbauen", "Leitung abbauen", "Lüfter in Stellung", etc.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{journalSuggestions.map((s, i) => (
|
||||||
|
<span key={i} className="inline-flex items-center gap-1 px-3 py-1.5 bg-blue-50 dark:bg-blue-950/30 text-blue-700 dark:text-blue-300 rounded-full text-sm border border-blue-200 dark:border-blue-800">
|
||||||
|
{s}
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemove(i)}
|
||||||
|
className="ml-1 hover:text-red-500 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mt-4 pt-4 border-t">
|
||||||
|
<p className="text-xs text-muted-foreground flex-1">
|
||||||
|
{journalSuggestions.length} Begriff(e) hinterlegt. Änderungen werden automatisch gespeichert.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const blob = new Blob([journalSuggestions.join('\n')], { type: 'text/plain' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = 'woerterliste.txt'
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
toast({ title: 'Exportiert', description: `${journalSuggestions.length} Begriffe exportiert` })
|
||||||
|
}}
|
||||||
|
disabled={journalSuggestions.length === 0}
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-1" />
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.type = 'file'
|
||||||
|
input.accept = '.txt,.csv'
|
||||||
|
input.onchange = async (e) => {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
const text = await file.text()
|
||||||
|
const words = text.split(/[\n\r,;]+/).map(w => w.trim()).filter(Boolean)
|
||||||
|
if (words.length === 0) { toast({ title: 'Keine Begriffe gefunden', variant: 'destructive' }); return }
|
||||||
|
const merged = Array.from(new Set([...journalSuggestions, ...words])).sort((a, b) => a.localeCompare(b, 'de'))
|
||||||
|
setJournalSuggestions(merged)
|
||||||
|
saveSuggestions(merged)
|
||||||
|
toast({ title: 'Importiert', description: `${words.length} Begriffe importiert (${merged.length} total)` })
|
||||||
|
}
|
||||||
|
input.click()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4 mr-1" />
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
372
src/components/admin/symbol-manager.tsx
Normal file
372
src/components/admin/symbol-manager.tsx
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { useToast } from '@/components/ui/use-toast'
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Search,
|
||||||
|
Upload,
|
||||||
|
Loader2,
|
||||||
|
X,
|
||||||
|
Check,
|
||||||
|
LayoutGrid,
|
||||||
|
ImageIcon,
|
||||||
|
Info,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
interface LibraryIcon {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
mimeType: string
|
||||||
|
iconType: string
|
||||||
|
categoryId: string
|
||||||
|
categoryName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MySymbol {
|
||||||
|
id: string
|
||||||
|
iconId: string
|
||||||
|
name: string
|
||||||
|
customName: string | null
|
||||||
|
baseName: string
|
||||||
|
mimeType: string
|
||||||
|
iconType: string
|
||||||
|
categoryName: string
|
||||||
|
sortOrder: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SymbolManager() {
|
||||||
|
const { toast } = useToast()
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [library, setLibrary] = useState<LibraryIcon[]>([])
|
||||||
|
const [mySymbols, setMySymbols] = useState<MySymbol[]>([])
|
||||||
|
|
||||||
|
// Library UI state
|
||||||
|
const [librarySearch, setLibrarySearch] = useState('')
|
||||||
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
|
||||||
|
const [libraryCollapsed, setLibraryCollapsed] = useState(false)
|
||||||
|
|
||||||
|
// My Symbols UI state
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [editName, setEditName] = useState('')
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/tenant/symbols')
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setLibrary(data.library || [])
|
||||||
|
setMySymbols(data.mySymbols || [])
|
||||||
|
// Auto-collapse library if tenant has own symbols
|
||||||
|
if ((data.mySymbols || []).length > 0) {
|
||||||
|
setLibraryCollapsed(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
setLoading(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { fetchData() }, [fetchData])
|
||||||
|
|
||||||
|
// Add symbol from library to my collection
|
||||||
|
const addSymbol = async (iconId: string, customName?: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/tenant/symbols', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ iconId, customName }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const symbol = await res.json()
|
||||||
|
setMySymbols(prev => [...prev, symbol])
|
||||||
|
toast({ title: 'Symbol hinzugefügt' })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Fehler', variant: 'destructive' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename a symbol
|
||||||
|
const renameSymbol = async (id: string, customName: string) => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/tenant/symbols', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id, customName }),
|
||||||
|
})
|
||||||
|
setMySymbols(prev => prev.map(s =>
|
||||||
|
s.id === id ? { ...s, name: customName || s.baseName, customName: customName || null } : s
|
||||||
|
))
|
||||||
|
setEditingId(null)
|
||||||
|
setEditName('')
|
||||||
|
toast({ title: 'Umbenannt' })
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Fehler', variant: 'destructive' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove a symbol
|
||||||
|
const removeSymbol = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/tenant/symbols', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id }),
|
||||||
|
})
|
||||||
|
setMySymbols(prev => prev.filter(s => s.id !== id))
|
||||||
|
toast({ title: 'Symbol entfernt' })
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Fehler', variant: 'destructive' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle category expand/collapse
|
||||||
|
const toggleCategory = (cat: string) => {
|
||||||
|
setExpandedCategories(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.has(cat) ? next.delete(cat) : next.add(cat)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group library icons by category
|
||||||
|
const filteredLibrary = library.filter(icon =>
|
||||||
|
!librarySearch || icon.name.toLowerCase().includes(librarySearch.toLowerCase())
|
||||||
|
)
|
||||||
|
const libraryGrouped = filteredLibrary.reduce<Record<string, LibraryIcon[]>>((acc, icon) => {
|
||||||
|
const key = icon.categoryName
|
||||||
|
if (!acc[key]) acc[key] = []
|
||||||
|
acc[key].push(icon)
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 py-12 justify-center text-muted-foreground">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" /> Symbole laden...
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* ===== MEINE SYMBOLE (always on top, prominent) ===== */}
|
||||||
|
<div className="border-2 border-primary/20 rounded-lg">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 bg-primary/5 border-b border-primary/20">
|
||||||
|
<h3 className="font-semibold text-sm flex items-center gap-2">
|
||||||
|
<LayoutGrid className="w-4 h-4 text-primary" />
|
||||||
|
Meine Symbole
|
||||||
|
<span className="text-xs text-muted-foreground font-normal">({mySymbols.length})</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mySymbols.length === 0 ? (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<ImageIcon className="w-10 h-10 mx-auto text-muted-foreground/40 mb-3" />
|
||||||
|
<p className="text-sm text-muted-foreground mb-1">Noch keine eigenen Symbole definiert.</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Füge Symbole aus der Bibliothek unten hinzu oder lade eigene SVGs hoch.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-3">
|
||||||
|
{mySymbols.map(sym => (
|
||||||
|
<div
|
||||||
|
key={sym.id}
|
||||||
|
className="group relative border rounded-lg p-2 transition-all hover:shadow-md hover:border-primary/30"
|
||||||
|
>
|
||||||
|
<div className="aspect-square flex items-center justify-center mb-1.5 bg-muted/50 rounded">
|
||||||
|
<img
|
||||||
|
src={`/api/icons/${sym.iconId}/image`}
|
||||||
|
alt={sym.name}
|
||||||
|
className="w-12 h-12 object-contain"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name / Edit */}
|
||||||
|
{editingId === sym.id ? (
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
<Input
|
||||||
|
value={editName}
|
||||||
|
onChange={e => setEditName(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') renameSymbol(sym.id, editName)
|
||||||
|
if (e.key === 'Escape') { setEditingId(null); setEditName('') }
|
||||||
|
}}
|
||||||
|
className="h-6 text-[10px] px-1"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button onClick={() => renameSymbol(sym.id, editName)} className="text-green-600 hover:text-green-700">
|
||||||
|
<Check className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setEditingId(null); setEditName('') }} className="text-muted-foreground hover:text-foreground">
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p
|
||||||
|
className="text-[11px] text-center truncate cursor-pointer hover:text-primary"
|
||||||
|
title={`${sym.name}${sym.customName ? ` (Basis: ${sym.baseName})` : ''} — Klick zum Umbenennen`}
|
||||||
|
onClick={() => { setEditingId(sym.id); setEditName(sym.name) }}
|
||||||
|
>
|
||||||
|
{sym.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hover actions */}
|
||||||
|
<div className="absolute top-1 right-1 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingId(sym.id); setEditName(sym.name) }}
|
||||||
|
className="w-5 h-5 rounded bg-background/80 border flex items-center justify-center text-muted-foreground hover:text-primary"
|
||||||
|
title="Umbenennen"
|
||||||
|
>
|
||||||
|
<Pencil className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => removeSymbol(sym.id)}
|
||||||
|
className="w-5 h-5 rounded bg-background/80 border flex items-center justify-center text-muted-foreground hover:text-destructive"
|
||||||
|
title="Entfernen"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom name badge */}
|
||||||
|
{sym.customName && (
|
||||||
|
<div className="absolute top-1 left-1">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-primary" title="Eigener Name" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== UPLOAD HINWEIS ===== */}
|
||||||
|
<div className="flex items-start gap-3 px-4 py-3 bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||||
|
<Info className="w-4 h-4 text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" />
|
||||||
|
<div className="text-xs text-blue-700 dark:text-blue-300">
|
||||||
|
<strong>Tipp:</strong> Eigene Symbole am besten als <strong>SVG</strong> hochladen — diese werden in jeder Grösse scharf dargestellt.
|
||||||
|
PNG/JPEG sind auch möglich, können aber bei Vergrösserung unscharf werden.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== STANDARD-BIBLIOTHEK (collapsible) ===== */}
|
||||||
|
<div className="border rounded-lg">
|
||||||
|
<button
|
||||||
|
className="w-full flex items-center justify-between px-4 py-3 hover:bg-muted/50 transition-colors"
|
||||||
|
onClick={() => setLibraryCollapsed(!libraryCollapsed)}
|
||||||
|
>
|
||||||
|
<h3 className="font-semibold text-sm flex items-center gap-2">
|
||||||
|
{libraryCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||||
|
Standard-Bibliothek
|
||||||
|
<span className="text-xs text-muted-foreground font-normal">({library.length} Symbole)</span>
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{libraryCollapsed ? 'Aufklappen' : 'Zuklappen'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!libraryCollapsed && (
|
||||||
|
<div className="border-t px-4 py-3 space-y-4">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Symbole suchen..."
|
||||||
|
value={librarySearch}
|
||||||
|
onChange={e => setLibrarySearch(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Categories */}
|
||||||
|
{Object.entries(libraryGrouped).sort(([a], [b]) => a.localeCompare(b)).map(([catName, icons]) => (
|
||||||
|
<div key={catName} className="border rounded-lg">
|
||||||
|
<button
|
||||||
|
className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/30 transition-colors text-sm"
|
||||||
|
onClick={() => toggleCategory(catName)}
|
||||||
|
>
|
||||||
|
<span className="font-medium flex items-center gap-2">
|
||||||
|
{expandedCategories.has(catName) ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
|
||||||
|
{catName}
|
||||||
|
<span className="text-xs text-muted-foreground font-normal">({icons.length})</span>
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
// Add all icons from this category
|
||||||
|
icons.forEach(icon => addSymbol(icon.id))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3 mr-1" /> Alle hinzufügen
|
||||||
|
</Button>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expandedCategories.has(catName) && (
|
||||||
|
<div className="border-t px-3 py-3">
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10 gap-2">
|
||||||
|
{icons.map(icon => {
|
||||||
|
const alreadyAdded = mySymbols.some(s => s.iconId === icon.id)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={icon.id}
|
||||||
|
onClick={() => addSymbol(icon.id)}
|
||||||
|
className={`group relative border rounded-lg p-2 transition-all hover:shadow-sm hover:border-primary/40 ${
|
||||||
|
alreadyAdded ? 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-800' : ''
|
||||||
|
}`}
|
||||||
|
title={`${icon.name} — Klick zum Hinzufügen`}
|
||||||
|
>
|
||||||
|
<div className="aspect-square flex items-center justify-center mb-1 bg-muted/30 rounded">
|
||||||
|
<img
|
||||||
|
src={`/api/icons/${icon.id}/image`}
|
||||||
|
alt={icon.name}
|
||||||
|
className="w-10 h-10 object-contain"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-center truncate">{icon.name}</p>
|
||||||
|
{/* Add overlay */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-primary/10 opacity-0 group-hover:opacity-100 rounded-lg transition-opacity">
|
||||||
|
<Plus className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
{/* Already added indicator */}
|
||||||
|
{alreadyAdded && (
|
||||||
|
<div className="absolute top-1 right-1 w-3 h-3 rounded-full bg-green-500 flex items-center justify-center">
|
||||||
|
<Check className="w-2 h-2 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{Object.keys(libraryGrouped).length === 0 && (
|
||||||
|
<div className="text-center text-muted-foreground py-6 text-sm">
|
||||||
|
{librarySearch ? 'Keine Symbole gefunden.' : 'Keine Standard-Symbole vorhanden.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
58
src/components/error-boundary.tsx
Normal file
58
src/components/error-boundary.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import { AlertTriangle, RotateCcw } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
interface ErrorBoundaryProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
fallback?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorBoundaryState {
|
||||||
|
hasError: boolean
|
||||||
|
error: Error | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
|
constructor(props: ErrorBoundaryProps) {
|
||||||
|
super(props)
|
||||||
|
this.state = { hasError: false, error: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||||
|
return { hasError: true, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||||
|
console.error('[ErrorBoundary] Caught error:', error, errorInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleReset = () => {
|
||||||
|
this.setState({ hasError: false, error: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
if (this.props.fallback) {
|
||||||
|
return this.props.fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[200px] p-8 text-center">
|
||||||
|
<AlertTriangle className="w-10 h-10 text-destructive mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Etwas ist schiefgelaufen</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4 max-w-md">
|
||||||
|
{this.state.error?.message || 'Ein unerwarteter Fehler ist aufgetreten.'}
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" onClick={this.handleReset}>
|
||||||
|
<RotateCcw className="w-4 h-4 mr-2" />
|
||||||
|
Erneut versuchen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
AlertTriangle, ClipboardList, Loader2, Printer, Pencil, Send, FileText,
|
AlertTriangle, ClipboardList, Loader2, Printer, Pencil, Send, FileText,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { getSocket } from '@/lib/socket'
|
import { getSocket } from '@/lib/socket'
|
||||||
|
import { RapportDialog } from '@/components/journal/rapport-dialog'
|
||||||
|
|
||||||
interface JournalEntry {
|
interface JournalEntry {
|
||||||
id: string
|
id: string
|
||||||
@@ -86,7 +87,6 @@ export function JournalView({ projectId, projectTitle, projectLocation, einsatzl
|
|||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// Rapport creation
|
// Rapport creation
|
||||||
const [creatingRapport, setCreatingRapport] = useState(false)
|
|
||||||
const [lastRapportLink, setLastRapportLink] = useState<string | null>(null)
|
const [lastRapportLink, setLastRapportLink] = useState<string | null>(null)
|
||||||
const [showRapportDialog, setShowRapportDialog] = useState(false)
|
const [showRapportDialog, setShowRapportDialog] = useState(false)
|
||||||
const [rapportForm, setRapportForm] = useState<Record<string, any>>({})
|
const [rapportForm, setRapportForm] = useState<Record<string, any>>({})
|
||||||
@@ -895,210 +895,16 @@ export function JournalView({ projectId, projectTitle, projectLocation, einsatzl
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rapport Dialog */}
|
{/* Rapport Dialog */}
|
||||||
{showRapportDialog && (
|
{showRapportDialog && projectId && (
|
||||||
<div className="fixed inset-0 z-50 bg-black/50 flex items-start justify-center overflow-auto py-8 print:hidden">
|
<RapportDialog
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-2xl w-full max-w-2xl mx-4">
|
projectId={projectId}
|
||||||
<div className="flex items-center justify-between p-4 border-b">
|
rapportForm={rapportForm}
|
||||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
setRapportForm={setRapportForm}
|
||||||
<FileText className="w-5 h-5" />
|
mapRef={mapRef}
|
||||||
Einsatzrapport erstellen
|
mapScreenshot={preCapuredScreenshot}
|
||||||
</h3>
|
onClose={() => setShowRapportDialog(false)}
|
||||||
<button onClick={() => setShowRapportDialog(false)} className="text-gray-400 hover:text-gray-600 text-xl leading-none">×</button>
|
onRapportCreated={(link) => setLastRapportLink(link)}
|
||||||
</div>
|
/>
|
||||||
<div className="p-4 space-y-4 max-h-[70vh] overflow-auto">
|
|
||||||
{/* Organisation */}
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase">Organisation</label>
|
|
||||||
<Input value={rapportForm.organisation || ''} onChange={e => setRapportForm(f => ({ ...f, organisation: e.target.value }))} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase">Abteilung</label>
|
|
||||||
<Input value={rapportForm.abteilung || ''} onChange={e => setRapportForm(f => ({ ...f, abteilung: e.target.value }))} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Einsatzdaten */}
|
|
||||||
<div className="grid grid-cols-4 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase">Datum</label>
|
|
||||||
<Input value={rapportForm.datum || ''} onChange={e => setRapportForm(f => ({ ...f, datum: e.target.value }))} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase">Uhrzeit</label>
|
|
||||||
<Input value={rapportForm.uhrzeit || ''} onChange={e => setRapportForm(f => ({ ...f, uhrzeit: e.target.value }))} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase">Einsatz-Nr.</label>
|
|
||||||
<Input value={rapportForm.einsatzNr || ''} onChange={e => setRapportForm(f => ({ ...f, einsatzNr: e.target.value }))} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase">Priorität</label>
|
|
||||||
<Input value={rapportForm.prioritaet || ''} onChange={e => setRapportForm(f => ({ ...f, prioritaet: e.target.value }))} placeholder="z.B. Hoch" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Ort */}
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase">Einsatzort / Adresse</label>
|
|
||||||
<Input value={rapportForm.einsatzort || ''} onChange={e => setRapportForm(f => ({ ...f, einsatzort: e.target.value }))} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase">Objekt / Gebäude</label>
|
|
||||||
<Input value={rapportForm.objekt || ''} onChange={e => setRapportForm(f => ({ ...f, objekt: e.target.value }))} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase">Alarmierungsart</label>
|
|
||||||
<Input value={rapportForm.alarmierungsart || ''} onChange={e => setRapportForm(f => ({ ...f, alarmierungsart: e.target.value }))} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase">Stichwort / Meldebild</label>
|
|
||||||
<Input value={rapportForm.stichwort || ''} onChange={e => setRapportForm(f => ({ ...f, stichwort: e.target.value }))} />
|
|
||||||
</div>
|
|
||||||
{/* Zeitverlauf */}
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase mb-1 block">Zeitverlauf</label>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="text-[10px] text-gray-400">Alarm</label>
|
|
||||||
<Input type="time" className="text-sm h-8" value={rapportForm.zeitAlarm || ''} onChange={e => setRapportForm(f => ({ ...f, zeitAlarm: e.target.value }))} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-[10px] text-gray-400">Eintreffen</label>
|
|
||||||
<Input type="time" className="text-sm h-8" value={rapportForm.zeitEintreffen || ''} onChange={e => setRapportForm(f => ({ ...f, zeitEintreffen: e.target.value }))} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Lagebild */}
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase">Lage bei Eintreffen</label>
|
|
||||||
<textarea className="w-full border rounded-md px-3 py-2 text-sm min-h-[60px] resize-y" value={rapportForm.lageEintreffen || ''} onChange={e => setRapportForm(f => ({ ...f, lageEintreffen: e.target.value }))} />
|
|
||||||
</div>
|
|
||||||
{/* Massnahmen (read-only, from journal) */}
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase">Massnahmen (aus Journal)</label>
|
|
||||||
<div className="border rounded-md p-2 bg-gray-50 dark:bg-gray-800 text-sm max-h-32 overflow-auto">
|
|
||||||
{Array.isArray(rapportForm.massnahmen) && rapportForm.massnahmen.length > 0 ? (
|
|
||||||
rapportForm.massnahmen.map((m: string, i: number) => <div key={i} className="py-0.5">• {m}</div>)
|
|
||||||
) : <span className="text-gray-400">Keine Einträge</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Bemerkungen */}
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase">Bemerkungen</label>
|
|
||||||
<textarea className="w-full border rounded-md px-3 py-2 text-sm min-h-[60px] resize-y" value={rapportForm.bemerkungen || ''} onChange={e => setRapportForm(f => ({ ...f, bemerkungen: e.target.value }))} />
|
|
||||||
</div>
|
|
||||||
{/* Unterschriften */}
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase">Einsatzleiter/in</label>
|
|
||||||
<Input value={rapportForm.einsatzleiter || ''} onChange={e => setRapportForm(f => ({ ...f, einsatzleiter: e.target.value }))} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase">Rapporteur</label>
|
|
||||||
<Input value={rapportForm.rapporteur || ''} onChange={e => setRapportForm(f => ({ ...f, rapporteur: e.target.value }))} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-end gap-3 p-4 border-t">
|
|
||||||
<Button variant="outline" size="sm" onClick={() => setShowRapportDialog(false)}>Abbrechen</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
disabled={creatingRapport}
|
|
||||||
onClick={async () => {
|
|
||||||
if (!projectId) return
|
|
||||||
setCreatingRapport(true)
|
|
||||||
try {
|
|
||||||
// Capture map screenshot — compress to JPEG and resize for smaller payload
|
|
||||||
let mapScreenshot = ''
|
|
||||||
const rawScreenshot = preCapuredScreenshot || ''
|
|
||||||
if (!rawScreenshot) {
|
|
||||||
try {
|
|
||||||
if (mapRef?.current) {
|
|
||||||
const canvas = mapRef.current.getCanvas()
|
|
||||||
if (canvas) {
|
|
||||||
// Resize to max 2400px wide and convert to JPEG
|
|
||||||
const maxW = 2400
|
|
||||||
const ratio = Math.min(1, maxW / canvas.width)
|
|
||||||
const offscreen = document.createElement('canvas')
|
|
||||||
offscreen.width = Math.round(canvas.width * ratio)
|
|
||||||
offscreen.height = Math.round(canvas.height * ratio)
|
|
||||||
const ctx = offscreen.getContext('2d')
|
|
||||||
if (ctx) {
|
|
||||||
ctx.drawImage(canvas, 0, 0, offscreen.width, offscreen.height)
|
|
||||||
mapScreenshot = offscreen.toDataURL('image/jpeg', 0.85)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) { console.warn('Map screenshot failed:', e) }
|
|
||||||
} else if (rawScreenshot.length > 800000) {
|
|
||||||
// Compress pre-captured screenshot if too large
|
|
||||||
try {
|
|
||||||
const img = new Image()
|
|
||||||
img.src = rawScreenshot
|
|
||||||
await new Promise(r => { img.onload = r; img.onerror = r })
|
|
||||||
const maxW = 2400
|
|
||||||
const ratio = Math.min(1, maxW / img.naturalWidth)
|
|
||||||
const offscreen = document.createElement('canvas')
|
|
||||||
offscreen.width = Math.round(img.naturalWidth * ratio)
|
|
||||||
offscreen.height = Math.round(img.naturalHeight * ratio)
|
|
||||||
const ctx = offscreen.getContext('2d')
|
|
||||||
if (ctx) {
|
|
||||||
ctx.drawImage(img, 0, 0, offscreen.width, offscreen.height)
|
|
||||||
mapScreenshot = offscreen.toDataURL('image/jpeg', 0.85)
|
|
||||||
}
|
|
||||||
} catch { mapScreenshot = rawScreenshot }
|
|
||||||
} else {
|
|
||||||
mapScreenshot = rawScreenshot
|
|
||||||
}
|
|
||||||
// Convert logo URL to base64 for PDF rendering
|
|
||||||
let logoDataUri = ''
|
|
||||||
if (rapportForm.logoUrl) {
|
|
||||||
try {
|
|
||||||
const logoRes = await fetch(rapportForm.logoUrl)
|
|
||||||
if (logoRes.ok) {
|
|
||||||
const blob = await logoRes.blob()
|
|
||||||
logoDataUri = await new Promise<string>((resolve) => {
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onloadend = () => resolve(reader.result as string)
|
|
||||||
reader.readAsDataURL(blob)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (e) { console.warn('Logo fetch failed:', e) }
|
|
||||||
}
|
|
||||||
const rapportData = { ...rapportForm, mapScreenshot, logoUrl: logoDataUri || rapportForm.logoUrl }
|
|
||||||
console.log('[Rapport] Sending request, body size ~', JSON.stringify({ projectId, data: rapportData }).length, 'bytes')
|
|
||||||
const res = await fetch('/api/rapports', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ projectId, data: rapportData }),
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
const result = await res.json()
|
|
||||||
setLastRapportLink(`/rapport/${result.token}`)
|
|
||||||
setShowRapportDialog(false)
|
|
||||||
window.open(`/rapport/${result.token}`, '_blank')
|
|
||||||
} else {
|
|
||||||
const errData = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
|
|
||||||
console.error('[Rapport] API error:', res.status, errData)
|
|
||||||
alert(`Rapport-Fehler: ${errData.error || 'Unbekannter Fehler (Status ' + res.status + ')'}`)
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Error creating rapport:', err)
|
|
||||||
alert('Rapport-Fehler: ' + (err?.message || 'Netzwerkfehler'))
|
|
||||||
} finally {
|
|
||||||
setCreatingRapport(false)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{creatingRapport ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <FileText className="w-4 h-4 mr-1.5" />}
|
|
||||||
Rapport generieren
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
236
src/components/journal/rapport-dialog.tsx
Normal file
236
src/components/journal/rapport-dialog.tsx
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, MutableRefObject } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { FileText, Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
|
interface RapportDialogProps {
|
||||||
|
projectId: string
|
||||||
|
rapportForm: Record<string, any>
|
||||||
|
setRapportForm: React.Dispatch<React.SetStateAction<Record<string, any>>>
|
||||||
|
mapRef?: MutableRefObject<any>
|
||||||
|
mapScreenshot?: string
|
||||||
|
onClose: () => void
|
||||||
|
onRapportCreated: (link: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RapportDialog({
|
||||||
|
projectId,
|
||||||
|
rapportForm,
|
||||||
|
setRapportForm,
|
||||||
|
mapRef,
|
||||||
|
mapScreenshot: preCapuredScreenshot,
|
||||||
|
onClose,
|
||||||
|
onRapportCreated,
|
||||||
|
}: RapportDialogProps) {
|
||||||
|
const [creatingRapport, setCreatingRapport] = useState(false)
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!projectId) return
|
||||||
|
setCreatingRapport(true)
|
||||||
|
try {
|
||||||
|
// Capture map screenshot — compress to JPEG and resize for smaller payload
|
||||||
|
let mapScreenshot = ''
|
||||||
|
const rawScreenshot = preCapuredScreenshot || ''
|
||||||
|
if (!rawScreenshot) {
|
||||||
|
try {
|
||||||
|
if (mapRef?.current) {
|
||||||
|
const canvas = mapRef.current.getCanvas()
|
||||||
|
if (canvas) {
|
||||||
|
// Resize to max 2400px wide and convert to JPEG
|
||||||
|
const maxW = 2400
|
||||||
|
const ratio = Math.min(1, maxW / canvas.width)
|
||||||
|
const offscreen = document.createElement('canvas')
|
||||||
|
offscreen.width = Math.round(canvas.width * ratio)
|
||||||
|
offscreen.height = Math.round(canvas.height * ratio)
|
||||||
|
const ctx = offscreen.getContext('2d')
|
||||||
|
if (ctx) {
|
||||||
|
ctx.drawImage(canvas, 0, 0, offscreen.width, offscreen.height)
|
||||||
|
mapScreenshot = offscreen.toDataURL('image/jpeg', 0.85)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { console.warn('Map screenshot failed:', e) }
|
||||||
|
} else if (rawScreenshot.length > 800000) {
|
||||||
|
// Compress pre-captured screenshot if too large
|
||||||
|
try {
|
||||||
|
const img = new Image()
|
||||||
|
img.src = rawScreenshot
|
||||||
|
await new Promise(r => { img.onload = r; img.onerror = r })
|
||||||
|
const maxW = 2400
|
||||||
|
const ratio = Math.min(1, maxW / img.naturalWidth)
|
||||||
|
const offscreen = document.createElement('canvas')
|
||||||
|
offscreen.width = Math.round(img.naturalWidth * ratio)
|
||||||
|
offscreen.height = Math.round(img.naturalHeight * ratio)
|
||||||
|
const ctx = offscreen.getContext('2d')
|
||||||
|
if (ctx) {
|
||||||
|
ctx.drawImage(img, 0, 0, offscreen.width, offscreen.height)
|
||||||
|
mapScreenshot = offscreen.toDataURL('image/jpeg', 0.85)
|
||||||
|
}
|
||||||
|
} catch { mapScreenshot = rawScreenshot }
|
||||||
|
} else {
|
||||||
|
mapScreenshot = rawScreenshot
|
||||||
|
}
|
||||||
|
// Convert logo URL to base64 for PDF rendering
|
||||||
|
let logoDataUri = ''
|
||||||
|
if (rapportForm.logoUrl) {
|
||||||
|
try {
|
||||||
|
const logoRes = await fetch(rapportForm.logoUrl)
|
||||||
|
if (logoRes.ok) {
|
||||||
|
const blob = await logoRes.blob()
|
||||||
|
logoDataUri = await new Promise<string>((resolve) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onloadend = () => resolve(reader.result as string)
|
||||||
|
reader.readAsDataURL(blob)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e) { console.warn('Logo fetch failed:', e) }
|
||||||
|
}
|
||||||
|
const rapportData = { ...rapportForm, mapScreenshot, logoUrl: logoDataUri || rapportForm.logoUrl }
|
||||||
|
console.log('[Rapport] Sending request, body size ~', JSON.stringify({ projectId, data: rapportData }).length, 'bytes')
|
||||||
|
const res = await fetch('/api/rapports', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ projectId, data: rapportData }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const result = await res.json()
|
||||||
|
onRapportCreated(`/rapport/${result.token}`)
|
||||||
|
onClose()
|
||||||
|
window.open(`/rapport/${result.token}`, '_blank')
|
||||||
|
} else {
|
||||||
|
const errData = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
|
||||||
|
console.error('[Rapport] API error:', res.status, errData)
|
||||||
|
alert(`Rapport-Fehler: ${errData.error || 'Unbekannter Fehler (Status ' + res.status + ')'}`)
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error creating rapport:', err)
|
||||||
|
alert('Rapport-Fehler: ' + (err?.message || 'Netzwerkfehler'))
|
||||||
|
} finally {
|
||||||
|
setCreatingRapport(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/50 flex items-start justify-center overflow-auto py-8 print:hidden">
|
||||||
|
<div className="bg-card rounded-lg shadow-2xl w-full max-w-2xl mx-4">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b">
|
||||||
|
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||||
|
<FileText className="w-5 h-5" />
|
||||||
|
Einsatzrapport erstellen
|
||||||
|
</h3>
|
||||||
|
<button onClick={onClose} className="text-muted-foreground hover:text-foreground text-xl leading-none">×</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-4 max-h-[70vh] overflow-auto">
|
||||||
|
{/* Organisation */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-muted-foreground uppercase">Organisation</label>
|
||||||
|
<Input value={rapportForm.organisation || ''} onChange={e => setRapportForm(f => ({ ...f, organisation: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-muted-foreground uppercase">Abteilung</label>
|
||||||
|
<Input value={rapportForm.abteilung || ''} onChange={e => setRapportForm(f => ({ ...f, abteilung: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Einsatzdaten */}
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-muted-foreground uppercase">Datum</label>
|
||||||
|
<Input value={rapportForm.datum || ''} onChange={e => setRapportForm(f => ({ ...f, datum: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-muted-foreground uppercase">Uhrzeit</label>
|
||||||
|
<Input value={rapportForm.uhrzeit || ''} onChange={e => setRapportForm(f => ({ ...f, uhrzeit: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-muted-foreground uppercase">Einsatz-Nr.</label>
|
||||||
|
<Input value={rapportForm.einsatzNr || ''} onChange={e => setRapportForm(f => ({ ...f, einsatzNr: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-muted-foreground uppercase">Priorität</label>
|
||||||
|
<Input value={rapportForm.prioritaet || ''} onChange={e => setRapportForm(f => ({ ...f, prioritaet: e.target.value }))} placeholder="z.B. Hoch" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Ort */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-muted-foreground uppercase">Einsatzort / Adresse</label>
|
||||||
|
<Input value={rapportForm.einsatzort || ''} onChange={e => setRapportForm(f => ({ ...f, einsatzort: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-muted-foreground uppercase">Objekt / Gebäude</label>
|
||||||
|
<Input value={rapportForm.objekt || ''} onChange={e => setRapportForm(f => ({ ...f, objekt: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-muted-foreground uppercase">Alarmierungsart</label>
|
||||||
|
<Input value={rapportForm.alarmierungsart || ''} onChange={e => setRapportForm(f => ({ ...f, alarmierungsart: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-muted-foreground uppercase">Stichwort / Meldebild</label>
|
||||||
|
<Input value={rapportForm.stichwort || ''} onChange={e => setRapportForm(f => ({ ...f, stichwort: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
{/* Zeitverlauf */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-muted-foreground uppercase mb-1 block">Zeitverlauf</label>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-muted-foreground">Alarm</label>
|
||||||
|
<Input type="time" className="text-sm h-8" value={rapportForm.zeitAlarm || ''} onChange={e => setRapportForm(f => ({ ...f, zeitAlarm: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-muted-foreground">Eintreffen</label>
|
||||||
|
<Input type="time" className="text-sm h-8" value={rapportForm.zeitEintreffen || ''} onChange={e => setRapportForm(f => ({ ...f, zeitEintreffen: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Lagebild */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-muted-foreground uppercase">Lage bei Eintreffen</label>
|
||||||
|
<textarea className="w-full border rounded-md px-3 py-2 text-sm min-h-[60px] resize-y" value={rapportForm.lageEintreffen || ''} onChange={e => setRapportForm(f => ({ ...f, lageEintreffen: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
{/* Massnahmen (read-only, from journal) */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-muted-foreground uppercase">Massnahmen (aus Journal)</label>
|
||||||
|
<div className="border rounded-md p-2 bg-muted text-sm max-h-32 overflow-auto">
|
||||||
|
{Array.isArray(rapportForm.massnahmen) && rapportForm.massnahmen.length > 0 ? (
|
||||||
|
rapportForm.massnahmen.map((m: string, i: number) => <div key={i} className="py-0.5">• {m}</div>)
|
||||||
|
) : <span className="text-muted-foreground">Keine Einträge</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Bemerkungen */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-muted-foreground uppercase">Bemerkungen</label>
|
||||||
|
<textarea className="w-full border rounded-md px-3 py-2 text-sm min-h-[60px] resize-y" value={rapportForm.bemerkungen || ''} onChange={e => setRapportForm(f => ({ ...f, bemerkungen: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
{/* Unterschriften */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-muted-foreground uppercase">Einsatzleiter/in</label>
|
||||||
|
<Input value={rapportForm.einsatzleiter || ''} onChange={e => setRapportForm(f => ({ ...f, einsatzleiter: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-muted-foreground uppercase">Rapporteur</label>
|
||||||
|
<Input value={rapportForm.rapporteur || ''} onChange={e => setRapportForm(f => ({ ...f, rapporteur: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-3 p-4 border-t">
|
||||||
|
<Button variant="outline" size="sm" onClick={onClose}>Abbrechen</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={creatingRapport}
|
||||||
|
onClick={handleCreate}
|
||||||
|
>
|
||||||
|
{creatingRapport ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <FileText className="w-4 h-4 mr-1.5" />}
|
||||||
|
Rapport generieren
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -126,9 +126,26 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
|
|||||||
// Separate tenant-specific icons ("Eigene" category) from global library
|
// Separate tenant-specific icons ("Eigene" category) from global library
|
||||||
const eigene = allCats.find(c => c.name === 'Eigene')
|
const eigene = allCats.find(c => c.name === 'Eigene')
|
||||||
const globalCats = allCats.filter(c => c.name !== 'Eigene')
|
const globalCats = allCats.filter(c => c.name !== 'Eigene')
|
||||||
setTenantIcons(eigene?.symbols || [])
|
|
||||||
|
// Merge: mySymbols (custom collection) + legacy "Eigene" category uploads
|
||||||
|
const mySymbols: DisplaySymbol[] = (data.mySymbols || []).map((s: any) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
imageUrl: s.url || `/api/icons/${s.id}/image`,
|
||||||
|
}))
|
||||||
|
const legacyOwn = eigene?.symbols || []
|
||||||
|
// Deduplicate: mySymbols takes priority over legacy
|
||||||
|
const mySymbolIds = new Set(mySymbols.map(s => s.id))
|
||||||
|
const mergedTenant = [...mySymbols, ...legacyOwn.filter(s => !mySymbolIds.has(s.id))]
|
||||||
|
|
||||||
|
setTenantIcons(mergedTenant)
|
||||||
setCategories(globalCats)
|
setCategories(globalCats)
|
||||||
if (globalCats.length > 0) setActiveCategory(globalCats[0].id)
|
if (globalCats.length > 0) setActiveCategory(globalCats[0].id)
|
||||||
|
|
||||||
|
// Auto-collapse library if tenant has own symbols
|
||||||
|
if (mergedTenant.length > 0) {
|
||||||
|
setShowLibrarySection(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load icons:', err)
|
console.error('Failed to load icons:', err)
|
||||||
|
|||||||
@@ -1472,21 +1472,27 @@ export function MapView({
|
|||||||
midpoint = [cx / len, cy / len]
|
midpoint = [cx / len, cy / len]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply stored label offset if present
|
||||||
|
const labelOffset = f.properties.labelOffset as [number, number] | undefined
|
||||||
|
if (labelOffset) {
|
||||||
|
midpoint = [midpoint[0] + labelOffset[0], midpoint[1] + labelOffset[1]]
|
||||||
|
}
|
||||||
|
|
||||||
const el = document.createElement('div')
|
const el = document.createElement('div')
|
||||||
const isDanger = f.type === 'dangerzone'
|
const isDanger = f.type === 'dangerzone'
|
||||||
el.style.cssText = `
|
el.style.cssText = `
|
||||||
background: ${isDanger ? 'rgba(220,38,38,0.85)' : 'rgba(0,0,0,0.75)'};
|
background: ${isDanger ? 'rgba(220,38,38,0.85)' : 'rgba(0,0,0,0.82)'};
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: 1px 5px;
|
padding: 3px 8px;
|
||||||
border-radius: 3px;
|
border-radius: 4px;
|
||||||
font-size: 11px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
pointer-events: ${canEdit ? 'auto' : 'none'};
|
pointer-events: ${canEdit ? 'auto' : 'none'};
|
||||||
letter-spacing: 0.3px;
|
letter-spacing: 0.3px;
|
||||||
border: 1px solid ${isDanger ? '#dc2626' : 'rgba(255,255,255,0.4)'};
|
border: 1px solid ${isDanger ? '#dc2626' : 'rgba(255,255,255,0.4)'};
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.25);
|
box-shadow: 0 1px 4px rgba(0,0,0,0.3);
|
||||||
cursor: ${canEdit ? 'pointer' : 'default'};
|
cursor: ${canEdit ? 'grab' : 'default'};
|
||||||
transform: translate(0,0);
|
transform: translate(0,0);
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
`
|
`
|
||||||
@@ -1503,11 +1509,11 @@ export function MapView({
|
|||||||
|
|
||||||
const labelLine = document.createElement('div')
|
const labelLine = document.createElement('div')
|
||||||
labelLine.textContent = label
|
labelLine.textContent = label
|
||||||
labelLine.style.cssText = 'font-size:11px;font-weight:600;line-height:1.2;'
|
labelLine.style.cssText = 'font-size:13px;font-weight:700;line-height:1.3;'
|
||||||
|
|
||||||
const infoLine = document.createElement('div')
|
const infoLine = document.createElement('div')
|
||||||
infoLine.textContent = `${lenText} / ${hoseCount} Schl.`
|
infoLine.textContent = `${lenText} / ${hoseCount} Schl.`
|
||||||
infoLine.style.cssText = 'font-size:8px;opacity:0.8;line-height:1.2;font-weight:400;'
|
infoLine.style.cssText = 'font-size:10px;opacity:0.85;line-height:1.3;font-weight:500;'
|
||||||
|
|
||||||
el.appendChild(labelLine)
|
el.appendChild(labelLine)
|
||||||
el.appendChild(infoLine)
|
el.appendChild(infoLine)
|
||||||
@@ -1519,11 +1525,11 @@ export function MapView({
|
|||||||
|
|
||||||
const labelLine = document.createElement('div')
|
const labelLine = document.createElement('div')
|
||||||
labelLine.textContent = label
|
labelLine.textContent = label
|
||||||
labelLine.style.cssText = 'font-size:11px;font-weight:600;line-height:1.2;'
|
labelLine.style.cssText = 'font-size:13px;font-weight:700;line-height:1.3;'
|
||||||
|
|
||||||
const infoLine = document.createElement('div')
|
const infoLine = document.createElement('div')
|
||||||
infoLine.textContent = areaText
|
infoLine.textContent = areaText
|
||||||
infoLine.style.cssText = 'font-size:8px;opacity:0.8;line-height:1.2;font-weight:400;'
|
infoLine.style.cssText = 'font-size:10px;opacity:0.85;line-height:1.3;font-weight:500;'
|
||||||
|
|
||||||
el.appendChild(labelLine)
|
el.appendChild(labelLine)
|
||||||
el.appendChild(infoLine)
|
el.appendChild(infoLine)
|
||||||
@@ -1539,9 +1545,41 @@ export function MapView({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const marker = new maplibregl.Marker({ element: el, anchor: 'center', rotationAlignment: 'viewport' })
|
const marker = new maplibregl.Marker({ element: el, anchor: 'center', draggable: canEdit, rotationAlignment: 'viewport' })
|
||||||
.setLngLat(midpoint)
|
.setLngLat(midpoint)
|
||||||
.addTo(map.current)
|
.addTo(map.current)
|
||||||
|
|
||||||
|
// Save label position offset on drag end
|
||||||
|
if (canEdit) {
|
||||||
|
marker.on('dragend', () => {
|
||||||
|
const newPos = marker.getLngLat()
|
||||||
|
// Calculate midpoint without offset to get the base midpoint
|
||||||
|
let baseMid: [number, number]
|
||||||
|
const feat = featuresRef.current.find(feat => feat.id === f.id)
|
||||||
|
if (!feat) return
|
||||||
|
if (feat.geometry.type === 'LineString') {
|
||||||
|
const coords = feat.geometry.coordinates as number[][]
|
||||||
|
const midIdx = Math.floor(coords.length / 2)
|
||||||
|
if (coords.length === 2) {
|
||||||
|
baseMid = [(coords[0][0] + coords[1][0]) / 2, (coords[0][1] + coords[1][1]) / 2]
|
||||||
|
} else {
|
||||||
|
baseMid = coords[midIdx] as [number, number]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const ring = (feat.geometry.coordinates as number[][][])[0]
|
||||||
|
const len = ring.length - 1
|
||||||
|
let cx = 0, cy = 0
|
||||||
|
for (let i = 0; i < len; i++) { cx += ring[i][0]; cy += ring[i][1] }
|
||||||
|
baseMid = [cx / len, cy / len]
|
||||||
|
}
|
||||||
|
const offset: [number, number] = [newPos.lng - baseMid[0], newPos.lat - baseMid[1]]
|
||||||
|
const updated = featuresRef.current.map(pf =>
|
||||||
|
pf.id === f.id ? { ...pf, properties: { ...pf.properties, labelOffset: offset } } : pf
|
||||||
|
)
|
||||||
|
onFeaturesChangeRef.current(updated)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
markersRef.current.push(marker)
|
markersRef.current.push(marker)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
101
src/hooks/use-auto-save.ts
Normal file
101
src/hooks/use-auto-save.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
|
import type { DrawFeature, Project } from '@/types'
|
||||||
|
import { addToSyncQueue, getSyncQueue } from '@/lib/offline-sync'
|
||||||
|
|
||||||
|
interface UseAutoSaveOptions {
|
||||||
|
currentProject: Project | null
|
||||||
|
features: DrawFeature[]
|
||||||
|
featuresRef: React.MutableRefObject<DrawFeature[]>
|
||||||
|
mapRef: React.MutableRefObject<any>
|
||||||
|
socketRef: React.MutableRefObject<any>
|
||||||
|
isEditingByMe: boolean
|
||||||
|
setSyncQueueCount: (count: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAutoSave({
|
||||||
|
currentProject,
|
||||||
|
features,
|
||||||
|
featuresRef,
|
||||||
|
mapRef,
|
||||||
|
socketRef,
|
||||||
|
isEditingByMe,
|
||||||
|
setSyncQueueCount,
|
||||||
|
}: UseAutoSaveOptions) {
|
||||||
|
// Persist features to localStorage on change (including empty array to reflect deletions)
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('lageplan-features', JSON.stringify(features))
|
||||||
|
}, [features])
|
||||||
|
|
||||||
|
// Auto-save to API — debounced 2s after every feature change + fallback interval
|
||||||
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const saveFeaturesToApi = useCallback(async () => {
|
||||||
|
if (!currentProject?.id) return
|
||||||
|
const url = `/api/projects/${currentProject.id}/features`
|
||||||
|
const mapInstance = mapRef.current
|
||||||
|
const body: any = { features: featuresRef.current }
|
||||||
|
if (mapInstance) {
|
||||||
|
const c = mapInstance.getCenter()
|
||||||
|
body.mapCenter = { lng: c.lng, lat: c.lat }
|
||||||
|
body.mapZoom = mapInstance.getZoom()
|
||||||
|
}
|
||||||
|
|
||||||
|
// If offline, queue the save for later sync
|
||||||
|
if (!navigator.onLine) {
|
||||||
|
addToSyncQueue(url, 'PUT', body)
|
||||||
|
setSyncQueueCount(getSyncQueue().length)
|
||||||
|
console.log('[Auto-Save] Offline — in Sync-Queue gespeichert')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
console.log('[Auto-Save] Features gespeichert')
|
||||||
|
socketRef.current?.emit('features-updated', {
|
||||||
|
projectId: currentProject.id,
|
||||||
|
features: featuresRef.current,
|
||||||
|
})
|
||||||
|
} else if (res.status === 404) {
|
||||||
|
console.warn('[Auto-Save] Projekt nicht in DB')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Network error — queue for later
|
||||||
|
addToSyncQueue(url, 'PUT', body)
|
||||||
|
setSyncQueueCount(getSyncQueue().length)
|
||||||
|
console.warn('[Auto-Save] Netzwerkfehler — in Sync-Queue:', e)
|
||||||
|
}
|
||||||
|
}, [currentProject, mapRef, featuresRef, socketRef, setSyncQueueCount])
|
||||||
|
|
||||||
|
// Debounced save on every feature change (2s delay)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentProject || !isEditingByMe) return
|
||||||
|
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
|
||||||
|
saveTimerRef.current = setTimeout(() => saveFeaturesToApi(), 2000)
|
||||||
|
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) }
|
||||||
|
}, [features, currentProject, isEditingByMe, saveFeaturesToApi])
|
||||||
|
|
||||||
|
// Also save on page unload / tab switch
|
||||||
|
useEffect(() => {
|
||||||
|
const handleBeforeUnload = () => {
|
||||||
|
if (currentProject?.id && featuresRef.current.length > 0) {
|
||||||
|
const payload = JSON.stringify({ features: featuresRef.current })
|
||||||
|
navigator.sendBeacon(`/api/projects/${currentProject.id}/features`, new Blob([payload], { type: 'application/json' }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === 'hidden' && currentProject?.id && isEditingByMe) {
|
||||||
|
saveFeaturesToApi()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
}
|
||||||
|
}, [currentProject, isEditingByMe, saveFeaturesToApi, featuresRef])
|
||||||
|
}
|
||||||
74
src/hooks/use-keyboard-shortcuts.ts
Normal file
74
src/hooks/use-keyboard-shortcuts.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { useEffect, useCallback } from 'react'
|
||||||
|
import type { DrawMode, DrawFeature } from '@/types'
|
||||||
|
|
||||||
|
interface UseKeyboardShortcutsOptions {
|
||||||
|
featuresRef: React.MutableRefObject<DrawFeature[]>
|
||||||
|
onUndo: () => void
|
||||||
|
onRedo: () => void
|
||||||
|
onSave: () => void
|
||||||
|
onDelete: (newFeatures: DrawFeature[]) => void
|
||||||
|
onToolChange: (mode: DrawMode) => void
|
||||||
|
onHelpOpen: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOOL_SHORTCUTS: Record<string, DrawMode> = {
|
||||||
|
'v': 'select', 's': 'select',
|
||||||
|
'p': 'point',
|
||||||
|
'l': 'linestring',
|
||||||
|
'g': 'polygon',
|
||||||
|
'r': 'rectangle',
|
||||||
|
'c': 'circle',
|
||||||
|
'f': 'freehand',
|
||||||
|
'a': 'arrow',
|
||||||
|
't': 'text',
|
||||||
|
'e': 'eraser',
|
||||||
|
'm': 'measure',
|
||||||
|
'd': 'dangerzone',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useKeyboardShortcuts({
|
||||||
|
featuresRef,
|
||||||
|
onUndo,
|
||||||
|
onRedo,
|
||||||
|
onSave,
|
||||||
|
onDelete,
|
||||||
|
onToolChange,
|
||||||
|
onHelpOpen,
|
||||||
|
}: UseKeyboardShortcutsOptions) {
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Ignore when typing in inputs/textareas
|
||||||
|
const tag = (e.target as HTMLElement)?.tagName
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (e.target as HTMLElement)?.isContentEditable) return
|
||||||
|
|
||||||
|
// ? or F1 → help
|
||||||
|
if (e.key === '?' || e.key === 'F1') { e.preventDefault(); onHelpOpen(); return }
|
||||||
|
|
||||||
|
// DEL / Backspace → delete selected feature(s)
|
||||||
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||||
|
e.preventDefault()
|
||||||
|
const current = featuresRef.current
|
||||||
|
const selected = current.filter(f => f.properties?._selected)
|
||||||
|
if (selected.length > 0) {
|
||||||
|
onDelete(current.filter(f => !f.properties?._selected))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd shortcuts
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
if (e.key === 'z' && e.shiftKey) { e.preventDefault(); onRedo(); return }
|
||||||
|
if (e.key === 'z') { e.preventDefault(); onUndo(); return }
|
||||||
|
if (e.key === 'y') { e.preventDefault(); onRedo(); return }
|
||||||
|
if (e.key === 's') { e.preventDefault(); onSave(); return }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool shortcuts (single key, no modifier)
|
||||||
|
const mode = TOOL_SHORTCUTS[e.key.toLowerCase()]
|
||||||
|
if (mode) { e.preventDefault(); onToolChange(mode); return }
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [featuresRef, onUndo, onRedo, onSave, onDelete, onToolChange, onHelpOpen])
|
||||||
|
}
|
||||||
329
src/hooks/use-map-export.ts
Normal file
329
src/hooks/use-map-export.ts
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
import { useCallback } from 'react'
|
||||||
|
import { jsPDF } from 'jspdf'
|
||||||
|
import type { DrawFeature, Project } from '@/types'
|
||||||
|
|
||||||
|
interface UseMapExportOptions {
|
||||||
|
mapRef: React.MutableRefObject<any>
|
||||||
|
featuresRef: React.MutableRefObject<DrawFeature[]>
|
||||||
|
currentProject: Project | null
|
||||||
|
tenant: { id: string; name: string } | null
|
||||||
|
addAudit: (action: string) => void
|
||||||
|
toast: (opts: { title: string; description?: string; variant?: string }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMapExport({
|
||||||
|
mapRef,
|
||||||
|
featuresRef,
|
||||||
|
currentProject,
|
||||||
|
tenant,
|
||||||
|
addAudit,
|
||||||
|
toast,
|
||||||
|
}: UseMapExportOptions) {
|
||||||
|
const handleExport = useCallback(async (format: 'png' | 'pdf') => {
|
||||||
|
const mapInstance = mapRef.current
|
||||||
|
if (!mapInstance) {
|
||||||
|
toast({ title: 'Fehler', description: 'Karte nicht bereit.', variant: 'destructive' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Get the MapLibre canvas (tiles + vector drawings)
|
||||||
|
const mapCanvas = mapInstance.getCanvas() as HTMLCanvasElement
|
||||||
|
const w = mapCanvas.width
|
||||||
|
const h = mapCanvas.height
|
||||||
|
|
||||||
|
// 2. Create composite canvas
|
||||||
|
const exportCanvas = document.createElement('canvas')
|
||||||
|
exportCanvas.width = w
|
||||||
|
exportCanvas.height = h
|
||||||
|
const ctx = exportCanvas.getContext('2d')!
|
||||||
|
ctx.drawImage(mapCanvas, 0, 0)
|
||||||
|
|
||||||
|
// 3. Draw symbols manually at correct size/rotation
|
||||||
|
const currentFeatures = featuresRef.current
|
||||||
|
// Derive actual pixel ratio from canvas vs container (more reliable than window.devicePixelRatio)
|
||||||
|
const container = mapInstance.getContainer()
|
||||||
|
const dpr = mapCanvas.width / container.offsetWidth
|
||||||
|
const zoom = mapInstance.getZoom()
|
||||||
|
// Symbol sizing: match the map rendering logic exactly
|
||||||
|
// In map-view.tsx: size = baseSize * scale * Math.pow(2, currentZoom - placementZoom)
|
||||||
|
const currentZoom = zoom
|
||||||
|
|
||||||
|
// Helper: load image as promise
|
||||||
|
const loadImage = (src: string): Promise<HTMLImageElement> => new Promise((resolve, reject) => {
|
||||||
|
const img = new Image()
|
||||||
|
img.crossOrigin = 'anonymous'
|
||||||
|
img.onload = () => resolve(img)
|
||||||
|
img.onerror = reject
|
||||||
|
img.src = src
|
||||||
|
})
|
||||||
|
|
||||||
|
// Draw symbol features
|
||||||
|
for (const f of currentFeatures.filter(f => f.type === 'symbol')) {
|
||||||
|
if (f.geometry.type !== 'Point') continue
|
||||||
|
const coords = f.geometry.coordinates as [number, number]
|
||||||
|
const pixel = mapInstance.project(coords)
|
||||||
|
const px = pixel.x * dpr
|
||||||
|
const py = pixel.y * dpr
|
||||||
|
|
||||||
|
const scale = (f.properties.scale as number) || 1
|
||||||
|
const rotation = (f.properties.rotation as number) || 0
|
||||||
|
const baseSize = 32
|
||||||
|
const placementZoom = (f.properties.placementZoom as number) || 17
|
||||||
|
const zoomFactor = Math.pow(2, currentZoom - placementZoom)
|
||||||
|
const size = Math.max(8, Math.min(400, baseSize * scale * zoomFactor)) * dpr
|
||||||
|
|
||||||
|
// Determine image source
|
||||||
|
const iconId = f.properties.iconId as string
|
||||||
|
const imageUrl = f.properties.imageUrl as string
|
||||||
|
let imgSrc = imageUrl || ''
|
||||||
|
if (!imgSrc && iconId) {
|
||||||
|
const { getSymbolById, getSymbolDataUri } = await import('@/lib/fw-symbols')
|
||||||
|
const sym = getSymbolById(iconId)
|
||||||
|
if (sym) imgSrc = getSymbolDataUri(sym)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imgSrc) {
|
||||||
|
try {
|
||||||
|
const img = await loadImage(imgSrc)
|
||||||
|
// Replicate CSS background-size: contain (preserve aspect ratio)
|
||||||
|
const imgAspect = img.naturalWidth / img.naturalHeight
|
||||||
|
let drawW = size
|
||||||
|
let drawH = size
|
||||||
|
if (imgAspect > 1) {
|
||||||
|
drawH = size / imgAspect
|
||||||
|
} else {
|
||||||
|
drawW = size * imgAspect
|
||||||
|
}
|
||||||
|
ctx.save()
|
||||||
|
ctx.translate(px, py)
|
||||||
|
ctx.rotate((rotation * Math.PI) / 180)
|
||||||
|
ctx.drawImage(img, -drawW / 2, -drawH / 2, drawW, drawH)
|
||||||
|
ctx.restore()
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Export] Failed to load symbol image:', iconId, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw arrowheads for arrow features
|
||||||
|
for (const f of currentFeatures.filter(f => f.type === 'arrow')) {
|
||||||
|
if (f.geometry.type !== 'LineString') continue
|
||||||
|
const lineCoords = f.geometry.coordinates as number[][]
|
||||||
|
if (lineCoords.length < 2) continue
|
||||||
|
const p1 = lineCoords[lineCoords.length - 2]
|
||||||
|
const p2 = lineCoords[lineCoords.length - 1]
|
||||||
|
const px1 = mapInstance.project(p1 as [number, number])
|
||||||
|
const px2 = mapInstance.project(p2 as [number, number])
|
||||||
|
const angle = Math.atan2(px2.y - px1.y, px2.x - px1.x)
|
||||||
|
const color = (f.properties.color as string) || '#000000'
|
||||||
|
const arrowSize = 14 * dpr
|
||||||
|
|
||||||
|
ctx.save()
|
||||||
|
ctx.translate(px2.x * dpr, px2.y * dpr)
|
||||||
|
ctx.rotate(angle + Math.PI / 2)
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(0, -arrowSize)
|
||||||
|
ctx.lineTo(-arrowSize * 0.7, arrowSize * 0.3)
|
||||||
|
ctx.lineTo(arrowSize * 0.7, arrowSize * 0.3)
|
||||||
|
ctx.closePath()
|
||||||
|
ctx.fillStyle = color
|
||||||
|
ctx.fill()
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw line/polygon label markers at midpoints
|
||||||
|
for (const f of currentFeatures.filter(f => f.properties.label && (f.geometry.type === 'LineString' || f.geometry.type === 'Polygon'))) {
|
||||||
|
const label = f.properties.label as string
|
||||||
|
let midpoint: [number, number]
|
||||||
|
|
||||||
|
if (f.geometry.type === 'LineString') {
|
||||||
|
const coords = f.geometry.coordinates as number[][]
|
||||||
|
const midIdx = Math.floor(coords.length / 2)
|
||||||
|
if (coords.length === 2) {
|
||||||
|
midpoint = [(coords[0][0] + coords[1][0]) / 2, (coords[0][1] + coords[1][1]) / 2]
|
||||||
|
} else {
|
||||||
|
midpoint = coords[midIdx] as [number, number]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Polygon: centroid of first ring
|
||||||
|
const ring = (f.geometry.coordinates as number[][][])[0]
|
||||||
|
const len = ring.length - 1
|
||||||
|
let cx = 0, cy = 0
|
||||||
|
for (let i = 0; i < len; i++) { cx += ring[i][0]; cy += ring[i][1] }
|
||||||
|
midpoint = [cx / len, cy / len]
|
||||||
|
}
|
||||||
|
|
||||||
|
const pixel = mapInstance.project(midpoint)
|
||||||
|
const px = pixel.x * dpr
|
||||||
|
const py = pixel.y * dpr
|
||||||
|
const fontSize = 13 * dpr
|
||||||
|
const isDanger = f.type === 'dangerzone'
|
||||||
|
const bgColor = isDanger ? 'rgba(220,38,38,0.85)' : 'rgba(0,0,0,0.75)'
|
||||||
|
const borderColor = isDanger ? '#dc2626' : 'rgba(255,255,255,0.5)'
|
||||||
|
|
||||||
|
ctx.save()
|
||||||
|
ctx.font = `bold ${fontSize}px system-ui, sans-serif`
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.textBaseline = 'middle'
|
||||||
|
const metrics = ctx.measureText(label)
|
||||||
|
const padX = 7 * dpr
|
||||||
|
const padY = 3 * dpr
|
||||||
|
const boxW = metrics.width + padX * 2
|
||||||
|
const boxH = fontSize + padY * 2
|
||||||
|
const radius = 4 * dpr
|
||||||
|
|
||||||
|
// Background pill
|
||||||
|
ctx.fillStyle = bgColor
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.roundRect(px - boxW / 2, py - boxH / 2, boxW, boxH, radius)
|
||||||
|
ctx.fill()
|
||||||
|
|
||||||
|
// Border
|
||||||
|
ctx.strokeStyle = borderColor
|
||||||
|
ctx.lineWidth = 1.5 * dpr
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.roundRect(px - boxW / 2, py - boxH / 2, boxW, boxH, radius)
|
||||||
|
ctx.stroke()
|
||||||
|
|
||||||
|
// Text
|
||||||
|
ctx.fillStyle = '#ffffff'
|
||||||
|
ctx.fillText(label, px, py)
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw text features
|
||||||
|
for (const f of currentFeatures.filter(f => f.type === 'text')) {
|
||||||
|
if (f.geometry.type !== 'Point') continue
|
||||||
|
const coords = f.geometry.coordinates as [number, number]
|
||||||
|
const pixel = mapInstance.project(coords)
|
||||||
|
const px = pixel.x * dpr
|
||||||
|
const py = pixel.y * dpr
|
||||||
|
|
||||||
|
const text = (f.properties.text as string) || ''
|
||||||
|
const fontSize = ((f.properties.fontSize as number) || 14) * dpr
|
||||||
|
const color = (f.properties.color as string) || '#000000'
|
||||||
|
|
||||||
|
ctx.save()
|
||||||
|
ctx.font = `bold ${fontSize}px system-ui, sans-serif`
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.textBaseline = 'middle'
|
||||||
|
// White outline
|
||||||
|
ctx.strokeStyle = '#ffffff'
|
||||||
|
ctx.lineWidth = 3 * dpr
|
||||||
|
ctx.lineJoin = 'round'
|
||||||
|
ctx.strokeText(text, px, py)
|
||||||
|
// Fill
|
||||||
|
ctx.fillStyle = color
|
||||||
|
ctx.fillText(text, px, py)
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = currentProject?.title || 'Lageplan'
|
||||||
|
const safeName = title.replace(/[^a-z0-9äöüÄÖÜß]/gi, '_')
|
||||||
|
|
||||||
|
if (format === 'png') {
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.download = `${safeName}.png`
|
||||||
|
link.href = exportCanvas.toDataURL('image/png')
|
||||||
|
link.click()
|
||||||
|
addAudit(`Export: ${safeName}.png`)
|
||||||
|
toast({ title: 'Exportiert', description: `${safeName}.png wurde heruntergeladen.` })
|
||||||
|
} else {
|
||||||
|
// PDF Export — rapport-style clean layout
|
||||||
|
const imgData = exportCanvas.toDataURL('image/png')
|
||||||
|
const now = new Date()
|
||||||
|
const dateStr = now.toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
|
const timeStr = now.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
const locationStr = currentProject?.location || ''
|
||||||
|
const einsatzNr = (currentProject as any)?.einsatzNr || ''
|
||||||
|
const tenantLabel = tenant?.name || ''
|
||||||
|
|
||||||
|
// A4 landscape (mm)
|
||||||
|
const pdf = new jsPDF('l', 'mm', 'a4')
|
||||||
|
const pageW = pdf.internal.pageSize.getWidth() // 297
|
||||||
|
const pageH = pdf.internal.pageSize.getHeight() // 210
|
||||||
|
const m = 10 // margin
|
||||||
|
|
||||||
|
// ── Header section ──
|
||||||
|
const headerY = m
|
||||||
|
pdf.setFontSize(18)
|
||||||
|
pdf.setFont('helvetica', 'bold')
|
||||||
|
pdf.setTextColor(26, 26, 26)
|
||||||
|
pdf.text('Einsatz-Lageplan', m, headerY + 6)
|
||||||
|
|
||||||
|
pdf.setFontSize(9)
|
||||||
|
pdf.setFont('helvetica', 'normal')
|
||||||
|
pdf.setTextColor(107, 114, 128) // gray-500
|
||||||
|
pdf.text(`${tenantLabel}${tenantLabel ? ' · ' : ''}${title}`, m, headerY + 12)
|
||||||
|
|
||||||
|
// Right side: Einsatz-Nr + date
|
||||||
|
pdf.setFontSize(14)
|
||||||
|
pdf.setFont('helvetica', 'bold')
|
||||||
|
pdf.setTextColor(185, 28, 28) // red-700
|
||||||
|
if (einsatzNr) {
|
||||||
|
const nrW = pdf.getTextWidth(einsatzNr)
|
||||||
|
pdf.text(einsatzNr, pageW - m - nrW, headerY + 6)
|
||||||
|
}
|
||||||
|
pdf.setFontSize(9)
|
||||||
|
pdf.setFont('helvetica', 'normal')
|
||||||
|
pdf.setTextColor(107, 114, 128)
|
||||||
|
const dateLabel = `${dateStr} · ${timeStr}`
|
||||||
|
const dlW = pdf.getTextWidth(dateLabel)
|
||||||
|
pdf.text(dateLabel, pageW - m - dlW, headerY + 12)
|
||||||
|
|
||||||
|
// Divider line + red accent
|
||||||
|
const divY = headerY + 15
|
||||||
|
pdf.setDrawColor(26, 26, 26)
|
||||||
|
pdf.setLineWidth(0.8)
|
||||||
|
pdf.line(m, divY, pageW - m, divY)
|
||||||
|
pdf.setFillColor(185, 28, 28)
|
||||||
|
pdf.rect(m, divY, (pageW - 2 * m) * 0.3, 1, 'F')
|
||||||
|
|
||||||
|
// ── Map image ──
|
||||||
|
const mapTop = divY + 3
|
||||||
|
const mapBottom = pageH - m - 12 // leave space for footer
|
||||||
|
const mapAreaW = pageW - 2 * m
|
||||||
|
const mapAreaH = mapBottom - mapTop
|
||||||
|
|
||||||
|
// Fit map image into area while preserving aspect ratio
|
||||||
|
const imgAspect = w / h
|
||||||
|
const areaAspect = mapAreaW / mapAreaH
|
||||||
|
let drawW = mapAreaW
|
||||||
|
let drawH = mapAreaH
|
||||||
|
if (imgAspect > areaAspect) {
|
||||||
|
drawH = mapAreaW / imgAspect
|
||||||
|
} else {
|
||||||
|
drawW = mapAreaH * imgAspect
|
||||||
|
}
|
||||||
|
const mapX = m + (mapAreaW - drawW) / 2
|
||||||
|
const mapY = mapTop + (mapAreaH - drawH) / 2
|
||||||
|
|
||||||
|
// Light border around map
|
||||||
|
pdf.setDrawColor(229, 231, 235)
|
||||||
|
pdf.setLineWidth(0.3)
|
||||||
|
pdf.rect(mapX, mapY, drawW, drawH)
|
||||||
|
pdf.addImage(imgData, 'PNG', mapX, mapY, drawW, drawH)
|
||||||
|
|
||||||
|
// ── Footer ──
|
||||||
|
const footerY = pageH - m - 4
|
||||||
|
pdf.setFontSize(7)
|
||||||
|
pdf.setFont('helvetica', 'normal')
|
||||||
|
pdf.setTextColor(156, 163, 175) // gray-400
|
||||||
|
pdf.text(`Erstellt: ${dateStr} ${timeStr}${locationStr ? ' · Standort: ' + locationStr : ''} · Projekt: ${title}`, m, footerY)
|
||||||
|
const footerR = 'app.lageplan.ch'
|
||||||
|
const frW = pdf.getTextWidth(footerR)
|
||||||
|
pdf.text(footerR, pageW - m - frW, footerY)
|
||||||
|
|
||||||
|
pdf.save(`${safeName}.pdf`)
|
||||||
|
addAudit(`Export: ${safeName}.pdf`)
|
||||||
|
toast({ title: 'Exportiert', description: `${safeName}.pdf wurde heruntergeladen.` })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Export error:', error)
|
||||||
|
toast({ title: 'Fehler', description: 'Export fehlgeschlagen.', variant: 'destructive' })
|
||||||
|
}
|
||||||
|
}, [currentProject, tenant, toast, addAudit, mapRef, featuresRef])
|
||||||
|
|
||||||
|
return { handleExport }
|
||||||
|
}
|
||||||
57
src/hooks/use-offline-sync.ts
Normal file
57
src/hooks/use-offline-sync.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { flushSyncQueue, getSyncQueue, isOnline as checkOnline } from '@/lib/offline-sync'
|
||||||
|
|
||||||
|
interface UseOfflineSyncOptions {
|
||||||
|
toast: (opts: { title: string; description?: string; variant?: string }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOfflineSync({ toast }: UseOfflineSyncOptions) {
|
||||||
|
const [isOffline, setIsOffline] = useState(false)
|
||||||
|
const [syncQueueCount, setSyncQueueCount] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsOffline(!checkOnline())
|
||||||
|
setSyncQueueCount(getSyncQueue().length)
|
||||||
|
|
||||||
|
const goOffline = () => {
|
||||||
|
setIsOffline(true)
|
||||||
|
toast({ title: 'Offline-Modus', description: 'Änderungen werden lokal gespeichert und beim Reconnect synchronisiert.' })
|
||||||
|
}
|
||||||
|
const goOnline = async () => {
|
||||||
|
setIsOffline(false)
|
||||||
|
const queue = getSyncQueue()
|
||||||
|
if (queue.length > 0) {
|
||||||
|
toast({ title: 'Verbindung wiederhergestellt', description: `${queue.length} Änderung(en) werden synchronisiert...` })
|
||||||
|
const result = await flushSyncQueue()
|
||||||
|
setSyncQueueCount(getSyncQueue().length)
|
||||||
|
if (result.success > 0) {
|
||||||
|
toast({ title: 'Synchronisiert', description: `${result.success} Änderung(en) erfolgreich gespeichert.` })
|
||||||
|
}
|
||||||
|
if (result.failed > 0) {
|
||||||
|
toast({ title: 'Sync-Fehler', description: `${result.failed} Änderung(en) konnten nicht gespeichert werden.`, variant: 'destructive' })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast({ title: 'Wieder online' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('offline', goOffline)
|
||||||
|
window.addEventListener('online', goOnline)
|
||||||
|
|
||||||
|
// Listen for SW sync messages
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.addEventListener('message', (event) => {
|
||||||
|
if (event.data?.type === 'FLUSH_SYNC_QUEUE') {
|
||||||
|
flushSyncQueue().then(() => setSyncQueueCount(getSyncQueue().length))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('offline', goOffline)
|
||||||
|
window.removeEventListener('online', goOnline)
|
||||||
|
}
|
||||||
|
}, [toast])
|
||||||
|
|
||||||
|
return { isOffline, syncQueueCount, setSyncQueueCount }
|
||||||
|
}
|
||||||
268
src/hooks/use-realtime-sync.ts
Normal file
268
src/hooks/use-realtime-sync.ts
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
|
import { getSocket, setSocketRoom } from '@/lib/socket'
|
||||||
|
import type { DrawFeature, Project } from '@/types'
|
||||||
|
|
||||||
|
interface UseRealtimeSyncOptions {
|
||||||
|
currentProject: Project | null
|
||||||
|
user: { id: string; name: string; role: string } | null
|
||||||
|
featuresRef: React.MutableRefObject<DrawFeature[]>
|
||||||
|
setFeatures: (features: DrawFeature[] | ((prev: DrawFeature[]) => DrawFeature[])) => void
|
||||||
|
toast: (opts: { title: string; description?: string; variant?: string }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRealtimeSync({
|
||||||
|
currentProject,
|
||||||
|
user,
|
||||||
|
featuresRef,
|
||||||
|
setFeatures,
|
||||||
|
toast,
|
||||||
|
}: UseRealtimeSyncOptions) {
|
||||||
|
// Live editing lock state
|
||||||
|
const [editingBy, setEditingBy] = useState<{ id: string; name: string; since: string } | null>(null)
|
||||||
|
const [isEditingByMe, setIsEditingByMe] = useState(false)
|
||||||
|
const [editingLoading, setEditingLoading] = useState(false)
|
||||||
|
|
||||||
|
// Unique session ID per browser tab (survives re-renders, not page reload)
|
||||||
|
const sessionIdRef = useRef<string>('')
|
||||||
|
if (!sessionIdRef.current) {
|
||||||
|
sessionIdRef.current = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Editing Lock: Check status + Heartbeat + Polling ─────────
|
||||||
|
|
||||||
|
const checkEditingStatus = useCallback(async (projectId: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/projects/${projectId}/editing?sessionId=${sessionIdRef.current}`)
|
||||||
|
if (!res.ok) return
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.editing) {
|
||||||
|
setEditingBy(data.editingBy)
|
||||||
|
setIsEditingByMe(data.isMe)
|
||||||
|
} else {
|
||||||
|
setEditingBy(null)
|
||||||
|
setIsEditingByMe(false)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Editing] Status check failed:', e)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Check editing status when project changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentProject?.id) {
|
||||||
|
setEditingBy(null)
|
||||||
|
setIsEditingByMe(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
checkEditingStatus(currentProject.id)
|
||||||
|
}, [currentProject?.id, checkEditingStatus])
|
||||||
|
|
||||||
|
// Heartbeat: keep lock alive every 30s while I'm editing
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentProject?.id || !isEditingByMe) return
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
await fetch(`/api/projects/${currentProject.id}/editing`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'heartbeat', sessionId: sessionIdRef.current }),
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Heartbeat] Failed:', e)
|
||||||
|
}
|
||||||
|
}, 30000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [currentProject?.id, isEditingByMe])
|
||||||
|
|
||||||
|
// Socket.io: real-time sync for features, editing status, journal
|
||||||
|
const socketRef = useRef<any>(null)
|
||||||
|
const prevProjectIdRef = useRef<string | null>(null)
|
||||||
|
|
||||||
|
// Throttled socket broadcast for near-real-time sync
|
||||||
|
const lastEmitRef = useRef(0)
|
||||||
|
const emitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const currentProjectRef = useRef(currentProject)
|
||||||
|
useEffect(() => { currentProjectRef.current = currentProject }, [currentProject])
|
||||||
|
|
||||||
|
const isEditingByMeRef = useRef(false)
|
||||||
|
useEffect(() => { isEditingByMeRef.current = isEditingByMe }, [isEditingByMe])
|
||||||
|
|
||||||
|
const broadcastFeatures = useCallback((feats: DrawFeature[]) => {
|
||||||
|
const proj = currentProjectRef.current
|
||||||
|
if (!socketRef.current || !proj?.id || !isEditingByMeRef.current) return
|
||||||
|
const now = Date.now()
|
||||||
|
const emit = () => {
|
||||||
|
socketRef.current?.emit('features-updated', {
|
||||||
|
projectId: proj!.id,
|
||||||
|
features: feats,
|
||||||
|
})
|
||||||
|
lastEmitRef.current = Date.now()
|
||||||
|
}
|
||||||
|
// Throttle: emit at most every 800ms for snappier sync
|
||||||
|
if (now - lastEmitRef.current > 800) {
|
||||||
|
emit()
|
||||||
|
} else {
|
||||||
|
if (emitTimerRef.current) clearTimeout(emitTimerRef.current)
|
||||||
|
emitTimerRef.current = setTimeout(emit, 800 - (now - lastEmitRef.current))
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentProject?.id) return
|
||||||
|
|
||||||
|
const socket = getSocket()
|
||||||
|
socketRef.current = socket
|
||||||
|
|
||||||
|
// Leave old room, join new room
|
||||||
|
if (prevProjectIdRef.current && prevProjectIdRef.current !== currentProject.id) {
|
||||||
|
socket.emit('leave-project', prevProjectIdRef.current)
|
||||||
|
}
|
||||||
|
socket.emit('join-project', currentProject.id)
|
||||||
|
setSocketRoom(currentProject.id)
|
||||||
|
prevProjectIdRef.current = currentProject.id
|
||||||
|
|
||||||
|
// Listen for features changes from other clients (only apply if NOT the editor)
|
||||||
|
const onFeaturesChanged = (data: { features: any[] }) => {
|
||||||
|
// Skip if I'm the one editing — my local state is the source of truth
|
||||||
|
if (isEditingByMeRef.current) {
|
||||||
|
console.log('[Socket.io] Ignoring features-changed (I am the editor)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (data.features && Array.isArray(data.features)) {
|
||||||
|
console.log('[Socket.io] Features updated from another client')
|
||||||
|
setFeatures(data.features)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for editing status changes from other clients
|
||||||
|
const onEditingStatus = (data: { editing: boolean; editingBy: any; sessionId: string }) => {
|
||||||
|
if (data.sessionId === sessionIdRef.current) return // ignore own events
|
||||||
|
if (data.editing && data.editingBy) {
|
||||||
|
setEditingBy(data.editingBy)
|
||||||
|
setIsEditingByMe(false)
|
||||||
|
} else {
|
||||||
|
setEditingBy(null)
|
||||||
|
setIsEditingByMe(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for journal changes — trigger a re-fetch in JournalView
|
||||||
|
const onJournalChanged = () => {
|
||||||
|
console.log('[Socket.io] Journal updated from another client')
|
||||||
|
window.dispatchEvent(new CustomEvent('journal-refresh'))
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on('features-changed', onFeaturesChanged)
|
||||||
|
socket.on('editing-status', onEditingStatus)
|
||||||
|
socket.on('journal-changed', onJournalChanged)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off('features-changed', onFeaturesChanged)
|
||||||
|
socket.off('editing-status', onEditingStatus)
|
||||||
|
socket.off('journal-changed', onJournalChanged)
|
||||||
|
}
|
||||||
|
}, [currentProject?.id, setFeatures])
|
||||||
|
|
||||||
|
// Fallback: check editing status on initial load and every 30s
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentProject?.id) return
|
||||||
|
checkEditingStatus(currentProject.id)
|
||||||
|
const interval = setInterval(() => checkEditingStatus(currentProject.id), 30000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [currentProject?.id, checkEditingStatus])
|
||||||
|
|
||||||
|
// Release lock on unmount / page close
|
||||||
|
useEffect(() => {
|
||||||
|
const release = () => {
|
||||||
|
if (currentProject?.id && isEditingByMe) {
|
||||||
|
const blob = new Blob([JSON.stringify({ action: 'stop', sessionId: sessionIdRef.current })], { type: 'application/json' })
|
||||||
|
navigator.sendBeacon(`/api/projects/${currentProject.id}/editing`, blob)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('beforeunload', release)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('beforeunload', release)
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
}, [currentProject?.id, isEditingByMe])
|
||||||
|
|
||||||
|
const handleStartEditing = useCallback(async () => {
|
||||||
|
if (!currentProject?.id) return
|
||||||
|
setEditingLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/projects/${currentProject.id}/editing`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'start', sessionId: sessionIdRef.current }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
toast({ title: 'Gesperrt', description: data.error || 'Bearbeitung nicht möglich', variant: 'destructive' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setIsEditingByMe(true)
|
||||||
|
const editingInfo = { id: user!.id, name: user!.name, since: new Date().toISOString() }
|
||||||
|
setEditingBy(editingInfo)
|
||||||
|
// Notify other clients
|
||||||
|
socketRef.current?.emit('editing-changed', {
|
||||||
|
projectId: currentProject.id,
|
||||||
|
editing: true,
|
||||||
|
editingBy: editingInfo,
|
||||||
|
sessionId: sessionIdRef.current,
|
||||||
|
})
|
||||||
|
toast({ title: 'Bearbeitung gestartet', description: 'Sie können jetzt zeichnen und Einträge erstellen.' })
|
||||||
|
} catch (e) {
|
||||||
|
toast({ title: 'Fehler', description: 'Konnte Bearbeitung nicht starten.', variant: 'destructive' })
|
||||||
|
} finally {
|
||||||
|
setEditingLoading(false)
|
||||||
|
}
|
||||||
|
}, [currentProject?.id, user, toast])
|
||||||
|
|
||||||
|
const handleStopEditing = useCallback(async () => {
|
||||||
|
if (!currentProject?.id) return
|
||||||
|
setEditingLoading(true)
|
||||||
|
try {
|
||||||
|
// Save features before releasing lock
|
||||||
|
const currentFeatures = featuresRef.current
|
||||||
|
await fetch(`/api/projects/${currentProject.id}/features`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ features: currentFeatures }),
|
||||||
|
})
|
||||||
|
// Release lock
|
||||||
|
await fetch(`/api/projects/${currentProject.id}/editing`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'stop', sessionId: sessionIdRef.current }),
|
||||||
|
})
|
||||||
|
setIsEditingByMe(false)
|
||||||
|
setEditingBy(null)
|
||||||
|
// Notify other clients: editing stopped + send final features
|
||||||
|
socketRef.current?.emit('editing-changed', {
|
||||||
|
projectId: currentProject.id,
|
||||||
|
editing: false,
|
||||||
|
editingBy: null,
|
||||||
|
sessionId: sessionIdRef.current,
|
||||||
|
})
|
||||||
|
socketRef.current?.emit('features-updated', {
|
||||||
|
projectId: currentProject.id,
|
||||||
|
features: currentFeatures,
|
||||||
|
})
|
||||||
|
toast({ title: 'Bearbeitung beendet', description: 'Änderungen gespeichert. Andere können jetzt bearbeiten.' })
|
||||||
|
} catch (e) {
|
||||||
|
toast({ title: 'Fehler', description: 'Konnte Bearbeitung nicht beenden.', variant: 'destructive' })
|
||||||
|
} finally {
|
||||||
|
setEditingLoading(false)
|
||||||
|
}
|
||||||
|
}, [currentProject?.id, toast, featuresRef])
|
||||||
|
|
||||||
|
return {
|
||||||
|
editingBy,
|
||||||
|
isEditingByMe,
|
||||||
|
editingLoading,
|
||||||
|
socketRef,
|
||||||
|
broadcastFeatures,
|
||||||
|
handleStartEditing,
|
||||||
|
handleStopEditing,
|
||||||
|
}
|
||||||
|
}
|
||||||
83
src/lib/api.ts
Normal file
83
src/lib/api.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Standardized API fetch wrapper with consistent error handling.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const data = await apiFetch<{ projects: Project[] }>('/api/projects')
|
||||||
|
* const result = await apiFetch('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ ... }) })
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number
|
||||||
|
data: any
|
||||||
|
|
||||||
|
constructor(message: string, status: number, data?: any) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.status = status
|
||||||
|
this.data = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiFetchOptions extends RequestInit {
|
||||||
|
/** If true, don't throw on non-2xx responses — return null instead */
|
||||||
|
silent?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed fetch wrapper that:
|
||||||
|
* - Automatically sets Content-Type for JSON bodies
|
||||||
|
* - Parses JSON responses
|
||||||
|
* - Throws ApiError with status code and server error message on failure
|
||||||
|
* - Supports silent mode for optional/non-critical requests
|
||||||
|
*/
|
||||||
|
export async function apiFetch<T = any>(
|
||||||
|
url: string,
|
||||||
|
options: ApiFetchOptions = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const { silent, ...fetchOptions } = options
|
||||||
|
|
||||||
|
// Auto-set Content-Type for JSON string bodies
|
||||||
|
if (
|
||||||
|
fetchOptions.body &&
|
||||||
|
typeof fetchOptions.body === 'string' &&
|
||||||
|
!fetchOptions.headers
|
||||||
|
) {
|
||||||
|
fetchOptions.headers = { 'Content-Type': 'application/json' }
|
||||||
|
} else if (
|
||||||
|
fetchOptions.body &&
|
||||||
|
typeof fetchOptions.body === 'string' &&
|
||||||
|
fetchOptions.headers &&
|
||||||
|
!(fetchOptions.headers as Record<string, string>)['Content-Type']
|
||||||
|
) {
|
||||||
|
fetchOptions.headers = {
|
||||||
|
...fetchOptions.headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url, fetchOptions)
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
if (silent) return null as T
|
||||||
|
|
||||||
|
let errorData: any = null
|
||||||
|
let errorMessage = `HTTP ${res.status}`
|
||||||
|
try {
|
||||||
|
errorData = await res.json()
|
||||||
|
errorMessage = errorData?.error || errorData?.message || errorMessage
|
||||||
|
} catch {
|
||||||
|
// Response not JSON, use status text
|
||||||
|
errorMessage = res.statusText || errorMessage
|
||||||
|
}
|
||||||
|
throw new ApiError(errorMessage, res.status, errorData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 204 No Content
|
||||||
|
if (res.status === 204) return null as T
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await res.json()
|
||||||
|
} catch {
|
||||||
|
return null as T
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,31 @@ import { io, Socket } from 'socket.io-client'
|
|||||||
let socket: Socket | null = null
|
let socket: Socket | null = null
|
||||||
let currentRoom: string | null = null
|
let currentRoom: string | null = null
|
||||||
|
|
||||||
|
export type SocketStatus = 'connected' | 'disconnected' | 'reconnecting'
|
||||||
|
type StatusListener = (status: SocketStatus) => void
|
||||||
|
|
||||||
|
let currentStatus: SocketStatus = 'disconnected'
|
||||||
|
const statusListeners = new Set<StatusListener>()
|
||||||
|
|
||||||
|
function setStatus(status: SocketStatus) {
|
||||||
|
if (status === currentStatus) return
|
||||||
|
currentStatus = status
|
||||||
|
statusListeners.forEach(fn => fn(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to socket connection status changes. Returns an unsubscribe function. */
|
||||||
|
export function onSocketStatus(listener: StatusListener): () => void {
|
||||||
|
statusListeners.add(listener)
|
||||||
|
// Immediately notify current status
|
||||||
|
listener(currentStatus)
|
||||||
|
return () => { statusListeners.delete(listener) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get current socket connection status */
|
||||||
|
export function getSocketStatus(): SocketStatus {
|
||||||
|
return currentStatus
|
||||||
|
}
|
||||||
|
|
||||||
export function getSocket(): Socket {
|
export function getSocket(): Socket {
|
||||||
if (!socket) {
|
if (!socket) {
|
||||||
socket = io({
|
socket = io({
|
||||||
@@ -20,6 +45,7 @@ export function getSocket(): Socket {
|
|||||||
})
|
})
|
||||||
socket.on('connect', () => {
|
socket.on('connect', () => {
|
||||||
console.log('[Socket.io] Connected:', socket?.id)
|
console.log('[Socket.io] Connected:', socket?.id)
|
||||||
|
setStatus('connected')
|
||||||
// Re-join project room after reconnect
|
// Re-join project room after reconnect
|
||||||
if (currentRoom) {
|
if (currentRoom) {
|
||||||
console.log('[Socket.io] Re-joining room:', currentRoom)
|
console.log('[Socket.io] Re-joining room:', currentRoom)
|
||||||
@@ -28,6 +54,7 @@ export function getSocket(): Socket {
|
|||||||
})
|
})
|
||||||
socket.on('disconnect', (reason) => {
|
socket.on('disconnect', (reason) => {
|
||||||
console.warn('[Socket.io] Disconnected:', reason)
|
console.warn('[Socket.io] Disconnected:', reason)
|
||||||
|
setStatus('disconnected')
|
||||||
if (reason === 'io server disconnect') {
|
if (reason === 'io server disconnect') {
|
||||||
// Server disconnected us, need to manually reconnect
|
// Server disconnected us, need to manually reconnect
|
||||||
socket?.connect()
|
socket?.connect()
|
||||||
@@ -38,9 +65,11 @@ export function getSocket(): Socket {
|
|||||||
})
|
})
|
||||||
socket.io.on('reconnect', (attempt) => {
|
socket.io.on('reconnect', (attempt) => {
|
||||||
console.log('[Socket.io] Reconnected after', attempt, 'attempts')
|
console.log('[Socket.io] Reconnected after', attempt, 'attempts')
|
||||||
|
setStatus('connected')
|
||||||
})
|
})
|
||||||
socket.io.on('reconnect_attempt', (attempt) => {
|
socket.io.on('reconnect_attempt', (attempt) => {
|
||||||
console.log('[Socket.io] Reconnect attempt', attempt)
|
console.log('[Socket.io] Reconnect attempt', attempt)
|
||||||
|
setStatus('reconnecting')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return socket
|
return socket
|
||||||
|
|||||||
69
src/stores/project-store.ts
Normal file
69
src/stores/project-store.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { Project, Feature, JournalEntry } from '@/types'
|
||||||
|
|
||||||
|
interface ProjectStore {
|
||||||
|
// Projekt-Daten
|
||||||
|
project: Project | null
|
||||||
|
features: Feature[] // Karten-Elemente (Symbole, Linien, Polygone)
|
||||||
|
journalEntries: JournalEntry[]
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setProject: (project: Project | null) => void
|
||||||
|
setFeatures: (features: Feature[]) => void
|
||||||
|
addFeature: (feature: Feature) => void
|
||||||
|
updateFeature: (id: string, updates: Partial<Feature>) => void
|
||||||
|
removeFeature: (id: string) => void
|
||||||
|
|
||||||
|
setJournalEntries: (entries: JournalEntry[]) => void
|
||||||
|
addJournalEntry: (entry: JournalEntry) => void
|
||||||
|
|
||||||
|
// Realtime-Sync Actions (werden von Socket.io getriggert)
|
||||||
|
syncFeatures: (features: Feature[]) => void
|
||||||
|
syncJournalEntry: (entry: JournalEntry) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useProjectStore = create<ProjectStore>((set) => ({
|
||||||
|
project: null,
|
||||||
|
features: [],
|
||||||
|
journalEntries: [],
|
||||||
|
|
||||||
|
setProject: (project) => set({ project }),
|
||||||
|
|
||||||
|
setFeatures: (features) => set({ features }),
|
||||||
|
|
||||||
|
addFeature: (feature) => set((state) => ({
|
||||||
|
features: [...state.features, feature]
|
||||||
|
})),
|
||||||
|
|
||||||
|
updateFeature: (id, updates) => set((state) => ({
|
||||||
|
features: state.features.map(f =>
|
||||||
|
f.id === id || f.properties?.id === id
|
||||||
|
? { ...f, properties: { ...f.properties, ...updates } }
|
||||||
|
: f
|
||||||
|
)
|
||||||
|
})),
|
||||||
|
|
||||||
|
removeFeature: (id) => set((state) => ({
|
||||||
|
features: state.features.filter(f => f.id !== id && f.properties?.id !== id)
|
||||||
|
})),
|
||||||
|
|
||||||
|
setJournalEntries: (entries) => set({ journalEntries: entries }),
|
||||||
|
|
||||||
|
addJournalEntry: (entry) => set((state) => ({
|
||||||
|
journalEntries: [...state.journalEntries, entry]
|
||||||
|
})),
|
||||||
|
|
||||||
|
syncFeatures: (features) => set({ features }),
|
||||||
|
|
||||||
|
syncJournalEntry: (entry) => set((state) => {
|
||||||
|
// Avoid duplicates
|
||||||
|
if (state.journalEntries.some(e => e.id === entry.id)) {
|
||||||
|
return {
|
||||||
|
journalEntries: state.journalEntries.map(e => e.id === entry.id ? entry : e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
journalEntries: [...state.journalEntries, entry]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}))
|
||||||
39
src/stores/tool-store.ts
Normal file
39
src/stores/tool-store.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { DrawMode } from '@/types'
|
||||||
|
|
||||||
|
export type LineType = 'solid' | 'dashed' | 'dotted'
|
||||||
|
|
||||||
|
interface ToolStore {
|
||||||
|
activeTool: DrawMode | null
|
||||||
|
activeColor: string
|
||||||
|
lineType: LineType
|
||||||
|
lineWidth: number
|
||||||
|
selectedFeatureId: string | null
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setActiveTool: (tool: ToolStore['activeTool']) => void
|
||||||
|
setActiveColor: (color: string) => void
|
||||||
|
setLineType: (type: ToolStore['lineType']) => void
|
||||||
|
setLineWidth: (width: number) => void
|
||||||
|
selectFeature: (id: string | null) => void
|
||||||
|
resetTool: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useToolStore = create<ToolStore>((set) => ({
|
||||||
|
activeTool: 'select',
|
||||||
|
activeColor: '#ff0000', // Default Rot
|
||||||
|
lineType: 'solid',
|
||||||
|
lineWidth: 3,
|
||||||
|
selectedFeatureId: null,
|
||||||
|
|
||||||
|
setActiveTool: (tool) => set({ activeTool: tool }),
|
||||||
|
setActiveColor: (color) => set({ activeColor: color }),
|
||||||
|
setLineType: (type) => set({ lineType: type }),
|
||||||
|
setLineWidth: (width) => set({ lineWidth: width }),
|
||||||
|
selectFeature: (id) => set({ selectedFeatureId: id }),
|
||||||
|
|
||||||
|
resetTool: () => set({
|
||||||
|
activeTool: 'select',
|
||||||
|
selectedFeatureId: null
|
||||||
|
}),
|
||||||
|
}))
|
||||||
39
src/stores/ui-store.ts
Normal file
39
src/stores/ui-store.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
export type SidebarTab = 'map' | 'journal'
|
||||||
|
export type ConnectionStatus = 'connected' | 'reconnecting' | 'offline'
|
||||||
|
|
||||||
|
interface UIStore {
|
||||||
|
sidebarOpen: boolean
|
||||||
|
sidebarTab: SidebarTab
|
||||||
|
activeModal: string | null
|
||||||
|
isEditing: boolean
|
||||||
|
connectionStatus: ConnectionStatus
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
toggleSidebar: () => void
|
||||||
|
setSidebarOpen: (open: boolean) => void
|
||||||
|
setSidebarTab: (tab: SidebarTab) => void
|
||||||
|
openModal: (modal: string) => void
|
||||||
|
closeModal: () => void
|
||||||
|
setIsEditing: (editing: boolean) => void
|
||||||
|
setConnectionStatus: (status: ConnectionStatus) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUIStore = create<UIStore>((set) => ({
|
||||||
|
sidebarOpen: true,
|
||||||
|
sidebarTab: 'map',
|
||||||
|
activeModal: null,
|
||||||
|
isEditing: false,
|
||||||
|
connectionStatus: 'offline',
|
||||||
|
|
||||||
|
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||||
|
setSidebarOpen: (open) => set({ sidebarOpen: open }),
|
||||||
|
setSidebarTab: (tab) => set({ sidebarTab: tab }),
|
||||||
|
|
||||||
|
openModal: (modal) => set({ activeModal: modal }),
|
||||||
|
closeModal: () => set({ activeModal: null }),
|
||||||
|
|
||||||
|
setIsEditing: (editing) => set({ isEditing: editing }),
|
||||||
|
setConnectionStatus: (status) => set({ connectionStatus: status }),
|
||||||
|
}))
|
||||||
70
src/types/index.ts
Normal file
70
src/types/index.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
export interface Project {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
location?: string
|
||||||
|
description?: string
|
||||||
|
einsatzleiter?: string
|
||||||
|
journalfuehrer?: string
|
||||||
|
mapCenter: { lng: number; lat: number }
|
||||||
|
mapZoom: number
|
||||||
|
isLocked: boolean
|
||||||
|
editingById?: string | null
|
||||||
|
editingUserName?: string | null
|
||||||
|
editingStartedAt?: string | null
|
||||||
|
planImageKey?: string | null
|
||||||
|
planBounds?: { north: number; south: number; east: number; west: number } | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DrawFeature {
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
geometry: {
|
||||||
|
type: string
|
||||||
|
coordinates: number[] | number[][] | number[][][]
|
||||||
|
}
|
||||||
|
properties: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mapbox Feature Types
|
||||||
|
export type Feature = {
|
||||||
|
id?: string | number
|
||||||
|
type: 'Feature'
|
||||||
|
geometry: {
|
||||||
|
type: string
|
||||||
|
coordinates: any
|
||||||
|
}
|
||||||
|
properties: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DrawMode =
|
||||||
|
| 'select'
|
||||||
|
| 'point'
|
||||||
|
| 'linestring'
|
||||||
|
| 'polygon'
|
||||||
|
| 'rectangle'
|
||||||
|
| 'circle'
|
||||||
|
| 'freehand'
|
||||||
|
| 'text'
|
||||||
|
| 'arrow'
|
||||||
|
| 'measure'
|
||||||
|
| 'dangerzone'
|
||||||
|
| 'eraser'
|
||||||
|
|
||||||
|
export interface JournalEntry {
|
||||||
|
id: string
|
||||||
|
type: 'TEXT' | 'IMAGE' | 'AUDIO' | 'SOMA' | 'DANGER'
|
||||||
|
content?: string
|
||||||
|
timestamp: string
|
||||||
|
userId?: string
|
||||||
|
userName?: string
|
||||||
|
userRole?: string
|
||||||
|
isDone?: boolean
|
||||||
|
fileUrl?: string
|
||||||
|
fileKey?: string
|
||||||
|
somaTemplateId?: string
|
||||||
|
somaChecked?: boolean
|
||||||
|
isCorrected?: boolean
|
||||||
|
correctionOfId?: string
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user