v1.1.0: keyboard shortcuts (CH), onboarding tour, admin projects tab, remember-me login, Luftbild CH removed, hose settings in admin, credit link, font Barlow, map auto-save viewport, rate-limit 10/5min

This commit is contained in:
Pepe Ziberi
2026-02-24 19:49:42 +01:00
parent cb575f9a82
commit d893373bd9
16 changed files with 618 additions and 54 deletions

View File

@@ -58,6 +58,7 @@ import {
} from 'lucide-react'
import Link from 'next/link'
import { TenantDetailDialog } from '@/components/admin/tenant-detail-dialog'
import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog'
// --- Types ---
interface IconCategory {
@@ -214,6 +215,14 @@ export default function AdminPage() {
const [symbolScaleLoading, setSymbolScaleLoading] = useState(false)
const [symbolScaleStatus, setSymbolScaleStatus] = useState<string | null>(null)
// Admin Projects (SERVER_ADMIN)
const [adminProjects, setAdminProjects] = useState<any[]>([])
const [adminProjectsLoading, setAdminProjectsLoading] = useState(false)
const [adminProjectTenantFilter, setAdminProjectTenantFilter] = useState<string>('all')
// Hose Settings (Tenant Admin)
const [isHoseSettingsOpen, setIsHoseSettingsOpen] = useState(false)
// Redirect to login if not authenticated, or to app if not admin
useEffect(() => {
if (authLoading) return
@@ -251,6 +260,25 @@ export default function AdminPage() {
if (user?.role === 'SERVER_ADMIN') fetchGlobalDict()
}, [user?.role])
// Fetch admin projects (SERVER_ADMIN)
const fetchAdminProjects = async (tenantFilter?: string) => {
setAdminProjectsLoading(true)
try {
const url = tenantFilter && tenantFilter !== 'all'
? `/api/admin/projects?tenantId=${tenantFilter}`
: '/api/admin/projects'
const res = await fetch(url)
if (res.ok) {
const data = await res.json()
setAdminProjects(data.projects || [])
}
} catch {}
setAdminProjectsLoading(false)
}
useEffect(() => {
if (user?.role === 'SERVER_ADMIN') fetchAdminProjects()
}, [user?.role])
const fetchData = async () => {
setIsLoading(true)
try {
@@ -683,11 +711,15 @@ export default function AdminPage() {
<div className="container mx-auto py-6 px-4 max-w-7xl">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
{user?.role === 'SERVER_ADMIN' ? (
<TabsList className="grid w-full grid-cols-6 max-w-3xl">
<TabsList className="grid w-full grid-cols-7 max-w-4xl">
<TabsTrigger value="tenants" className="gap-2">
<Shield className="w-4 h-4" />
Mandanten
</TabsTrigger>
<TabsTrigger value="projects" className="gap-2">
<Map className="w-4 h-4" />
Einsätze
</TabsTrigger>
<TabsTrigger value="icons" className="gap-2">
<Image className="w-4 h-4" />
Symbole
@@ -710,7 +742,7 @@ export default function AdminPage() {
</TabsTrigger>
</TabsList>
) : user?.role === 'TENANT_ADMIN' ? (
<TabsList className="grid w-full grid-cols-5 max-w-2xl">
<TabsList className="grid w-full grid-cols-6 max-w-3xl">
<TabsTrigger value="users" className="gap-2">
<Users className="w-4 h-4" />
Benutzer
@@ -719,6 +751,10 @@ export default function AdminPage() {
<ClipboardList className="w-4 h-4" />
Wörterliste
</TabsTrigger>
<TabsTrigger value="hose-types" className="gap-2">
<Settings className="w-4 h-4" />
Schläuche
</TabsTrigger>
<TabsTrigger value="donate" className="gap-2">
<Heart className="w-4 h-4" />
Spenden
@@ -960,6 +996,97 @@ export default function AdminPage() {
</TabsContent>
)}
{/* ===== PROJECTS TAB (SERVER_ADMIN — Einsätze verwalten) ===== */}
{user?.role === 'SERVER_ADMIN' && (
<TabsContent value="projects" className="space-y-4">
<div className="flex items-center justify-between gap-4 flex-wrap">
<p className="text-sm text-muted-foreground">
{adminProjects.length} Einsatz/Einsätze
</p>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Feuerwehr:</span>
<Select value={adminProjectTenantFilter} onValueChange={(val) => { setAdminProjectTenantFilter(val); fetchAdminProjects(val) }}>
<SelectTrigger className="w-[220px]">
<SelectValue placeholder="Alle Mandanten" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alle Mandanten</SelectItem>
{tenants.map(t => (
<SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{adminProjectsLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : adminProjects.length === 0 ? (
<p className="text-center text-muted-foreground py-8">Keine Einsätze gefunden.</p>
) : (
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left px-4 py-2.5 font-medium">Einsatz-Nr</th>
<th className="text-left px-4 py-2.5 font-medium">Titel</th>
<th className="text-left px-4 py-2.5 font-medium">Ort</th>
<th className="text-left px-4 py-2.5 font-medium">Erstellt von</th>
<th className="text-left px-4 py-2.5 font-medium">Feuerwehr</th>
<th className="text-left px-4 py-2.5 font-medium">Elemente</th>
<th className="text-left px-4 py-2.5 font-medium">Geändert</th>
<th className="text-left px-4 py-2.5 font-medium">Aktion</th>
</tr>
</thead>
<tbody className="divide-y">
{adminProjects.map((p: any) => (
<tr key={p.id} className="hover:bg-muted/30">
<td className="px-4 py-2.5 font-mono text-xs">{p.einsatzNr || '—'}</td>
<td className="px-4 py-2.5 font-semibold">{p.title}</td>
<td className="px-4 py-2.5 text-muted-foreground truncate max-w-[200px]">{p.location || '—'}</td>
<td className="px-4 py-2.5">
<span className="text-xs">{p.owner?.name || p.owner?.email || '—'}</span>
</td>
<td className="px-4 py-2.5">
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded">{p.tenant?.name || '—'}</span>
</td>
<td className="px-4 py-2.5 text-center">{p._count?.features || 0}</td>
<td className="px-4 py-2.5 text-xs text-muted-foreground">{new Date(p.updatedAt).toLocaleString('de-CH')}</td>
<td className="px-4 py-2.5">
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => window.open(`/app?project=${p.id}`, '_blank')}>
<Eye className="w-3 h-3 mr-1" />
Öffnen
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</TabsContent>
)}
{/* ===== HOSE TYPES TAB (Schlauchtypen) ===== */}
<TabsContent value="hose-types" className="space-y-4">
<div className="border rounded-lg p-6">
<h3 className="font-semibold text-lg mb-2 flex items-center gap-2">
<Settings className="w-5 h-5" />
Schlauchtypen verwalten
</h3>
<p className="text-sm text-muted-foreground mb-4">
Konfiguriere die Schlauchtypen für die Druckberechnung im Messwerkzeug. Der Standard-Schlauch wird automatisch für neue Berechnungen verwendet.
</p>
<Button variant="outline" onClick={() => setIsHoseSettingsOpen(true)}>
<Settings className="w-4 h-4 mr-2" />
Schlauchtypen bearbeiten
</Button>
</div>
<HoseSettingsDialog open={isHoseSettingsOpen} onOpenChange={setIsHoseSettingsOpen} />
</TabsContent>
{/* ===== SUGGESTIONS TAB (Word Library) ===== */}
<TabsContent value="suggestions" className="space-y-4">
<div className="border rounded-lg p-6">