diff --git a/package.json b/package.json index 51eb412..36ed4ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lageplan", - "version": "1.1.0", + "version": "1.2.0", "description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation", "private": true, "scripts": { diff --git a/prisma/migrate.js b/prisma/migrate.js index cf1f7a0..e395919 100644 --- a/prisma/migrate.js +++ b/prisma/migrate.js @@ -214,6 +214,23 @@ async function migrate() { console.log(' Privacy consent columns skipped:', e.message) } + // ─── Step 12: Create tenant_symbols table ─── + console.log(' [12] Creating tenant_symbols table...') + try { + await prisma.$executeRawUnsafe(` + CREATE TABLE IF NOT EXISTS tenant_symbols ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid(), + "isActive" BOOLEAN NOT NULL DEFAULT true, + "tenantId" TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + "iconId" TEXT NOT NULL REFERENCES icon_assets(id) ON DELETE CASCADE, + UNIQUE("tenantId", "iconId") + ) + `) + console.log(' tenant_symbols table created (or already exists)') + } catch (e) { + console.log(' tenant_symbols table skipped:', e.message) + } + console.log('✅ Database migrations complete') } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d1080ac..0a5ef21 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -89,6 +89,7 @@ model Tenant { checkTemplates JournalCheckTemplate[] iconCategories IconCategory[] iconAssets IconAsset[] + tenantSymbols TenantSymbol[] upgradeRequests UpgradeRequest[] dictionaryEntries DictionaryEntry[] rapports Rapport[] @@ -236,6 +237,8 @@ model IconAsset { tenantId String? tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: SetNull) + tenantSymbols TenantSymbol[] + @@map("icon_assets") } @@ -375,6 +378,22 @@ model UpgradeRequest { @@map("upgrade_requests") } +// ─── Tenant Symbol Visibility ───────────────────────────── + +model TenantSymbol { + id String @id @default(uuid()) + isActive Boolean @default(true) + + tenantId String + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + + iconId String + icon IconAsset @relation(fields: [iconId], references: [id], onDelete: Cascade) + + @@unique([tenantId, iconId]) + @@map("tenant_symbols") +} + // ─── Dictionary (Global + Tenant word library) ──────────── model DictionaryEntry { diff --git a/prisma/seed.js b/prisma/seed.js index 850556c..0513845 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -252,32 +252,8 @@ async function main() { console.log('✅ Hose types created:', hoseTypes.length) // ─── Journal Check Templates (SOMA) ───────────────────── - const somaTemplates = [ - { label: 'Stopp Zutritt / Ex-Gefahr', sortOrder: 1 }, - { label: 'Alarmierung Gesamt-Fw', sortOrder: 2 }, - { label: 'Alarmierung Ofw Wohlen', sortOrder: 3 }, - { label: 'Alarmierung Stp Baden', sortOrder: 4 }, - { label: 'Alarmierung Ambulanz', sortOrder: 5 }, - { label: 'Alarmierung BWL', sortOrder: 6 }, - { label: 'Alarmierung BL/Meister', sortOrder: 7 }, - { label: 'Brunnenchef Villmergen', sortOrder: 8 }, - { label: 'Berieselung Tank / Anl', sortOrder: 9 }, - { label: 'Druckerhöhung', sortOrder: 10 }, - ] - - for (const tpl of somaTemplates) { - await prisma.journalCheckTemplate.upsert({ - where: { id: tpl.label.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() }, - update: { label: tpl.label, sortOrder: tpl.sortOrder }, - create: { - id: tpl.label.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase(), - label: tpl.label, - sortOrder: tpl.sortOrder, - }, - }) - } - - console.log('✅ SOMA templates created:', somaTemplates.length) + // No default SOMA templates — each tenant defines their own via Admin → SOMA tab + console.log('ℹ️ SOMA templates: keine Standard-Vorgaben (Tenants konfigurieren eigene)') console.log('🎉 Seed completed successfully!') } diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index f5fcc96..4454698 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -55,6 +55,9 @@ import { X, BookOpen, Download, + AlertTriangle, + GripVertical, + LayoutGrid, } from 'lucide-react' import Link from 'next/link' import { TenantDetailDialog } from '@/components/admin/tenant-detail-dialog' @@ -223,6 +226,19 @@ export default function AdminPage() { // Hose Settings (Tenant Admin) const [isHoseSettingsOpen, setIsHoseSettingsOpen] = useState(false) + // SOMA Templates (Tenant Admin) + const [somaTemplates, setSomaTemplates] = useState<{ id: string; label: string; sortOrder: number; isActive: boolean }[]>([]) + const [newSomaLabel, setNewSomaLabel] = useState('') + const [somaLoading, setSomaLoading] = useState(false) + + // Symbol Management (Tenant Admin) + const [tenantSymbols, setTenantSymbols] = useState<{ id: string; name: string; fileKey: string; mimeType: string; iconType: string; categoryId: string | null; categoryName: string; isActive: boolean }[]>([]) + const [symbolSearch, setSymbolSearch] = useState('') + const [symbolCatFilter, setSymbolCatFilter] = useState('all') + const [symbolViewTab, setSymbolViewTab] = useState<'library' | 'active'>('library') + const [selectedSymbolIds, setSelectedSymbolIds] = useState>(new Set()) + const [symbolsLoading, setSymbolsLoading] = useState(false) + // Redirect to login if not authenticated, or to app if not admin useEffect(() => { if (authLoading) return @@ -246,6 +262,38 @@ export default function AdminPage() { .catch(() => {}) }, [tenant?.id]) + // Load SOMA templates (TENANT_ADMIN) + 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(() => { + if (user?.role === 'TENANT_ADMIN') fetchSomaTemplates() + }, [user?.role]) + + // Load tenant symbols (TENANT_ADMIN) + const fetchTenantSymbols = async () => { + setSymbolsLoading(true) + try { + const res = await fetch('/api/tenant/symbols') + if (res.ok) { + const data = await res.json() + setTenantSymbols(data.symbols || []) + } + } catch {} + setSymbolsLoading(false) + } + useEffect(() => { + if (user?.role === 'TENANT_ADMIN') fetchTenantSymbols() + }, [user?.role]) + // Load global dictionary (SERVER_ADMIN) const fetchGlobalDict = async () => { try { @@ -742,7 +790,7 @@ export default function AdminPage() { ) : user?.role === 'TENANT_ADMIN' ? ( - + Benutzer @@ -767,66 +815,225 @@ export default function AdminPage() { Kategorien + + + SOMA + ) : null} {/* ===== ICONS TAB ===== */} -
-
- - {filteredIcons.length} Symbol(e) -
- -
+ {user?.role === 'TENANT_ADMIN' ? (() => { + // --- Tenant Symbol Management UI --- + const symbolCategories = [...new Set(tenantSymbols.map(s => s.categoryName))].sort() + const viewSymbols = tenantSymbols + .filter(s => symbolViewTab === 'active' ? s.isActive : true) + .filter(s => symbolCatFilter === 'all' || s.categoryName === symbolCatFilter) + .filter(s => !symbolSearch || s.name.toLowerCase().includes(symbolSearch.toLowerCase())) + const grouped = viewSymbols.reduce>((acc, s) => { + const key = s.categoryName + if (!acc[key]) acc[key] = [] + acc[key].push(s) + return acc + }, {}) + const toggleSelect = (id: string) => { + setSelectedSymbolIds(prev => { + const next = new Set(prev) + next.has(id) ? next.delete(id) : next.add(id) + return next + }) + } + const bulkUpdate = async (ids: string[], isActive: boolean) => { + try { + await fetch('/api/tenant/symbols', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ updates: ids.map(iconId => ({ iconId, isActive })) }), + }) + setSelectedSymbolIds(new Set()) + fetchTenantSymbols() + toast({ title: isActive ? 'Symbole aktiviert' : 'Symbole deaktiviert' }) + } catch {} + } - {filteredIcons.length === 0 ? ( -
- -

Keine Symbole vorhanden

-

Laden Sie eigene Symbole hoch (PNG, SVG, JPEG)

- -
- ) : ( -
- {filteredIcons.map(icon => ( -
-
- {icon.name} -
-

{icon.name}

-

{icon.category.name}

- {icon.isSystem &&

System

} -
- - - + return ( + <> + {/* Header: View tabs + search + filter */} +
+
+ +
+ setSymbolSearch(e.target.value)} + className="w-48" + /> + + + {tenantSymbols.filter(s => s.isActive).length} aktiv / {tenantSymbols.length} gesamt +
- ))} -
+ + {/* Bulk category action */} + {symbolCatFilter !== 'all' && ( +
+ + +
+ )} + + {/* Selection action bar */} + {selectedSymbolIds.size > 0 && ( +
+ {selectedSymbolIds.size} ausgewählt + + + +
+ )} + + {/* Symbol grid grouped by category */} + {symbolsLoading ? ( +
+ Symbole laden... +
+ ) : viewSymbols.length === 0 ? ( +
+ {symbolViewTab === 'active' ? 'Keine aktiven Symbole.' : 'Keine Symbole gefunden.'} +
+ ) : ( + Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)).map(([catName, syms]) => ( +
+

+ {catName} + ({syms.length}) +

+
+ {syms.map(sym => { + const selected = selectedSymbolIds.has(sym.id) + return ( +
toggleSelect(sym.id)} + className={`relative cursor-pointer border-2 rounded-lg p-2 transition-all hover:shadow-sm ${ + selected ? 'border-blue-500 bg-blue-50 dark:bg-blue-950/30' : + sym.isActive ? 'border-transparent hover:border-border' : 'border-transparent opacity-40' + }`} + > +
+ {sym.name} +
+

{sym.name}

+ {/* Status dot */} +
+
+ ) + })} +
+
+ )) + )} + + ) + })() : ( + /* --- SERVER_ADMIN: existing icon management --- */ + <> +
+
+ + {filteredIcons.length} Symbol(e) +
+ +
+ + {filteredIcons.length === 0 ? ( +
+ +

Keine Symbole vorhanden

+

Laden Sie eigene Symbole hoch (PNG, SVG, JPEG)

+ +
+ ) : ( +
+ {filteredIcons.map(icon => ( +
+
+ {icon.name} +
+

{icon.name}

+

{icon.category.name}

+ {icon.isSystem &&

System

} +
+ + + +
+
+ ))} +
+ )} + )} @@ -1623,6 +1830,14 @@ export default function AdminPage() { Zur Spendenseite +
+

+ mit ♥ von Pepe —{' '} + + Über mich & Lageplan + +

+

Dein Mandant

@@ -1644,6 +1859,130 @@ export default function AdminPage() { )} + {/* ===== SOMA TAB (TENANT_ADMIN) ===== */} + {user?.role === 'TENANT_ADMIN' && ( + +
+

+ + SOMA-Checkliste verwalten +

+

+ Definiere die Sofortmassnahmen (SOMA), die bei jedem neuen Einsatz als Checkliste erscheinen. + Bestehende Einsätze werden nicht verändert. +

+ + {somaLoading ? ( +
+ Laden... +
+ ) : ( + <> + {/* Template list */} +
+ {somaTemplates.length === 0 ? ( +
+ Keine SOMA-Vorlagen definiert. Neue Einsätze starten ohne Checkliste. +
+ ) : somaTemplates.map((tpl, idx) => ( +
+ + {tpl.label} + #{idx + 1} + + +
+ ))} +
+ + {/* Add new */} +
+ setNewSomaLabel(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter' && newSomaLabel.trim()) { + e.preventDefault() + ;(async () => { + 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 {} + })() + } + }} + className="flex-1" + /> + +
+ +

+ {somaTemplates.filter(t => t.isActive).length} aktiv / {somaTemplates.length} gesamt — + Nur aktive Vorlagen erscheinen bei neuen Einsätzen. +

+ + )} +
+
+ )} + {/* Upgrades tab removed — plan management simplified */}
diff --git a/src/app/api/icons/route.ts b/src/app/api/icons/route.ts index 3894a20..f7ee392 100644 --- a/src/app/api/icons/route.ts +++ b/src/app/api/icons/route.ts @@ -38,20 +38,28 @@ export async function GET() { }, }) - // Get tenant's hidden icon IDs + // Get tenant's hidden icon IDs (legacy) + TenantSymbol overrides let hiddenIconIds: string[] = [] + let deactivatedIconIds = new Set() if (user?.tenantId) { - const tenant = await (prisma as any).tenant.findUnique({ - where: { id: user.tenantId }, - select: { hiddenIconIds: true }, - }) + const [tenant, tenantSymbols] = await Promise.all([ + (prisma as any).tenant.findUnique({ + where: { id: user.tenantId }, + select: { hiddenIconIds: true }, + }), + (prisma as any).tenantSymbol.findMany({ + where: { tenantId: user.tenantId, isActive: false }, + select: { iconId: true }, + }), + ]) hiddenIconIds = tenant?.hiddenIconIds || [] + deactivatedIconIds = new Set(tenantSymbols.map((ts: any) => ts.iconId)) } const categoriesWithUrls = categories.map((cat: any) => ({ ...cat, icons: cat.icons - .filter((icon: any) => !hiddenIconIds.includes(icon.id)) + .filter((icon: any) => !hiddenIconIds.includes(icon.id) && !deactivatedIconIds.has(icon.id)) .map((icon: any) => ({ ...icon, url: `/api/icons/${icon.id}/image`, diff --git a/src/app/api/projects/[id]/journal/check-items/route.ts b/src/app/api/projects/[id]/journal/check-items/route.ts index ad06a88..6f803a6 100644 --- a/src/app/api/projects/[id]/journal/check-items/route.ts +++ b/src/app/api/projects/[id]/journal/check-items/route.ts @@ -24,10 +24,17 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: if (existing.length > 0) { return NextResponse.json(existing) } - const templates = await (prisma as any).journalCheckTemplate.findMany({ - where: { isActive: true }, + // Prefer tenant-specific templates; fall back to global (tenantId=null) if none exist + let templates = await (prisma as any).journalCheckTemplate.findMany({ + where: { isActive: true, tenantId: user.tenantId || null }, orderBy: { sortOrder: 'asc' }, }) + if (templates.length === 0 && user.tenantId) { + templates = await (prisma as any).journalCheckTemplate.findMany({ + where: { isActive: true, tenantId: null }, + orderBy: { sortOrder: 'asc' }, + }) + } const items = await Promise.all( templates.map((tpl: any, i: number) => (prisma as any).journalCheckItem.create({ diff --git a/src/app/api/tenant/soma-templates/route.ts b/src/app/api/tenant/soma-templates/route.ts new file mode 100644 index 0000000..8f5ff7f --- /dev/null +++ b/src/app/api/tenant/soma-templates/route.ts @@ -0,0 +1,108 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/db' +import { getSession } from '@/lib/auth' + +// GET: List SOMA templates for the current tenant +export async function GET() { + try { + const user = await getSession() + if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 }) + if (user.role !== 'TENANT_ADMIN' && user.role !== 'SERVER_ADMIN') { + return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 }) + } + + const templates = await (prisma as any).journalCheckTemplate.findMany({ + where: { tenantId: user.tenantId || null }, + orderBy: { sortOrder: 'asc' }, + }) + + return NextResponse.json({ templates }) + } catch (error) { + console.error('Error fetching SOMA templates:', error) + return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 }) + } +} + +// POST: Create a new SOMA template for the current tenant +export async function POST(req: NextRequest) { + try { + const user = await getSession() + if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 }) + if (user.role !== 'TENANT_ADMIN' && user.role !== 'SERVER_ADMIN') { + return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 }) + } + + const { label, sortOrder } = await req.json() + if (!label?.trim()) { + return NextResponse.json({ error: 'Label ist erforderlich' }, { status: 400 }) + } + + const template = await (prisma as any).journalCheckTemplate.create({ + data: { + label: label.trim(), + sortOrder: sortOrder ?? 0, + tenantId: user.tenantId || null, + isActive: true, + }, + }) + + return NextResponse.json({ template }, { status: 201 }) + } catch (error) { + console.error('Error creating SOMA template:', error) + return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 }) + } +} + +// PATCH: Update multiple templates (bulk reorder/toggle) +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' && user.role !== 'SERVER_ADMIN') { + return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 }) + } + + const { updates } = await req.json() + if (!Array.isArray(updates)) { + return NextResponse.json({ error: 'updates Array erforderlich' }, { status: 400 }) + } + + await Promise.all( + updates.map((u: { id: string; label?: string; sortOrder?: number; isActive?: boolean }) => + (prisma as any).journalCheckTemplate.update({ + where: { id: u.id }, + data: { + ...(u.label !== undefined && { label: u.label }), + ...(u.sortOrder !== undefined && { sortOrder: u.sortOrder }), + ...(u.isActive !== undefined && { isActive: u.isActive }), + }, + }) + ) + ) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Error updating SOMA templates:', error) + return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 }) + } +} + +// DELETE: Delete a SOMA template +export async function DELETE(req: NextRequest) { + try { + const user = await getSession() + if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 }) + if (user.role !== 'TENANT_ADMIN' && user.role !== 'SERVER_ADMIN') { + return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 }) + } + + const { id } = await req.json() + if (!id) return NextResponse.json({ error: 'ID erforderlich' }, { status: 400 }) + + await (prisma as any).journalCheckTemplate.delete({ where: { id } }) + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Error deleting SOMA template:', error) + return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 }) + } +} diff --git a/src/app/api/tenant/symbols/route.ts b/src/app/api/tenant/symbols/route.ts new file mode 100644 index 0000000..7cde19b --- /dev/null +++ b/src/app/api/tenant/symbols/route.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/db' +import { getSession } from '@/lib/auth' + +// GET: List all icons with their tenant-specific active status +export async function GET() { + try { + const user = await getSession() + if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 }) + if (user.role !== 'TENANT_ADMIN' && user.role !== 'SERVER_ADMIN') { + return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 }) + } + + const tenantId = user.tenantId + 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({ + where: { isActive: true }, + include: { category: { select: { id: true, name: true } } }, + orderBy: [{ category: { sortOrder: 'asc' } }, { name: 'asc' }], + }) + + // Get tenant-specific overrides + 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, + name: icon.name, + fileKey: icon.fileKey, + mimeType: icon.mimeType, + iconType: icon.iconType, + categoryId: icon.categoryId, + categoryName: icon.category?.name || 'Ohne Kategorie', + isActive: overrideMap.has(icon.id) ? overrideMap.get(icon.id) : true, + })) + + return NextResponse.json({ symbols }) + } catch (error) { + console.error('Error fetching tenant symbols:', error) + return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 }) + } +} + +// PATCH: Update symbol visibility for the tenant (bulk) +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' && user.role !== 'SERVER_ADMIN') { + return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 }) + } + + const tenantId = user.tenantId + if (!tenantId) return NextResponse.json({ error: 'Kein Mandant zugeordnet' }, { status: 400 }) + + const { updates } = await req.json() + if (!Array.isArray(updates)) { + return NextResponse.json({ error: 'updates Array erforderlich' }, { status: 400 }) + } + + // Upsert each symbol override + await Promise.all( + updates.map((u: { iconId: string; isActive: boolean }) => + (prisma as any).tenantSymbol.upsert({ + where: { tenantId_iconId: { tenantId, iconId: u.iconId } }, + update: { isActive: u.isActive }, + create: { tenantId, iconId: u.iconId, isActive: u.isActive }, + }) + ) + ) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Error updating tenant symbols:', error) + return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 }) + } +} diff --git a/src/app/globals.css b/src/app/globals.css index bbfcfe3..4f6e618 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -4,25 +4,26 @@ @layer base { :root { - --background: 0 0% 100%; - --foreground: 222.2 84% 4.9%; - --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; + /* Light Mode — optimiert für Einsatz-Kontext (WCAG 2.1 AA) */ + --background: 210 33% 99%; /* #fafbfc — leicht abgesetzt, kein reines Weiss */ + --foreground: 217.2 32.6% 17.5%; /* Slate-800 #1e293b — Primärtext, 12.6:1 Kontrast */ + --card: 0 0% 100%; /* Weisse Karten/Panels */ + --card-foreground: 217.2 32.6% 17.5%; --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; - --primary: 222.2 47.4% 11.2%; + --popover-foreground: 217.2 32.6% 17.5%; + --primary: 222.2 47.4% 11.2%; /* Slate-900 — Buttons, aktive Elemente */ --primary-foreground: 210 40% 98%; - --secondary: 210 40% 96.1%; - --secondary-foreground: 222.2 47.4% 11.2%; - --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; + --secondary: 210 40% 96.1%; /* Slate-100 */ + --secondary-foreground: 217.2 32.6% 17.5%; + --muted: 210 40% 96.1%; /* Slate-100 — Hover, dezente Flächen */ + --muted-foreground: 215.3 19.4% 34.5%; /* Slate-600 #475569 — Sekundärtext, 7.1:1 Kontrast */ --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; - --ring: 222.2 84% 4.9%; + --accent-foreground: 217.2 32.6% 17.5%; + --destructive: 0 72.2% 50.6%; /* Feuerwehr-Rot #dc2626 */ + --destructive-foreground: 0 0% 100%; + --border: 214.3 31.8% 91.4%; /* Slate-200 #e2e8f0 */ + --input: 212.7 26.8% 83.9%; /* Slate-300 #cbd5e1 — stärkere Input-Borders */ + --ring: 217.2 91.2% 59.8%; /* Blau #3b82f6 — Focus-Ring */ --radius: 0.5rem; } diff --git a/src/components/map/map-view.tsx b/src/components/map/map-view.tsx index 3b8ce59..34f7e04 100644 --- a/src/components/map/map-view.tsx +++ b/src/components/map/map-view.tsx @@ -2299,16 +2299,6 @@ export function MapView({
)} - {/* Credit link */} - - mit ♥ von Pepe - - {/* Cursor-following tooltip — always mounted for stable ref */}
, + 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.', }, { - title: 'Neuer Einsatz', - description: 'Erstelle einen neuen Einsatz über das Menü oben links. Gib eine Adresse ein und die Karte fliegt automatisch dorthin.', + title: 'Einsatz erstellen', + icon: , + 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.', targetSelector: '[data-tour="new-project"]', position: 'bottom', }, { title: 'Zeichenwerkzeuge', - description: 'Links findest du alle Werkzeuge: Punkte, Linien, Polygone, Freihand, Pfeile, Text und mehr. Jedes Werkzeug hat ein Tastenkürzel (drücke ? für die Übersicht).', + icon: , + 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.', targetSelector: '[data-tour="toolbar"]', position: 'right', }, { - title: 'Symbole & Karte', - description: 'Rechts findest du die Symbol-Bibliothek. Ziehe Symbole per Drag & Drop auf die Karte. Wechsle zwischen Karte und Journal.', + title: 'Symbole & Sidebar', + icon: , + 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.', targetSelector: '[data-tour="sidebar"]', position: 'left', }, { - title: 'Speichern & Exportieren', - description: 'Dein Einsatz wird automatisch gespeichert. Du kannst ihn auch als PNG oder PDF exportieren.', + title: 'Speichern & Export', + icon: , + 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.', targetSelector: '[data-tour="save"]', position: 'bottom', }, { - title: 'Tastenkürzel', - description: 'Drücke ? oder F1 für eine Übersicht aller Tastenkürzel. Ctrl+S speichert, Ctrl+Z macht rückgängig.', + title: 'Messen & Schlauch-Rechner', + icon: , + 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.', }, { - title: 'Bereit!', - description: 'Das war\'s! Du kannst diese Tour jederzeit über das Benutzermenü erneut starten. Viel Erfolg im Einsatz!', + title: 'Live-Zusammenarbeit', + icon: , + description: 'Mehrere Benutzer können gleichzeitig am selben Einsatz arbeiten. Änderungen werden in Echtzeit synchronisiert — ideal für die Einsatzleitung mit mehreren Operateuren.', + }, + { + title: 'Tastenkürzel (CH)', + icon: , + 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.', + }, + { + title: 'Bereit für den Einsatz!', + icon: , + description: 'Du bist startklar! Diese Tour kannst du jederzeit über dein Benutzermenü (oben rechts → «Tour starten») erneut aufrufen. Viel Erfolg im Einsatz — Feuer frei!', }, ] @@ -192,7 +213,10 @@ export function OnboardingTour({ forceShow = false, onComplete }: OnboardingTour style={getTooltipStyle()} >
-

{step.title}

+
+ {step.icon} +

{step.title}

+