v1.3.1: Fix symbol loading, DEL key, SOMA/Pendenzen in rapport, improved onboarding, org settings tab, logo upload

This commit is contained in:
Pepe Ziberi
2026-02-25 22:28:10 +01:00
parent 5917fa88ad
commit 708bdf6be0
17 changed files with 570 additions and 55 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "lageplan",
"version": "1.2.2",
"version": "1.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lageplan",
"version": "1.2.2",
"version": "1.3.1",
"hasInstallScript": true,
"dependencies": {
"@dnd-kit/core": "^6.1.0",

View File

@@ -1,6 +1,6 @@
{
"name": "lageplan",
"version": "1.2.2",
"version": "1.3.1",
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
"private": true,
"scripts": {

View File

@@ -53,6 +53,7 @@ import {
BookOpen,
AlertTriangle,
LayoutGrid,
Building2,
} from 'lucide-react'
import Link from 'next/link'
import { TenantDetailDialog } from '@/components/admin/tenant-detail-dialog'
@@ -62,6 +63,7 @@ import { SomaTab } from '@/components/admin/soma-tab'
import { SuggestionsTab } from '@/components/admin/suggestions-tab'
import { DictionaryTab } from '@/components/admin/dictionary-tab'
import { SymbolManager } from '@/components/admin/symbol-manager'
import { OrgTab } from '@/components/admin/org-tab'
// --- Types ---
interface IconCategory {
@@ -133,7 +135,7 @@ export default function AdminPage() {
const [tenants, setTenants] = useState<TenantRecord[]>([])
const [selectedCategory, setSelectedCategory] = useState<string>('all')
const [isLoading, setIsLoading] = useState(true)
const [activeTab, setActiveTab] = useState(user?.role === 'SERVER_ADMIN' ? 'tenants' : 'users')
const [activeTab, setActiveTab] = useState(user?.role === 'SERVER_ADMIN' ? 'tenants' : 'org')
// Category Dialog
const [isCategoryDialogOpen, setIsCategoryDialogOpen] = useState(false)
@@ -571,10 +573,22 @@ export default function AdminPage() {
</TabsList>
) : user?.role === 'TENANT_ADMIN' ? (
<TabsList className="grid w-full grid-cols-7 max-w-4xl">
<TabsTrigger value="org" className="gap-2">
<Building2 className="w-4 h-4" />
Organisation
</TabsTrigger>
<TabsTrigger value="users" className="gap-2">
<Users className="w-4 h-4" />
Benutzer
</TabsTrigger>
<TabsTrigger value="icons" className="gap-2">
<Image className="w-4 h-4" />
Symbole
</TabsTrigger>
<TabsTrigger value="soma" className="gap-2">
<AlertTriangle className="w-4 h-4" />
SOMA
</TabsTrigger>
<TabsTrigger value="suggestions" className="gap-2">
<ClipboardList className="w-4 h-4" />
Wörterliste
@@ -587,21 +601,16 @@ export default function AdminPage() {
<Heart className="w-4 h-4" />
Spenden
</TabsTrigger>
<TabsTrigger value="icons" className="gap-2">
<Image className="w-4 h-4" />
Symbole
</TabsTrigger>
<TabsTrigger value="categories" className="gap-2">
<Layers className="w-4 h-4" />
Kategorien
</TabsTrigger>
<TabsTrigger value="soma" className="gap-2">
<AlertTriangle className="w-4 h-4" />
SOMA
</TabsTrigger>
</TabsList>
) : null}
{/* ===== ORGANISATION TAB (TENANT_ADMIN) ===== */}
{user?.role === 'TENANT_ADMIN' && (
<TabsContent value="org" className="space-y-4">
<OrgTab tenantId={tenant?.id} />
</TabsContent>
)}
{/* ===== ICONS TAB ===== */}
<TabsContent value="icons" className="space-y-4">
{user?.role === 'TENANT_ADMIN' ? (

View File

@@ -17,9 +17,13 @@ export async function GET(req: NextRequest) {
id: true,
name: true,
slug: true,
description: true,
contactEmail: true,
contactPhone: true,
address: true,
logoUrl: true,
plan: true,
subscriptionStatus: true,
contactEmail: true,
privacyAccepted: true,
privacyAcceptedAt: true,
adminAccessAccepted: true,
@@ -39,3 +43,35 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
export async function PATCH(req: NextRequest) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role !== 'TENANT_ADMIN') return NextResponse.json({ error: 'Nur Admin' }, { status: 403 })
if (!user.tenantId) return NextResponse.json({ error: 'Kein Mandant' }, { status: 400 })
const body = await req.json()
const { name, description, contactEmail, contactPhone, address } = body
if (!name || !name.trim()) {
return NextResponse.json({ error: 'Name darf nicht leer sein' }, { status: 400 })
}
const updated = await (prisma as any).tenant.update({
where: { id: user.tenantId },
data: {
name: name.trim(),
description: description || null,
contactEmail: contactEmail || null,
contactPhone: contactPhone || null,
address: address || null,
},
})
return NextResponse.json({ tenant: updated })
} catch (error: any) {
console.error('[Tenant Info PATCH] Error:', error?.message)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

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

View File

@@ -862,7 +862,7 @@ export default function AppPage() {
<span>Niemand bearbeitet gerade</span>
</div>
)}
<div className="flex items-center gap-2">
<div data-tour="edit-toggle" className="flex items-center gap-2">
{roleCanEdit && !isEditingByMe && !isReadOnly && (
<Button size="sm" variant="default" onClick={handleStartEditing} disabled={editingLoading}>
<Lock className="w-3.5 h-3.5 mr-1" />

View File

@@ -210,9 +210,59 @@ export default function RapportViewerPage({ params }: { params: Promise<{ token:
</Section>
)}
{/* 5. Eingesetzte Mittel */}
{/* 5. SOMA Checkliste */}
{Array.isArray(d.somaItems) && d.somaItems.length > 0 && (
<Section num="5" title="SOMA Checkliste">
<div className="border rounded">
<div className="grid grid-cols-[24px_24px_1fr_60px] gap-0 text-[7pt] font-semibold uppercase tracking-wider bg-gray-900 text-white p-1.5">
<span className="text-center">Best.</span>
<span className="text-center">OK</span>
<span>Punkt</span>
<span className="text-right">Zeit</span>
</div>
{d.somaItems.map((s: any, i: number) => (
<div key={i} className={`grid grid-cols-[24px_24px_1fr_60px] gap-0 p-1.5 border-b border-gray-100 text-[9pt] ${i % 2 === 1 ? 'bg-gray-50' : ''}`}>
<span className="text-center font-bold">{s.confirmed ? '✓' : '—'}</span>
<span className="text-center font-bold">{s.ok ? '✓' : '—'}</span>
<span className="font-medium">{s.label}</span>
<span className="text-right text-[8pt] text-gray-500 font-mono">{s.confirmedAt || ''}</span>
</div>
))}
</div>
</Section>
)}
{/* 6. Pendenzen */}
{Array.isArray(d.pendenzenItems) && d.pendenzenItems.length > 0 && (
<Section num="6" title="Pendenzen">
<table className="w-full border-collapse border rounded text-xs">
<thead>
<tr className="bg-gray-900 text-white">
<th className="p-1.5 text-center font-semibold uppercase tracking-wider text-[7pt] w-8"></th>
<th className="p-1.5 text-left font-semibold uppercase tracking-wider text-[7pt]">Aufgabe</th>
<th className="p-1.5 text-left font-semibold uppercase tracking-wider text-[7pt] w-24">Wer</th>
<th className="p-1.5 text-left font-semibold uppercase tracking-wider text-[7pt] w-32">Wann / Wie</th>
<th className="p-1.5 text-right font-semibold uppercase tracking-wider text-[7pt] w-16">Erledigt</th>
</tr>
</thead>
<tbody>
{d.pendenzenItems.map((p: any, i: number) => (
<tr key={i} className={`${i % 2 === 1 ? 'bg-gray-50' : ''} ${p.done ? 'text-gray-400' : ''}`}>
<td className="p-1.5 border-b border-gray-100 text-center font-bold">{p.done ? '✓' : '○'}</td>
<td className={`p-1.5 border-b border-gray-100 ${p.done ? 'line-through' : ''}`}>{p.what}</td>
<td className="p-1.5 border-b border-gray-100 text-gray-500">{p.who || '—'}</td>
<td className="p-1.5 border-b border-gray-100 text-gray-500">{p.whenHow || '—'}</td>
<td className="p-1.5 border-b border-gray-100 text-right font-mono text-[8pt]">{p.doneAt || ''}</td>
</tr>
))}
</tbody>
</table>
</Section>
)}
{/* 7. Eingesetzte Mittel */}
{d.fahrzeuge?.length > 0 && (
<Section num="5" title="Eingesetzte Mittel">
<Section num="7" title="Eingesetzte Mittel">
<table className="w-full border-collapse border rounded text-xs">
<thead>
<tr className="bg-gray-900 text-white">
@@ -238,8 +288,8 @@ export default function RapportViewerPage({ params }: { params: Promise<{ token:
</Section>
)}
{/* 6. Bemerkungen */}
<Section num="6" title="Bemerkungen / Besondere Vorkommnisse">
{/* 8. Bemerkungen */}
<Section num="8" title="Bemerkungen / Besondere Vorkommnisse">
<div className="border rounded p-3 min-h-[50px] text-sm">{d.bemerkungen || '—'}</div>
</Section>

View File

@@ -0,0 +1,246 @@
'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 {
Building2, Upload, X, Loader2, Shield, Trash2, AlertTriangle,
} from 'lucide-react'
interface OrgTabProps {
tenantId?: string | null
}
export function OrgTab({ tenantId }: OrgTabProps) {
const { toast } = useToast()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [uploadingLogo, setUploadingLogo] = useState(false)
const [tenant, setTenant] = useState<any>(null)
// Editable fields
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [contactEmail, setContactEmail] = useState('')
const [contactPhone, setContactPhone] = useState('')
const [address, setAddress] = useState('')
const fetchTenant = async () => {
setLoading(true)
try {
const res = await fetch('/api/tenant/info')
if (res.ok) {
const data = await res.json()
const t = data.tenant
if (t) {
setTenant(t)
setName(t.name || '')
setDescription(t.description || '')
setContactEmail(t.contactEmail || '')
setContactPhone(t.contactPhone || '')
setAddress(t.address || '')
}
}
} catch (e) {
console.error('Failed to load tenant info:', e)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchTenant()
}, [tenantId])
const handleSave = async () => {
setSaving(true)
try {
const res = await fetch('/api/tenant/info', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.trim(),
description: description.trim() || null,
contactEmail: contactEmail.trim() || null,
contactPhone: contactPhone.trim() || null,
address: address.trim() || null,
}),
})
if (res.ok) {
toast({ title: 'Organisation aktualisiert' })
fetchTenant()
} else {
const err = await res.json()
toast({ title: 'Fehler', description: err.error || 'Speichern fehlgeschlagen', variant: 'destructive' })
}
} catch {
toast({ title: 'Fehler', description: 'Verbindungsfehler', variant: 'destructive' })
} finally {
setSaving(false)
}
}
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files?.[0]) return
setUploadingLogo(true)
try {
const formData = new FormData()
formData.append('logo', e.target.files[0])
const res = await fetch('/api/tenant/logo', { method: 'POST', body: formData })
if (res.ok) {
toast({ title: 'Logo hochgeladen' })
fetchTenant()
} else {
const data = await res.json()
toast({ title: 'Fehler', description: data.error || 'Upload fehlgeschlagen', variant: 'destructive' })
}
} catch {
toast({ title: 'Fehler', description: 'Upload fehlgeschlagen', variant: 'destructive' })
} finally {
setUploadingLogo(false)
e.target.value = ''
}
}
const handleLogoDelete = async () => {
try {
const res = await fetch('/api/tenant/logo', { method: 'DELETE' })
if (res.ok) {
toast({ title: 'Logo entfernt' })
fetchTenant()
}
} catch {
toast({ title: 'Fehler', variant: 'destructive' })
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
)
}
if (!tenant) {
return (
<div className="text-center text-muted-foreground py-12">
Keine Organisation zugeordnet.
</div>
)
}
return (
<div className="space-y-6 max-w-2xl">
{/* Logo */}
<div className="border rounded-lg p-5">
<h3 className="font-semibold text-base mb-3 flex items-center gap-2">
<Building2 className="w-4 h-4" />
Logo
</h3>
<div className="flex items-center gap-4">
<div className="w-20 h-20 rounded-lg border bg-muted flex items-center justify-center overflow-hidden shrink-0">
{tenant.logoUrl ? (
<img
src={tenant.logoUrl.startsWith('/') ? tenant.logoUrl : `/api/tenant/logo/serve`}
alt="Logo"
className="w-full h-full object-contain"
/>
) : (
<Building2 className="w-10 h-10 text-muted-foreground/40" />
)}
</div>
<div className="space-y-1.5">
<div className="flex gap-2">
<Button variant="outline" size="sm" className="relative" disabled={uploadingLogo}>
{uploadingLogo ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <Upload className="w-3.5 h-3.5 mr-1" />}
{tenant.logoUrl ? 'Ändern' : 'Hochladen'}
<input
type="file"
accept="image/png,image/jpeg,image/svg+xml,image/webp"
onChange={handleLogoUpload}
className="absolute inset-0 opacity-0 cursor-pointer"
/>
</Button>
{tenant.logoUrl && (
<Button variant="ghost" size="sm" className="text-destructive" onClick={handleLogoDelete}>
<X className="w-3.5 h-3.5 mr-1" /> Entfernen
</Button>
)}
</div>
<p className="text-[11px] text-muted-foreground">PNG, JPEG, SVG oder WebP, max. 2 MB. Wird auch im Rapport angezeigt.</p>
</div>
</div>
</div>
{/* Organisation Details */}
<div className="border rounded-lg p-5 space-y-4">
<h3 className="font-semibold text-base mb-1 flex items-center gap-2">
<Shield className="w-4 h-4" />
Stammdaten
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs">Name</Label>
<Input value={name} onChange={e => setName(e.target.value)} />
</div>
<div>
<Label className="text-xs">Slug</Label>
<Input value={tenant.slug} disabled className="font-mono bg-muted" />
</div>
</div>
<div>
<Label className="text-xs">Beschreibung</Label>
<Input value={description} onChange={e => setDescription(e.target.value)} placeholder="z.B. Feuerwehr Musterstadt" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs">Kontakt E-Mail</Label>
<Input type="email" value={contactEmail} onChange={e => setContactEmail(e.target.value)} placeholder="kontakt@feuerwehr.ch" />
</div>
<div>
<Label className="text-xs">Kontakt Telefon</Label>
<Input value={contactPhone} onChange={e => setContactPhone(e.target.value)} placeholder="+41 ..." />
</div>
</div>
<div>
<Label className="text-xs">Adresse</Label>
<Input value={address} onChange={e => setAddress(e.target.value)} placeholder="Strasse, PLZ Ort" />
</div>
<Button onClick={handleSave} disabled={saving || !name.trim()}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : null}
Speichern
</Button>
</div>
{/* Info (read-only) */}
<div className="border rounded-lg p-5">
<h3 className="font-semibold text-base mb-3">Übersicht</h3>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-muted-foreground">Plan:</span>
<span className="ml-2">{tenant.plan}</span>
</div>
<div>
<span className="text-muted-foreground">Status:</span>
<span className="ml-2">{tenant.subscriptionStatus}</span>
</div>
{tenant._count && (
<>
<div>
<span className="text-muted-foreground">Benutzer:</span>
<span className="ml-2">{tenant._count.memberships}</span>
</div>
<div>
<span className="text-muted-foreground">Einsätze:</span>
<span className="ml-2">{tenant._count.projects}</span>
</div>
</>
)}
</div>
</div>
</div>
)
}

View File

@@ -13,7 +13,7 @@ import {
} from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { MapPin, Loader2, X } from 'lucide-react'
import type { Project } from '@/app/app/page'
import type { Project } from '@/types'
interface NominatimResult {
place_id: number

View File

@@ -461,8 +461,21 @@ export function JournalView({ projectId, projectTitle, projectLocation, einsatzl
zeitKontrolle: '', zeitAus: '', zeitEinruecken: '', zeitEnde: '',
lageEintreffen: '',
massnahmen: entries.map(e => `${formatTime(e.time)} ${e.what}${e.who ? ` (${e.who})` : ''}`),
somaItems: checkItems.map(c => ({
label: c.label,
confirmed: c.confirmed,
ok: c.ok,
confirmedAt: c.confirmedAt ? formatTime(c.confirmedAt) : null,
})),
pendenzenItems: pendenzen.map(p => ({
what: p.what,
who: p.who || '',
whenHow: p.whenHow || '',
done: p.done,
doneAt: p.doneAt ? formatTime(p.doneAt) : null,
})),
fahrzeuge: [] as any[],
bemerkungen: pendenzen.filter(p => !p.done).map(p => `PENDENT: ${p.what}${p.who ? ` (${p.who})` : ''}`).join('\n'),
bemerkungen: '',
einsatzleiter: einsatzleiter || '',
rapporteur: journalfuehrer || '',
reportNumber: '',

View File

@@ -202,6 +202,39 @@ export function RapportDialog({
) : <span className="text-muted-foreground">Keine Einträge</span>}
</div>
</div>
{/* SOMA Checklist (read-only, from journal) */}
{Array.isArray(rapportForm.somaItems) && rapportForm.somaItems.length > 0 && (
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">SOMA Checkliste</label>
<div className="border rounded-md p-2 bg-muted text-sm max-h-32 overflow-auto">
{rapportForm.somaItems.map((s: any, i: number) => (
<div key={i} className="flex items-center gap-2 py-0.5">
<span className={`inline-block w-4 text-center ${s.confirmed ? 'text-blue-600 font-bold' : 'text-muted-foreground'}`}>{s.confirmed ? '✓' : '—'}</span>
<span className={`inline-block w-4 text-center ${s.ok ? 'text-green-600 font-bold' : 'text-muted-foreground'}`}>{s.ok ? '✓' : '—'}</span>
<span>{s.label}</span>
{s.confirmedAt && <span className="text-[10px] text-muted-foreground ml-auto">{s.confirmedAt}</span>}
</div>
))}
</div>
</div>
)}
{/* Pendenzen (read-only, from journal) */}
{Array.isArray(rapportForm.pendenzenItems) && rapportForm.pendenzenItems.length > 0 && (
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">Pendenzen</label>
<div className="border rounded-md p-2 bg-muted text-sm max-h-32 overflow-auto">
{rapportForm.pendenzenItems.map((p: any, i: number) => (
<div key={i} className={`flex items-center gap-2 py-0.5 ${p.done ? 'line-through text-muted-foreground' : ''}`}>
<span className={`inline-block w-4 text-center ${p.done ? 'text-green-600 font-bold' : 'text-muted-foreground'}`}>{p.done ? '✓' : '○'}</span>
<span>{p.what}</span>
{p.who && <span className="text-muted-foreground text-xs">({p.who})</span>}
{p.whenHow && <span className="text-muted-foreground text-xs ml-1"> {p.whenHow}</span>}
{p.doneAt && <span className="text-[10px] text-green-600 ml-auto">{p.doneAt}</span>}
</div>
))}
</div>
</div>
)}
{/* Bemerkungen */}
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">Bemerkungen</label>

View File

@@ -22,7 +22,7 @@ import {
Ruler,
Eraser,
} from 'lucide-react'
import type { DrawMode } from '@/app/app/page'
import type { DrawMode } from '@/types'
import { cn } from '@/lib/utils'
interface LeftToolbarProps {

View File

@@ -108,7 +108,7 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
async function fetchIcons() {
setIsLoading(true)
try {
const res = await fetch('/api/icons')
const res = await fetch('/api/icons', { cache: 'no-store' })
if (res.ok) {
const data = await res.json()
const allCats: DisplayCategory[] = (data.categories || [])
@@ -154,7 +154,7 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
}
}
fetchIcons()
}, [])
}, [tenantId])
const filteredCategories = categories.map((cat) => ({
...cat,

View File

@@ -37,12 +37,11 @@ import {
ImagePlus,
Key,
Shield,
Building2,
MapPin,
HelpCircle,
} from 'lucide-react'
import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog'
import type { Project, DrawFeature } from '@/app/app/page'
import type { Project, DrawFeature } from '@/types'
import { formatDateTime } from '@/lib/utils'
import { Logo } from '@/components/ui/logo'
@@ -286,12 +285,6 @@ export function Topbar({
<Key className="w-4 h-4 mr-2" />
Kennwort ändern
</DropdownMenuItem>
{userRole === 'TENANT_ADMIN' && (
<DropdownMenuItem onClick={() => window.location.href = '/settings'}>
<Building2 className="w-4 h-4 mr-2" />
Organisation
</DropdownMenuItem>
)}
{(userRole === 'SERVER_ADMIN' || userRole === 'TENANT_ADMIN') && (
<DropdownMenuItem onClick={() => window.location.href = '/admin'}>
<Shield className="w-4 h-4 mr-2" />

View File

@@ -6,7 +6,7 @@ import 'maplibre-gl/dist/maplibre-gl.css'
import { useDrop } from 'react-dnd'
import Moveable from 'react-moveable'
import { getSymbolById, getSymbolDataUri } from '@/lib/fw-symbols'
import type { Project, DrawFeature, DrawMode } from '@/app/app/page'
import type { Project, DrawFeature, DrawMode } from '@/types'
// Haversine distance between two [lng, lat] points in meters
function haversineDistance(a: number[], b: number[]): number {
@@ -1892,9 +1892,19 @@ export function MapView({
}
}, [drawMode, deselectSymbol])
// ESC to cancel drawing / finalize measurement
// ESC to cancel drawing, DEL to delete selected symbol
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// DEL / Backspace → delete selected symbol
if (e.key === 'Delete' || e.key === 'Backspace') {
const tag = (e.target as HTMLElement)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (e.target as HTMLElement)?.isContentEditable) return
if (selectedSymbolRef.current) {
e.preventDefault()
deleteSelectedSymbol()
return
}
}
if (e.key === 'Escape') {
// In measure mode: finalize (keep line + labels), just stop adding
if (drawModeRef.current === 'measure' && drawingRef.current.isDrawing) {
@@ -1921,7 +1931,7 @@ export function MapView({
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
}, [deleteSelectedSymbol])
// Drop zone for symbols — use stable ref connection (no inline ref callback)
const [, drop] = useDrop(() => ({

View File

@@ -5,6 +5,8 @@ import { Button } from '@/components/ui/button'
import {
X, ChevronRight, ChevronLeft, SkipForward,
MapPin, Pencil, LayoutGrid, Save, Ruler, Users, Keyboard, Rocket,
MousePointer2, CircleDot, Minus, Pentagon, Square, Circle, MoveRight, Type, Eraser,
Lock, ClipboardList, Download, AlertTriangle,
} from 'lucide-react'
const TOUR_STORAGE_KEY = 'lageplan-onboarding-completed'
@@ -15,61 +17,96 @@ interface TourStep {
icon?: React.ReactNode
targetSelector?: string
position?: 'top' | 'bottom' | 'left' | 'right'
tools?: { icon: React.ReactNode; label: string; shortcut?: string }[]
}
const TOUR_STEPS: TourStep[] = [
{
title: 'Willkommen bei Lageplan!',
icon: <Rocket className="w-5 h-5 text-red-500" />,
description: 'Lageplan ist deine taktische Lageskizzen-App für den Feuerwehr-Einsatz. Diese kurze Tour zeigt dir die wichtigsten Funktionen. Du kannst sie jederzeit überspringen oder später im Benutzermenü erneut starten.',
description: 'Lageplan ist deine taktische Lageskizzen-App für den Feuerwehr-Einsatz. Diese kurze Tour zeigt dir die wichtigsten Funktionen.',
},
{
title: 'Einsatz erstellen',
icon: <MapPin className="w-5 h-5 text-red-500" />,
description: 'Erstelle über «Neuer Einsatz» ein neues Projekt. Gib eine Adresse ein — die Karte fliegt automatisch dorthin. Jeder Einsatz wird separat gespeichert und kann als PDF oder PNG exportiert werden.',
description: 'Erstelle über «Neuer Einsatz» ein Projekt. Gib eine Adresse ein — die Karte fliegt automatisch dorthin.',
targetSelector: '[data-tour="new-project"]',
position: 'bottom',
},
{
title: 'Bearbeitung starten',
icon: <Lock className="w-5 h-5 text-green-500" />,
description: 'Klicke auf «Bearbeitung starten» um die Karte zu bearbeiten. Nur ein Benutzer gleichzeitig kann bearbeiten — andere sehen deine Änderungen live.',
targetSelector: '[data-tour="edit-toggle"]',
position: 'bottom',
},
{
title: 'Zeichenwerkzeuge',
icon: <Pencil className="w-5 h-5 text-blue-500" />,
description: 'Die Werkzeugleiste links enthält alle Zeichentools: Punkte, Linien, Polygone, Freihand, Pfeile, Text, Radiergummi und mehr. Jedes Tool hat ein Tastenkürzel — drücke «?» für eine Übersicht.',
description: 'Links findest du alle Werkzeuge. Jedes hat ein Tastenkürzel:',
targetSelector: '[data-tour="toolbar"]',
position: 'right',
tools: [
{ icon: <MousePointer2 className="w-3.5 h-3.5" />, label: 'Auswählen', shortcut: 'V' },
{ icon: <CircleDot className="w-3.5 h-3.5" />, label: 'Punkt', shortcut: 'P' },
{ icon: <Minus className="w-3.5 h-3.5" />, label: 'Linie / Schlauch', shortcut: 'L' },
{ icon: <Pentagon className="w-3.5 h-3.5" />, label: 'Polygon', shortcut: 'G' },
{ icon: <Square className="w-3.5 h-3.5" />, label: 'Rechteck', shortcut: 'R' },
{ icon: <Circle className="w-3.5 h-3.5" />, label: 'Kreis', shortcut: 'C' },
{ icon: <Pencil className="w-3.5 h-3.5" />, label: 'Freihand', shortcut: 'F' },
{ icon: <MoveRight className="w-3.5 h-3.5" />, label: 'Pfeil / Route', shortcut: 'A' },
{ icon: <Type className="w-3.5 h-3.5" />, label: 'Text', shortcut: 'T' },
{ icon: <Eraser className="w-3.5 h-3.5" />, label: 'Radiergummi', shortcut: 'E' },
{ icon: <Ruler className="w-3.5 h-3.5" />, label: 'Messen', shortcut: 'M' },
{ icon: <AlertTriangle className="w-3.5 h-3.5" />, label: 'Gefahrenzone', shortcut: 'D' },
],
},
{
title: 'Symbole & Sidebar',
title: 'Symbole (Drag & Drop)',
icon: <LayoutGrid className="w-5 h-5 text-orange-500" />,
description: 'Rechts findest du über 100 taktische Feuerwehr-Symbole, sortiert nach Kategorien (Wasser, Feuer, Fahrzeuge usw.). Ziehe sie per Drag & Drop auf die Karte. Wechsle zwischen Symbolen und dem Einsatz-Journal.',
description: 'Rechts findest du taktische Feuerwehr-Symbole. Deine eigenen Symbole stehen zuoberst. Ziehe Symbole per Drag & Drop auf die Karte. Klicke auf ein platziertes Symbol um es zu drehen, skalieren oder löschen.',
targetSelector: '[data-tour="sidebar"]',
position: 'left',
},
{
title: 'Journal & Rapport',
icon: <ClipboardList className="w-5 h-5 text-indigo-500" />,
description: 'Wechsle rechts zum Journal-Tab für das Einsatz-Journal. Erfasse Einträge, SOMA-Checkliste und Pendenzen. Erstelle einen druckfertigen Einsatzrapport als PDF.',
targetSelector: '[data-tour="sidebar"]',
position: 'left',
},
{
title: 'Speichern & Export',
icon: <Save className="w-5 h-5 text-green-500" />,
description: 'Speichere deinen Einsatz mit Ctrl+S oder dem Speichern-Button. Exportiere als PNG (Bild) oder als druckfertiges PDF. Die letzte Kartenansicht wird automatisch gespeichert.',
description: 'Speichere mit dem Speichern-Button oder Ctrl+S. Exportiere die Karte als PNG oder PDF über das Menü.',
targetSelector: '[data-tour="save"]',
position: 'bottom',
},
{
title: 'Messen & Schlauch-Rechner',
icon: <Ruler className="w-5 h-5 text-purple-500" />,
description: 'Mit dem Messwerkzeug (Taste «M») misst du Distanzen direkt auf der Karte. Der Schlauch-Rechner im Admin-Bereich berechnet die benötigten Schlauchlängen und -typen für deinen Einsatz.',
tools: [
{ icon: <Save className="w-3.5 h-3.5" />, label: 'Speichern', shortcut: 'Ctrl+S' },
{ icon: <Download className="w-3.5 h-3.5" />, label: 'Export als PNG/PDF' },
],
},
{
title: 'Live-Zusammenarbeit',
icon: <Users className="w-5 h-5 text-cyan-500" />,
description: 'Mehrere Benutzer können gleichzeitig am selben Einsatz arbeiten. Änderungen werden in Echtzeit synchronisiert — ideal für die Einsatzleitung mit mehreren Operateuren.',
description: 'Mehrere Benutzer arbeiten gleichzeitig am selben Einsatz. Änderungen werden in Echtzeit synchronisiert.',
},
{
title: 'Tastenkürzel (CH)',
title: 'Tastenkürzel',
icon: <Keyboard className="w-5 h-5 text-slate-500" />,
description: 'Optimiert für Schweizer Tastaturen: Ctrl+Y = Rückgängig, Ctrl+Z = Wiederholen, Del = Löschen, Ctrl+S = Speichern. Drücke «?» oder F1 für die komplette Übersicht aller Kürzel.',
description: 'Wichtige Kürzel:',
tools: [
{ icon: <span className="text-[10px] font-mono font-bold">Ctrl+Z</span>, label: 'Rückgängig' },
{ icon: <span className="text-[10px] font-mono font-bold">Ctrl+Y</span>, label: 'Wiederholen' },
{ icon: <span className="text-[10px] font-mono font-bold">Del</span>, label: 'Symbol löschen' },
{ icon: <span className="text-[10px] font-mono font-bold">Ctrl+S</span>, label: 'Speichern' },
{ icon: <span className="text-[10px] font-mono font-bold">?</span>, label: 'Alle Kürzel anzeigen' },
],
},
{
title: 'Bereit für den Einsatz!',
icon: <Rocket className="w-5 h-5 text-red-500" />,
description: 'Du bist startklar! Diese Tour kannst du jederzeit über dein Benutzermenü (oben rechts → «Tour starten») erneut aufrufen. Viel Erfolg im Einsatz — Feuer frei!',
description: 'Du bist startklar! Diese Tour kannst du jederzeit über dein Benutzermenü (oben rechts) erneut starten. Viel Erfolg!',
},
]
@@ -224,9 +261,20 @@ export function OnboardingTour({ forceShow = false, onComplete }: OnboardingTour
<X className="w-4 h-4" />
</button>
</div>
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
<p className="text-sm text-muted-foreground leading-relaxed mb-2">
{step.description}
</p>
{step.tools && (
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5 mb-3 text-xs">
{step.tools.map((t, i) => (
<div key={i} className="flex items-center gap-1.5 py-0.5">
<span className="text-muted-foreground shrink-0">{t.icon}</span>
<span className="truncate">{t.label}</span>
{t.shortcut && <kbd className="ml-auto shrink-0 px-1 py-0.5 bg-muted rounded border border-border font-mono text-[9px]">{t.shortcut}</kbd>}
</div>
))}
</div>
)}
{/* Progress dots */}
<div className="flex items-center justify-between">

View File

@@ -1,6 +1,6 @@
import html2canvas from 'html2canvas'
import jsPDF from 'jspdf'
import type { Project, DrawFeature } from '@/app/app/page'
import type { Project, DrawFeature } from '@/types'
import { formatDateTime } from './utils'
export interface ExportOptions {