Initial commit: Lageplan v1.0 - Next.js 15.5, React 19

This commit is contained in:
Pepe Ziberi
2026-02-21 11:57:44 +01:00
commit adf3dc8c1d
167 changed files with 34265 additions and 0 deletions

View File

@@ -0,0 +1,476 @@
'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 {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from '@/components/ui/dialog'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useToast } from '@/components/ui/use-toast'
import {
Shield, Trash2, UserPlus, Loader2, Ban, CheckCircle, Clock, AlertTriangle, Copy, Mail, MailX, Upload, X,
} from 'lucide-react'
interface TenantDetail {
id: string
name: string
slug: string
description: string | null
isActive: boolean
contactEmail: string | null
contactPhone: string | null
address: string | null
plan: string
subscriptionStatus: string
trialEndsAt: string | null
subscriptionEndsAt: string | null
maxUsers: number
maxProjects: number
logoUrl: string | null
notes: string | null
createdAt: string
memberships: { id: string; role: string; user: { id: string; email: string; name: string; role: string; lastLoginAt: string | null } }[]
_count: { projects: number; memberships: number }
}
const PLANS = [
{ value: 'FREE', label: 'Free', desc: '5 Benutzer, 10 Projekte', color: 'bg-gray-100 text-gray-700' },
{ value: 'PRO', label: 'Pro', desc: 'CHF 45/Monat — Unbegrenzte Benutzer & Projekte', color: 'bg-blue-100 text-blue-700' },
]
const STATUSES = [
{ value: 'ACTIVE', label: 'Aktiv', icon: CheckCircle, color: 'text-green-600' },
{ value: 'SUSPENDED', label: 'Gesperrt', icon: Ban, color: 'text-red-600' },
{ value: 'EXPIRED', label: 'Abgelaufen', icon: AlertTriangle, color: 'text-orange-600' },
{ value: 'CANCELLED', label: 'Gekündigt', icon: Ban, color: 'text-gray-600' },
]
interface Props {
tenantId: string | null
open: boolean
onOpenChange: (open: boolean) => void
onUpdated: () => void
}
export function TenantDetailDialog({ tenantId, open, onOpenChange, onUpdated }: Props) {
const { toast } = useToast()
const [loading, setLoading] = useState(false)
const [tenant, setTenant] = useState<TenantDetail | null>(null)
const [tab, setTab] = useState('info')
// Editable fields
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [contactEmail, setContactEmail] = useState('')
const [contactPhone, setContactPhone] = useState('')
const [address, setAddress] = useState('')
const [plan, setPlan] = useState('FREE')
const [status, setStatus] = useState('TRIAL')
const [maxUsers, setMaxUsers] = useState(5)
const [maxProjects, setMaxProjects] = useState(10)
const [trialEndsAt, setTrialEndsAt] = useState('')
const [subscriptionEndsAt, setSubscriptionEndsAt] = useState('')
const [notes, setNotes] = useState('')
// Add member (NEW user)
const [newUserName, setNewUserName] = useState('')
const [newUserEmail, setNewUserEmail] = useState('')
const [newUserRole, setNewUserRole] = useState('OPERATOR')
const [addingUser, setAddingUser] = useState(false)
const [createdUserInfo, setCreatedUserInfo] = useState<{ email: string; tempPassword: string; emailSent: boolean } | null>(null)
// Logo
const [uploadingLogo, setUploadingLogo] = useState(false)
// Delete confirmation
const [confirmDelete, setConfirmDelete] = useState(false)
const [deleteSlug, setDeleteSlug] = useState('')
useEffect(() => {
if (tenantId && open) {
fetchTenant()
setCreatedUserInfo(null)
setConfirmDelete(false)
setDeleteSlug('')
}
}, [tenantId, open])
const fetchTenant = async () => {
if (!tenantId) return
setLoading(true)
try {
const res = await fetch(`/api/admin/tenants/${tenantId}`)
if (res.ok) {
const data = await res.json()
const t = data.tenant
setTenant(t)
setName(t.name)
setDescription(t.description || '')
setContactEmail(t.contactEmail || '')
setContactPhone(t.contactPhone || '')
setAddress(t.address || '')
setPlan(t.plan)
setStatus(t.subscriptionStatus)
setMaxUsers(t.maxUsers)
setMaxProjects(t.maxProjects)
setTrialEndsAt(t.trialEndsAt ? t.trialEndsAt.split('T')[0] : '')
setSubscriptionEndsAt(t.subscriptionEndsAt ? t.subscriptionEndsAt.split('T')[0] : '')
setNotes(t.notes || '')
}
} catch (e) {
console.error(e)
} finally {
setLoading(false)
}
}
const handleSave = async () => {
if (!tenantId) return
try {
const res = await fetch(`/api/admin/tenants/${tenantId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name, description: description || null,
contactEmail: contactEmail || null, contactPhone: contactPhone || null, address: address || null,
plan, subscriptionStatus: status, maxUsers, maxProjects,
trialEndsAt: trialEndsAt || null, subscriptionEndsAt: subscriptionEndsAt || null,
notes: notes || null,
}),
})
if (res.ok) {
toast({ title: 'Mandant aktualisiert' })
onUpdated()
fetchTenant()
} else {
const err = await res.json()
throw new Error(err.error)
}
} catch (error) {
toast({ title: 'Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' })
}
}
const handleSuspend = async () => {
if (!tenantId) return
const newStatus = status === 'SUSPENDED' ? 'ACTIVE' : 'SUSPENDED'
try {
const res = await fetch(`/api/admin/tenants/${tenantId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subscriptionStatus: newStatus, isActive: newStatus !== 'SUSPENDED' }),
})
if (res.ok) {
toast({ title: newStatus === 'SUSPENDED' ? 'Mandant gesperrt' : 'Mandant aktiviert' })
setStatus(newStatus)
onUpdated()
fetchTenant()
}
} catch {
toast({ title: 'Fehler', variant: 'destructive' })
}
}
const handleDeleteTenant = async () => {
if (!tenantId || deleteSlug !== tenant?.slug) return
try {
const res = await fetch(`/api/admin/tenants/${tenantId}`, { method: 'DELETE' })
if (res.ok) {
toast({ title: 'Mandant gelöscht' })
onOpenChange(false)
onUpdated()
} else {
const err = await res.json()
throw new Error(err.error)
}
} catch (error) {
toast({ title: 'Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' })
}
}
const handleAddMember = async () => {
if (!tenantId || !newUserName || !newUserEmail) return
setAddingUser(true)
setCreatedUserInfo(null)
try {
const res = await fetch(`/api/admin/tenants/${tenantId}/members`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newUserName, email: newUserEmail, role: newUserRole }),
})
const data = await res.json()
if (res.ok) {
setCreatedUserInfo({ email: newUserEmail.toLowerCase(), tempPassword: data.tempPassword, emailSent: data.emailSent })
toast({ title: 'Benutzer erstellt und hinzugefügt' })
setNewUserName('')
setNewUserEmail('')
setNewUserRole('OPERATOR')
fetchTenant()
onUpdated()
} else {
throw new Error(data.error)
}
} catch (error) {
toast({ title: 'Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' })
} finally {
setAddingUser(false)
}
}
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!tenantId || !e.target.files?.[0]) return
setUploadingLogo(true)
try {
const formData = new FormData()
formData.append('logo', e.target.files[0])
const res = await fetch(`/api/admin/tenants/${tenantId}/logo`, { method: 'POST', body: formData })
const data = await res.json()
if (res.ok) {
toast({ title: 'Logo hochgeladen' })
fetchTenant()
} else {
throw new Error(data.error)
}
} catch (error) {
toast({ title: 'Fehler', description: error instanceof Error ? error.message : 'Upload fehlgeschlagen', variant: 'destructive' })
} finally {
setUploadingLogo(false)
e.target.value = ''
}
}
const handleLogoDelete = async () => {
if (!tenantId) return
try {
const res = await fetch(`/api/admin/tenants/${tenantId}/logo`, { method: 'DELETE' })
if (res.ok) {
toast({ title: 'Logo entfernt' })
fetchTenant()
}
} catch {
toast({ title: 'Fehler', variant: 'destructive' })
}
}
const handleRemoveMember = async (membershipId: string) => {
if (!tenantId || !confirm('Benutzer wirklich entfernen? Der Benutzer und seine Daten werden gelöscht.')) return
try {
const res = await fetch(`/api/admin/tenants/${tenantId}/members?membershipId=${membershipId}&deleteUser=true`, { method: 'DELETE' })
if (res.ok) {
toast({ title: 'Benutzer entfernt' })
fetchTenant()
onUpdated()
}
} catch {
toast({ title: 'Fehler', variant: 'destructive' })
}
}
const statusInfo = STATUSES.find(s => s.value === status)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="w-5 h-5" />
{tenant?.name || 'Mandant'}
{statusInfo && (
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUSES.find(s => s.value === tenant?.subscriptionStatus)?.color || ''}`}>
{statusInfo.label}
</span>
)}
</DialogTitle>
</DialogHeader>
{loading ? (
<div className="flex justify-center py-8"><Loader2 className="w-6 h-6 animate-spin" /></div>
) : tenant ? (
<Tabs value={tab} onValueChange={setTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="info">Stammdaten</TabsTrigger>
<TabsTrigger value="members">Benutzer ({tenant.memberships.length})</TabsTrigger>
</TabsList>
{/* === INFO TAB === */}
<TabsContent value="info" className="space-y-3 mt-3">
{/* Logo */}
<div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-lg border bg-muted flex items-center justify-center overflow-hidden shrink-0">
{tenant.logoUrl ? (
<img src={tenant.logoUrl.startsWith('/') ? tenant.logoUrl : `/api/admin/tenants/${tenantId}/logo/serve`} alt="Logo" className="w-full h-full object-contain" />
) : (
<Shield className="w-8 h-8 text-muted-foreground" />
)}
</div>
<div className="space-y-1">
<Label className="text-xs">Logo</Label>
<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</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<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)} /></div>
<div className="grid grid-cols-2 gap-3">
<div><Label className="text-xs">Kontakt E-Mail</Label><Input type="email" value={contactEmail} onChange={e => setContactEmail(e.target.value)} placeholder="kontakt@firma.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>
<div><Label className="text-xs">Interne Notizen</Label><Input value={notes} onChange={e => setNotes(e.target.value)} placeholder="Interne Anmerkungen..." /></div>
<div className="flex gap-2 pt-2">
<Button onClick={handleSave} disabled={!name.trim()}>Speichern</Button>
<Button variant={status === 'SUSPENDED' ? 'default' : 'destructive'} onClick={handleSuspend}>
{status === 'SUSPENDED' ? <><CheckCircle className="w-4 h-4 mr-1" /> Aktivieren</> : <><Ban className="w-4 h-4 mr-1" /> Sperren</>}
</Button>
</div>
{/* Delete section */}
<div className="border-t pt-4 mt-4">
{!confirmDelete ? (
<Button variant="ghost" className="text-destructive hover:text-destructive text-xs" onClick={() => setConfirmDelete(true)}>
<Trash2 className="w-3.5 h-3.5 mr-1" /> Mandant löschen...
</Button>
) : (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 space-y-3">
<p className="text-sm font-medium text-red-800">Mandant unwiderruflich löschen?</p>
<p className="text-xs text-red-600">Alle Benutzer, Projekte und Daten dieses Mandanten werden gelöscht. Geben Sie <strong>{tenant.slug}</strong> ein zur Bestätigung:</p>
<Input
value={deleteSlug}
onChange={e => setDeleteSlug(e.target.value)}
placeholder={tenant.slug}
className="font-mono text-sm"
/>
<div className="flex gap-2">
<Button variant="destructive" size="sm" onClick={handleDeleteTenant} disabled={deleteSlug !== tenant.slug}>
<Trash2 className="w-3.5 h-3.5 mr-1" /> Endgültig löschen
</Button>
<Button variant="outline" size="sm" onClick={() => { setConfirmDelete(false); setDeleteSlug('') }}>Abbrechen</Button>
</div>
</div>
)}
</div>
</TabsContent>
{/* === MEMBERS TAB === */}
<TabsContent value="members" className="space-y-4 mt-3">
<div className="border rounded-lg p-4 space-y-3 bg-muted/20">
<h4 className="text-sm font-semibold flex items-center gap-1.5"><UserPlus className="w-4 h-4" /> Neuen Benutzer erstellen</h4>
<div className="grid grid-cols-2 gap-3">
<div><Label className="text-xs">Name</Label><Input value={newUserName} onChange={e => setNewUserName(e.target.value)} placeholder="Vor- und Nachname" /></div>
<div><Label className="text-xs">E-Mail</Label><Input type="email" value={newUserEmail} onChange={e => setNewUserEmail(e.target.value)} placeholder="name@feuerwehr.ch" /></div>
</div>
<div className="flex gap-3 items-end">
<div className="w-44">
<Label className="text-xs">Rolle</Label>
<Select value={newUserRole} onValueChange={setNewUserRole}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="TENANT_ADMIN">Admin</SelectItem>
<SelectItem value="OPERATOR">Bediener</SelectItem>
<SelectItem value="VIEWER">Betrachter</SelectItem>
</SelectContent>
</Select>
</div>
<Button onClick={handleAddMember} disabled={!newUserName.trim() || !newUserEmail.trim() || addingUser}>
{addingUser ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <UserPlus className="w-4 h-4 mr-1.5" />}
Benutzer erstellen
</Button>
</div>
<p className="text-[11px] text-muted-foreground">Ein temporäres Passwort wird automatisch generiert. Falls SMTP konfiguriert ist, wird eine Willkommens-E-Mail gesendet.</p>
</div>
{/* Created user info */}
{createdUserInfo && (
<div className="border rounded-lg p-4 bg-green-50 border-green-200 space-y-2">
<h4 className="text-sm font-semibold text-green-800 flex items-center gap-1.5">
<CheckCircle className="w-4 h-4" /> Benutzer erstellt
</h4>
<div className="grid grid-cols-2 gap-2 text-sm">
<div><span className="text-green-700 font-medium">E-Mail:</span> {createdUserInfo.email}</div>
<div className="flex items-center gap-2">
<span className="text-green-700 font-medium">Passwort:</span>
<code className="bg-white px-2 py-0.5 rounded text-sm font-mono">{createdUserInfo.tempPassword}</code>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => {
navigator.clipboard.writeText(createdUserInfo.tempPassword)
toast({ title: 'Passwort kopiert' })
}}>
<Copy className="w-3.5 h-3.5" />
</Button>
</div>
</div>
<div className="flex items-center gap-1.5 text-xs">
{createdUserInfo.emailSent ? (
<><Mail className="w-3.5 h-3.5 text-green-600" /><span className="text-green-700">Willkommens-E-Mail wurde gesendet</span></>
) : (
<><MailX className="w-3.5 h-3.5 text-orange-500" /><span className="text-orange-600">Keine E-Mail gesendet (SMTP nicht konfiguriert). Bitte Passwort manuell weitergeben.</span></>
)}
</div>
</div>
)}
{/* Members list */}
<div className="border rounded-lg divide-y">
{tenant.memberships.length === 0 ? (
<div className="text-center text-muted-foreground py-6 text-sm">Keine Benutzer zugeordnet</div>
) : tenant.memberships.map(m => (
<div key={m.id} className="flex items-center justify-between px-3 py-2 text-sm">
<div className="flex items-center gap-2">
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-[10px] font-bold text-white ${
m.role === 'TENANT_ADMIN' ? 'bg-orange-500' : m.role === 'OPERATOR' ? 'bg-blue-500' : 'bg-gray-400'
}`}>{m.user.name.charAt(0).toUpperCase()}</div>
<div>
<p className="font-medium">{m.user.name}</p>
<p className="text-xs text-muted-foreground">{m.user.email}</p>
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-[10px] text-muted-foreground" title="Zuletzt online">
{m.user.lastLoginAt
? new Date(m.user.lastLoginAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: '2-digit' }) + ' ' + new Date(m.user.lastLoginAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
: 'Nie eingeloggt'}
</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${
m.role === 'TENANT_ADMIN' ? 'bg-orange-100 text-orange-700' :
m.role === 'OPERATOR' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-700'
}`}>{m.role === 'TENANT_ADMIN' ? 'Admin' : m.role === 'OPERATOR' ? 'Bediener' : 'Betrachter'}</span>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => handleRemoveMember(m.id)}>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
{tenant.memberships.length} Benutzer
</p>
</TabsContent>
{/* Subscription tab removed — SERVER_ADMIN only suspends/activates orgs */}
</Tabs>
) : (
<div className="text-center text-muted-foreground py-8">Mandant nicht gefunden</div>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,313 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Plus, Trash2, Save, Star, Loader2 } from 'lucide-react'
interface HoseType {
id: string
name: string
diameterMm: number
lengthPerPieceM: number
flowRateLpm: number
frictionCoeff: number
description: string | null
isDefault: boolean
isActive: boolean
sortOrder: number
}
interface HoseSettingsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function HoseSettingsDialog({ open, onOpenChange }: HoseSettingsDialogProps) {
const [hoseTypes, setHoseTypes] = useState<HoseType[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [showNewForm, setShowNewForm] = useState(false)
const [formData, setFormData] = useState({
name: '',
diameterMm: '',
lengthPerPieceM: '10',
flowRateLpm: '',
frictionCoeff: '',
description: '',
isDefault: false,
})
const fetchHoseTypes = useCallback(async () => {
setIsLoading(true)
try {
const res = await fetch('/api/hose-types')
if (res.ok) {
const data = await res.json()
setHoseTypes(data.hoseTypes || [])
}
} catch (err) {
console.error('Failed to load hose types:', err)
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
if (open) fetchHoseTypes()
}, [open, fetchHoseTypes])
const resetForm = () => {
setFormData({ name: '', diameterMm: '', lengthPerPieceM: '10', flowRateLpm: '', frictionCoeff: '', description: '', isDefault: false })
setEditingId(null)
setShowNewForm(false)
}
const startEdit = (ht: HoseType) => {
setFormData({
name: ht.name,
diameterMm: String(ht.diameterMm),
lengthPerPieceM: String(ht.lengthPerPieceM),
flowRateLpm: String(ht.flowRateLpm),
frictionCoeff: String(ht.frictionCoeff),
description: ht.description || '',
isDefault: ht.isDefault,
})
setEditingId(ht.id)
setShowNewForm(false)
}
const handleSave = async () => {
if (!formData.name || !formData.diameterMm || !formData.flowRateLpm || !formData.frictionCoeff) return
setIsSaving(true)
try {
const url = editingId ? `/api/hose-types/${editingId}` : '/api/hose-types'
const method = editingId ? 'PUT' : 'POST'
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
})
if (res.ok) {
await fetchHoseTypes()
resetForm()
}
} catch (err) {
console.error('Save failed:', err)
} finally {
setIsSaving(false)
}
}
const handleDelete = async (id: string) => {
if (!confirm('Schlauchtyp wirklich löschen?')) return
try {
await fetch(`/api/hose-types/${id}`, { method: 'DELETE' })
await fetchHoseTypes()
if (editingId === id) resetForm()
} catch (err) {
console.error('Delete failed:', err)
}
}
const handleSetDefault = async (id: string) => {
try {
await fetch(`/api/hose-types/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isDefault: true }),
})
await fetchHoseTypes()
} catch (err) {
console.error('Set default failed:', err)
}
}
const frictionPer100m = (coeff: string, flow: string) => {
const c = parseFloat(coeff)
const q = parseFloat(flow)
if (!c || !q) return '-'
return (c * Math.pow(q / 100, 2)).toFixed(2)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-lg">
🚒 Schlauchtypen verwalten
</DialogTitle>
</DialogHeader>
<div className="text-sm text-muted-foreground mb-2">
Konfiguriere die Schlauchtypen für die Druckberechnung im Messwerkzeug. Der Standard-Schlauch wird automatisch für neue Berechnungen verwendet.
</div>
<ScrollArea className="flex-1 max-h-[50vh]">
{isLoading ? (
<div className="flex items-center justify-center py-8 gap-2 text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" /> Laden...
</div>
) : hoseTypes.length === 0 ? (
<div className="text-center text-muted-foreground py-8 text-sm">
Keine Schlauchtypen vorhanden. Erstelle den ersten!
</div>
) : (
<div className="space-y-2">
{hoseTypes.map((ht) => (
<div
key={ht.id}
className={`p-3 border rounded-lg cursor-pointer transition-colors ${
editingId === ht.id ? 'border-primary bg-primary/5' : 'hover:bg-accent'
}`}
onClick={() => startEdit(ht)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm">{ht.name}</span>
{ht.isDefault && (
<span className="text-xs bg-primary/20 text-primary px-2 py-0.5 rounded-full font-medium">Standard</span>
)}
</div>
<div className="flex items-center gap-1">
{!ht.isDefault && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => { e.stopPropagation(); handleSetDefault(ht.id) }}
title="Als Standard setzen"
>
<Star className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={(e) => { e.stopPropagation(); handleDelete(ht.id) }}
title="Löschen"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-4 gap-2 mt-2 text-xs text-muted-foreground">
<div><span className="font-medium"></span> {ht.diameterMm}mm</div>
<div><span className="font-medium">L</span> {ht.lengthPerPieceM}m/Stk</div>
<div><span className="font-medium">Q</span> {ht.flowRateLpm} l/min</div>
<div><span className="font-medium">R</span> {frictionPer100m(String(ht.frictionCoeff), String(ht.flowRateLpm))} bar/100m</div>
</div>
{ht.description && (
<p className="text-xs text-muted-foreground mt-1">{ht.description}</p>
)}
</div>
))}
</div>
)}
</ScrollArea>
{/* Edit/Create Form */}
{(editingId || showNewForm) && (
<div className="border-t pt-3 mt-2">
<h4 className="text-sm font-semibold mb-2">{editingId ? 'Bearbeiten' : 'Neuer Schlauchtyp'}</h4>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-muted-foreground">Name</label>
<Input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="z.B. 55mm Transportleitung"
className="h-9 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Durchmesser (mm)</label>
<Input
type="number"
value={formData.diameterMm}
onChange={(e) => setFormData({ ...formData, diameterMm: e.target.value })}
placeholder="55"
className="h-9 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Schlauchlänge pro Stück (m)</label>
<Input
type="number"
value={formData.lengthPerPieceM}
onChange={(e) => setFormData({ ...formData, lengthPerPieceM: e.target.value })}
placeholder="10"
className="h-9 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Durchfluss (l/min)</label>
<Input
type="number"
value={formData.flowRateLpm}
onChange={(e) => setFormData({ ...formData, flowRateLpm: e.target.value })}
placeholder="500"
className="h-9 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Reibungskoeffizient (c)</label>
<Input
type="number"
step="0.001"
value={formData.frictionCoeff}
onChange={(e) => setFormData({ ...formData, frictionCoeff: e.target.value })}
placeholder="0.034"
className="h-9 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">
Reibung/100m: <strong>{frictionPer100m(formData.frictionCoeff, formData.flowRateLpm)} bar</strong>
</label>
<Input
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Beschreibung (optional)"
className="h-9 text-sm"
/>
</div>
</div>
<div className="flex items-center gap-2 mt-3">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={formData.isDefault}
onChange={(e) => setFormData({ ...formData, isDefault: e.target.checked })}
className="w-4 h-4"
/>
Als Standard setzen
</label>
<div className="flex-1" />
<Button variant="outline" size="sm" onClick={resetForm}>Abbrechen</Button>
<Button size="sm" onClick={handleSave} disabled={isSaving}>
<Save className="w-4 h-4 mr-1" />
{isSaving ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</div>
)}
{/* Add button */}
{!showNewForm && !editingId && (
<Button
variant="outline"
className="w-full mt-2"
onClick={() => { resetForm(); setShowNewForm(true) }}
>
<Plus className="w-4 h-4 mr-2" /> Neuen Schlauchtyp hinzufügen
</Button>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,88 @@
'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 {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
interface LineLabelDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onConfirm: (label: string) => void
onSkip: () => void
lineType: 'linestring' | 'arrow' | 'polygon'
}
export function LineLabelDialog({ open, onOpenChange, onConfirm, onSkip, lineType }: LineLabelDialogProps) {
const [label, setLabel] = useState('')
useEffect(() => {
if (open) setLabel('')
}, [open])
const handleConfirm = () => {
onConfirm(label.trim())
setLabel('')
}
const handleSkip = () => {
onSkip()
setLabel('')
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
if (label.trim()) {
handleConfirm()
} else {
handleSkip()
}
}
}
const title = lineType === 'polygon' ? 'Fläche beschriften' : 'Leitung beschriften'
const placeholder = lineType === 'polygon'
? 'z.B. Brandzone, Sperrgebiet...'
: 'z.B. 1, L2, Zuleitung...'
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm top-[12%] translate-y-0">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<div className="space-y-1.5">
<Label htmlFor="line-label">Bezeichnung (optional)</Label>
<Input
id="line-label"
value={label}
onChange={(e) => setLabel(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
autoFocus
/>
</div>
<p className="text-xs text-muted-foreground">
Wird am Mittelpunkt der Leitung auf der Karte angezeigt. Leer lassen für keine Beschriftung.
</p>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={handleSkip}>
Ohne Label
</Button>
<Button onClick={handleConfirm} disabled={!label.trim()}>
Beschriften
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,348 @@
'use client'
import { useState, useRef, useEffect, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} 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'
interface NominatimResult {
place_id: number
display_name: string
lat: string
lon: string
type: string
address?: {
road?: string
house_number?: string
postcode?: string
city?: string
town?: string
village?: string
municipality?: string
state?: string
}
}
interface ProjectDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onProjectCreated: (project: Project) => void
}
export function ProjectDialog({
open,
onOpenChange,
onProjectCreated,
}: ProjectDialogProps) {
const [title, setTitle] = useState('')
const [location, setLocation] = useState('')
const [description, setDescription] = useState('')
const [einsatzleiter, setEinsatzleiter] = useState('')
const [journalfuehrer, setJournalfuehrer] = useState('')
const [isCreating, setIsCreating] = useState(false)
const { toast } = useToast()
// Address autocomplete state
const [suggestions, setSuggestions] = useState<NominatimResult[]>([])
const [isSearching, setIsSearching] = useState(false)
const [showSuggestions, setShowSuggestions] = useState(false)
const [selectedCoords, setSelectedCoords] = useState<{ lat: number; lng: number } | null>(null)
const debounceRef = useRef<NodeJS.Timeout | null>(null)
const suggestionsRef = useRef<HTMLDivElement>(null)
// Debounced Nominatim search
const searchAddress = useCallback((query: string) => {
if (debounceRef.current) clearTimeout(debounceRef.current)
if (query.length < 3) {
setSuggestions([])
setShowSuggestions(false)
return
}
debounceRef.current = setTimeout(async () => {
setIsSearching(true)
try {
const res = await fetch(
`https://nominatim.openstreetmap.org/search?` +
`q=${encodeURIComponent(query)}&format=json&addressdetails=1&limit=5&countrycodes=ch,de,at,li,fr,it`
)
if (res.ok) {
const data: NominatimResult[] = await res.json()
setSuggestions(data)
setShowSuggestions(data.length > 0)
}
} catch (e) {
console.warn('Nominatim search failed:', e)
} finally {
setIsSearching(false)
}
}, 350)
}, [])
const handleLocationChange = (value: string) => {
setLocation(value)
setSelectedCoords(null) // Clear coords when typing
searchAddress(value)
}
const handleSelectSuggestion = (result: NominatimResult) => {
// Build a clean display name
const addr = result.address
let displayName = result.display_name
if (addr) {
const parts: string[] = []
if (addr.road) {
parts.push(addr.road + (addr.house_number ? ' ' + addr.house_number : ''))
}
const city = addr.city || addr.town || addr.village || addr.municipality
if (addr.postcode && city) {
parts.push(`${addr.postcode} ${city}`)
} else if (city) {
parts.push(city)
}
if (parts.length > 0) displayName = parts.join(', ')
}
setLocation(displayName)
setSelectedCoords({ lat: parseFloat(result.lat), lng: parseFloat(result.lon) })
setSuggestions([])
setShowSuggestions(false)
}
// Close suggestions on click outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (suggestionsRef.current && !suggestionsRef.current.contains(e.target as Node)) {
setShowSuggestions(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleCreate = async () => {
if (!title.trim()) {
toast({
title: 'Fehler',
description: 'Bitte geben Sie einen Titel ein.',
variant: 'destructive',
})
return
}
setIsCreating(true)
try {
const body: any = {
title: title.trim(),
location: location.trim() || undefined,
description: description.trim() || undefined,
einsatzleiter: einsatzleiter.trim() || undefined,
journalfuehrer: journalfuehrer.trim() || undefined,
}
// If an address was selected with coordinates, set mapCenter
if (selectedCoords) {
body.mapCenter = { lng: selectedCoords.lng, lat: selectedCoords.lat }
body.mapZoom = 17
}
const res = await fetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || 'Einsatz konnte nicht erstellt werden')
}
const data = await res.json()
onProjectCreated(data.project)
// Reset form
setTitle('')
setLocation('')
setDescription('')
setEinsatzleiter('')
setJournalfuehrer('')
setSelectedCoords(null)
setSuggestions([])
} catch (error) {
toast({
title: 'Fehler',
description: error instanceof Error ? error.message : 'Unbekannter Fehler',
variant: 'destructive',
})
} finally {
setIsCreating(false)
}
}
const handleClose = () => {
if (!isCreating) {
setTitle('')
setLocation('')
setDescription('')
setEinsatzleiter('')
setJournalfuehrer('')
setSelectedCoords(null)
setSuggestions([])
onOpenChange(false)
}
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Neuer Einsatz</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="project-title">Titel *</Label>
<Input
id="project-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="z.B. Wohnungsbrand Musterstrasse"
disabled={isCreating}
/>
</div>
<div className="space-y-2">
<Label htmlFor="project-location">
Einsatzort
{selectedCoords && (
<span className="ml-2 text-xs text-green-600 font-normal inline-flex items-center gap-1">
<MapPin className="w-3 h-3" /> Koordinaten gesetzt
</span>
)}
</Label>
<div className="relative" ref={suggestionsRef}>
<div className="relative">
<Input
id="project-location"
value={location}
onChange={(e) => handleLocationChange(e.target.value)}
placeholder="Adresse eingeben — z.B. Bahnhofstrasse 1, Zürich"
disabled={isCreating}
autoComplete="off"
className={selectedCoords ? 'pr-8 border-green-300 focus:ring-green-500' : ''}
/>
{isSearching && (
<Loader2 className="absolute right-2.5 top-2.5 w-4 h-4 animate-spin text-muted-foreground" />
)}
{selectedCoords && !isSearching && (
<button
type="button"
onClick={() => { setSelectedCoords(null); setLocation('') }}
className="absolute right-2.5 top-2.5 text-muted-foreground hover:text-foreground"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Autocomplete dropdown */}
{showSuggestions && suggestions.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-60 overflow-auto">
{suggestions.map((s) => {
const addr = s.address
const city = addr?.city || addr?.town || addr?.village || addr?.municipality || ''
return (
<button
key={s.place_id}
type="button"
onClick={() => handleSelectSuggestion(s)}
className="w-full text-left px-3 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-800 border-b border-gray-100 dark:border-gray-800 last:border-0 transition-colors"
>
<div className="flex items-start gap-2">
<MapPin className="w-4 h-4 text-red-500 mt-0.5 shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{addr?.road
? `${addr.road}${addr.house_number ? ' ' + addr.house_number : ''}`
: s.display_name.split(',')[0]
}
</p>
<p className="text-xs text-gray-500 truncate">
{addr?.postcode ? `${addr.postcode} ` : ''}{city}
{addr?.state ? `, ${addr.state}` : ''}
</p>
</div>
</div>
</button>
)
})}
</div>
)}
</div>
<p className="text-xs text-muted-foreground">
Adresse suchen die Karte springt automatisch zum Einsatzort
</p>
</div>
<div className="space-y-2">
<Label htmlFor="project-description">Beschreibung</Label>
<Input
id="project-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optionale Notizen zum Einsatz"
disabled={isCreating}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="project-einsatzleiter">Einsatzleiter</Label>
<Input
id="project-einsatzleiter"
value={einsatzleiter}
onChange={(e) => setEinsatzleiter(e.target.value)}
placeholder="Name"
disabled={isCreating}
/>
</div>
<div className="space-y-2">
<Label htmlFor="project-journalfuehrer">Journalführer</Label>
<Input
id="project-journalfuehrer"
value={journalfuehrer}
onChange={(e) => setJournalfuehrer(e.target.value)}
placeholder="Name"
disabled={isCreating}
/>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleClose}
disabled={isCreating}
>
Abbrechen
</Button>
<Button
onClick={handleCreate}
disabled={isCreating || !title.trim()}
>
{isCreating ? 'Erstellen...' : 'Erstellen'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,90 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
interface TextDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onConfirm: (text: string, fontSize: number) => void
selectedColor: string
}
export function TextDialog({ open, onOpenChange, onConfirm, selectedColor }: TextDialogProps) {
const [text, setText] = useState('')
const [fontSize, setFontSize] = useState(16)
const handleConfirm = () => {
if (text.trim()) {
onConfirm(text.trim(), fontSize)
setText('')
setFontSize(16)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleConfirm()
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md top-[12%] translate-y-0">
<DialogHeader>
<DialogTitle>Text platzieren</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="text-input">Text</Label>
<Input
id="text-input"
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="z.B. Zugang Süd, Brandstelle, etc."
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="font-size">Schriftgrösse: {fontSize}px</Label>
<input
id="font-size"
type="range"
min="10"
max="32"
value={fontSize}
onChange={(e) => setFontSize(Number(e.target.value))}
className="w-full"
/>
</div>
<div className="flex items-center gap-2">
<Label>Vorschau:</Label>
<span
style={{ fontSize: `${fontSize}px`, color: selectedColor, fontWeight: 'bold' }}
>
{text || 'Beispieltext'}
</span>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Abbrechen
</Button>
<Button onClick={handleConfirm} disabled={!text.trim()}>
Platzieren
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,134 @@
'use client'
import { useState } from 'react'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Button } from '@/components/ui/button'
import {
Eye,
EyeOff,
Trash2,
GripVertical,
MapPin,
Minus,
Square,
Circle,
Hexagon,
Type,
ArrowRight,
} from 'lucide-react'
export interface LayerItem {
id: string
kind: string
name?: string
isVisible: boolean
iconId?: string
properties?: Record<string, unknown>
}
interface LayerListProps {
items: LayerItem[]
selectedId: string | null
onSelect: (id: string) => void
onToggleVisibility: (id: string) => void
onDelete: (id: string) => void
canEdit: boolean
}
const kindIcons: Record<string, React.ReactNode> = {
SYMBOL: <MapPin className="w-4 h-4" />,
LINE: <Minus className="w-4 h-4" />,
POLYGON: <Hexagon className="w-4 h-4" />,
RECTANGLE: <Square className="w-4 h-4" />,
CIRCLE: <Circle className="w-4 h-4" />,
ARROW: <ArrowRight className="w-4 h-4" />,
TEXT: <Type className="w-4 h-4" />,
}
const kindLabels: Record<string, string> = {
SYMBOL: 'Symbol',
LINE: 'Linie',
POLYGON: 'Polygon',
RECTANGLE: 'Rechteck',
CIRCLE: 'Kreis',
ARROW: 'Pfeil',
TEXT: 'Text',
}
export function LayerList({
items,
selectedId,
onSelect,
onToggleVisibility,
onDelete,
canEdit,
}: LayerListProps) {
return (
<div className="flex flex-col h-full">
<div className="p-3 border-b border-border">
<h3 className="font-semibold text-sm">Objekte ({items.length})</h3>
</div>
<ScrollArea className="flex-1">
<div className="p-2 space-y-1">
{items.length === 0 ? (
<p className="text-xs text-muted-foreground text-center py-4">
Keine Objekte vorhanden
</p>
) : (
items.map((item) => (
<div
key={item.id}
className={`flex items-center gap-2 p-2 rounded-md cursor-pointer transition-colors ${
selectedId === item.id
? 'bg-primary/10 border border-primary/30'
: 'hover:bg-accent'
} ${!item.isVisible ? 'opacity-50' : ''}`}
onClick={() => onSelect(item.id)}
>
<GripVertical className="w-3 h-3 text-muted-foreground shrink-0" />
<div className="shrink-0">
{kindIcons[item.kind] || <MapPin className="w-4 h-4" />}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm truncate">
{item.name || kindLabels[item.kind] || item.kind}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={(e) => {
e.stopPropagation()
onToggleVisibility(item.id)
}}
>
{item.isVisible ? (
<Eye className="w-3 h-3" />
) : (
<EyeOff className="w-3 h-3" />
)}
</Button>
{canEdit && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive"
onClick={(e) => {
e.stopPropagation()
onDelete(item.id)
}}
>
<Trash2 className="w-3 h-3" />
</Button>
)}
</div>
</div>
))
)}
</div>
</ScrollArea>
</div>
)
}

View File

@@ -0,0 +1,164 @@
'use client'
import { Button } from '@/components/ui/button'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
MousePointer2,
Circle,
CircleDot,
Square,
Minus,
Pentagon,
Type,
Pencil,
Undo2,
Redo2,
MoveRight,
Ruler,
Eraser,
} from 'lucide-react'
import type { DrawMode } from '@/app/app/page'
import { cn } from '@/lib/utils'
interface LeftToolbarProps {
drawMode: DrawMode
onDrawModeChange: (mode: DrawMode) => void
selectedColor: string
onColorChange: (color: string) => void
selectedWidth: number
onWidthChange: (width: number) => void
onUndo: () => void
onRedo: () => void
canEdit: boolean
}
const colors = [
{ value: '#ef4444', name: 'Rot' },
{ value: '#f97316', name: 'Orange' },
{ value: '#eab308', name: 'Gelb' },
{ value: '#22c55e', name: 'Grün' },
{ value: '#3b82f6', name: 'Blau' },
{ value: '#8b5cf6', name: 'Violett' },
{ value: '#000000', name: 'Schwarz' },
{ value: '#ffffff', name: 'Weiss' },
]
const drawTools: { mode: DrawMode; icon: typeof MousePointer2; label: string }[] = [
{ mode: 'select', icon: MousePointer2, label: 'Auswählen' },
{ mode: 'point', icon: CircleDot, label: 'Punkt' },
{ mode: 'linestring', icon: Minus, label: 'Linie' },
{ mode: 'polygon', icon: Pentagon, label: 'Polygon' },
{ mode: 'rectangle', icon: Square, label: 'Rechteck' },
{ mode: 'circle', icon: Circle, label: 'Kreis' },
{ mode: 'freehand', icon: Pencil, label: 'Freihand' },
{ mode: 'arrow', icon: MoveRight, label: 'Pfeil / Route' },
{ mode: 'text', icon: Type, label: 'Text' },
{ mode: 'eraser', icon: Eraser, label: 'Radiergummi' },
{ mode: 'measure', icon: Ruler, label: 'Messen' },
]
export function LeftToolbar({
drawMode,
onDrawModeChange,
selectedColor,
onColorChange,
selectedWidth,
onWidthChange,
onUndo,
onRedo,
canEdit,
}: LeftToolbarProps) {
return (
<TooltipProvider delayDuration={300}>
<aside className="w-11 md:w-14 lg:w-20 border-r border-border bg-card flex flex-col items-center py-1 md:py-2 shrink-0 overflow-y-auto overflow-x-hidden z-10">
{/* Draw Tools */}
<div className="flex flex-col gap-px md:gap-0.5">
{drawTools.map((tool) => (
<Tooltip key={tool.mode}>
<TooltipTrigger asChild>
<Button
variant={drawMode === tool.mode ? 'default' : 'ghost'}
size="icon"
className="w-8 h-8 md:w-10 md:h-10 lg:w-12 lg:h-12"
onClick={() => onDrawModeChange(tool.mode)}
disabled={!canEdit && tool.mode !== 'select'}
>
<tool.icon className="w-4 h-4 md:w-5 md:h-5 lg:w-6 lg:h-6" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>{tool.label}</p>
</TooltipContent>
</Tooltip>
))}
</div>
<div className="w-6 md:w-8 lg:w-12 h-px bg-border my-1" />
{/* Undo/Redo - 2-col grid to save vertical space */}
<div className="grid grid-cols-2 gap-px md:gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="w-5 h-5 md:w-6 md:h-6 lg:w-8 lg:h-8"
onClick={onUndo}
disabled={!canEdit}
>
<Undo2 className="w-3.5 h-3.5 md:w-4 md:h-4 lg:w-5 lg:h-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Rückgängig</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="w-5 h-5 md:w-6 md:h-6 lg:w-8 lg:h-8"
onClick={onRedo}
disabled={!canEdit}
>
<Redo2 className="w-3.5 h-3.5 md:w-4 md:h-4 lg:w-5 lg:h-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Wiederholen</p>
</TooltipContent>
</Tooltip>
</div>
<div className="w-6 md:w-8 lg:w-12 h-px bg-border my-1" />
{/* Color Picker - 2-col grid */}
<div className="grid grid-cols-2 gap-0.5 md:gap-1">
{colors.map((color) => (
<button
key={color.value}
className={cn(
'w-4 h-4 md:w-5 md:h-5 lg:w-7 lg:h-7 rounded border-2 transition-all',
selectedColor === color.value
? 'border-primary ring-1 ring-primary ring-offset-1 scale-110'
: 'border-muted hover:border-muted-foreground'
)}
style={{ backgroundColor: color.value }}
onClick={() => onColorChange(color.value)}
disabled={!canEdit}
title={color.name}
/>
))}
</div>
</aside>
</TooltipProvider>
)
}

View File

@@ -0,0 +1,130 @@
'use client'
import { ScrollArea } from '@/components/ui/scroll-area'
export interface LegendItem {
id: string
iconId?: string
iconUrl?: string
name: string
kind: string
count: number
properties?: {
unNummer?: string
gefahrennummer?: string
anzahlOpfer?: number
}
}
interface LegendProps {
items: LegendItem[]
}
export function Legend({ items }: LegendProps) {
if (items.length === 0) return null
// Group by kind and iconId
const grouped = items.reduce((acc, item) => {
const key = item.iconId || item.kind
if (!acc[key]) {
acc[key] = { ...item, count: 1 }
} else {
acc[key].count++
}
return acc
}, {} as Record<string, LegendItem>)
const legendItems = Object.values(grouped)
return (
<div className="bg-card border border-border rounded-lg shadow-lg p-3 min-w-[200px] max-w-[300px]">
<h4 className="font-semibold text-sm mb-2 pb-2 border-b border-border">Legende</h4>
<ScrollArea className="max-h-[300px]">
<div className="space-y-2">
{legendItems.map((item, index) => (
<div key={index} className="flex items-start gap-2">
{item.iconUrl ? (
<img
src={item.iconUrl}
alt={item.name}
className="w-5 h-5 object-contain shrink-0 mt-0.5"
/>
) : (
<div className="w-5 h-5 bg-primary/20 rounded shrink-0 mt-0.5" />
)}
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">
{item.name}
{item.count > 1 && (
<span className="text-muted-foreground ml-1">({item.count})</span>
)}
</p>
{item.properties?.unNummer && (
<p className="text-xs text-orange-600">
UN {item.properties.unNummer}
{item.properties.gefahrennummer && ` / ${item.properties.gefahrennummer}`}
</p>
)}
{item.properties?.anzahlOpfer !== undefined && (
<p className="text-xs text-yellow-600">
{item.properties.anzahlOpfer} Opfer
</p>
)}
</div>
</div>
))}
</div>
</ScrollArea>
</div>
)
}
export function LegendExport({ items }: LegendProps) {
if (items.length === 0) return null
const grouped = items.reduce((acc, item) => {
const key = item.iconId || item.kind
if (!acc[key]) {
acc[key] = { ...item, count: 1 }
} else {
acc[key].count++
}
return acc
}, {} as Record<string, LegendItem>)
const legendItems = Object.values(grouped)
return (
<div className="legend-export" style={{ fontFamily: 'Arial, sans-serif' }}>
<h4 style={{ fontSize: '14px', fontWeight: 'bold', marginBottom: '8px', borderBottom: '1px solid #ccc', paddingBottom: '4px' }}>
Legende
</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{legendItems.map((item, index) => (
<div key={index} style={{ display: 'flex', alignItems: 'flex-start', gap: '8px' }}>
{item.iconUrl ? (
<img
src={item.iconUrl}
alt={item.name}
style={{ width: '16px', height: '16px', objectFit: 'contain' }}
/>
) : (
<div style={{ width: '16px', height: '16px', background: '#ddd', borderRadius: '2px' }} />
)}
<div>
<span style={{ fontSize: '11px' }}>
{item.name}
{item.count > 1 && ` (${item.count})`}
</span>
{item.properties?.unNummer && (
<span style={{ fontSize: '10px', color: '#c2410c', marginLeft: '4px' }}>
UN {item.properties.unNummer}
</span>
)}
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,215 @@
'use client'
import { useState, useEffect } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { X } from 'lucide-react'
export interface ItemProperties {
id: string
kind: string
name: string
note: string
status: string
iconType?: string
// Rettung fields
anzahlOpfer?: number
// Gefahrstoff fields
unNummer?: string
gefahrennummer?: string
// Generic
[key: string]: unknown
}
interface PropertiesPanelProps {
item: ItemProperties | null
onUpdate: (id: string, properties: Partial<ItemProperties>) => void
onClose: () => void
canEdit: boolean
}
const STATUS_OPTIONS = [
{ value: 'aktiv', label: 'Aktiv' },
{ value: 'inaktiv', label: 'Inaktiv' },
{ value: 'erledigt', label: 'Erledigt' },
{ value: 'geplant', label: 'Geplant' },
]
export function PropertiesPanel({
item,
onUpdate,
onClose,
canEdit,
}: PropertiesPanelProps) {
const [name, setName] = useState('')
const [note, setNote] = useState('')
const [status, setStatus] = useState('aktiv')
const [anzahlOpfer, setAnzahlOpfer] = useState('')
const [unNummer, setUnNummer] = useState('')
const [gefahrennummer, setGefahrennummer] = useState('')
useEffect(() => {
if (item) {
setName(item.name || '')
setNote(item.note || '')
setStatus(item.status || 'aktiv')
setAnzahlOpfer(item.anzahlOpfer?.toString() || '')
setUnNummer(item.unNummer || '')
setGefahrennummer(item.gefahrennummer || '')
}
}, [item])
if (!item) return null
const handleSave = () => {
const updates: Partial<ItemProperties> = {
name,
note,
status,
}
if (item.iconType === 'RETTUNG') {
updates.anzahlOpfer = anzahlOpfer ? parseInt(anzahlOpfer) : undefined
}
if (item.iconType === 'GEFAHRSTOFF') {
updates.unNummer = unNummer || undefined
updates.gefahrennummer = gefahrennummer || undefined
}
onUpdate(item.id, updates)
}
const isRettung = item.iconType === 'RETTUNG'
const isGefahrstoff = item.iconType === 'GEFAHRSTOFF'
return (
<div className="w-72 border-l border-border bg-card flex flex-col shrink-0">
<div className="p-3 border-b border-border flex items-center justify-between">
<h3 className="font-semibold text-sm">Eigenschaften</h3>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onClose}>
<X className="w-4 h-4" />
</Button>
</div>
<ScrollArea className="flex-1 p-3">
<div className="space-y-4">
<div>
<Label className="text-xs">Typ</Label>
<p className="text-sm text-muted-foreground">{item.kind}</p>
</div>
<div>
<Label className="text-xs">Name / Bezeichnung</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="z.B. Einsatzleiter"
disabled={!canEdit}
className="h-8 text-sm"
/>
</div>
<div>
<Label className="text-xs">Notiz</Label>
<Input
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Zusätzliche Informationen"
disabled={!canEdit}
className="h-8 text-sm"
/>
</div>
<div>
<Label className="text-xs">Status</Label>
<Select value={status} onValueChange={setStatus} disabled={!canEdit}>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Rettung specific fields */}
{isRettung && (
<div className="pt-2 border-t border-border">
<div className="bg-yellow-500/10 p-2 rounded-md mb-3">
<p className="text-xs font-medium text-yellow-600">Rettungs-Symbol</p>
</div>
<div>
<Label className="text-xs">
Anzahl vermuteter Opfer <span className="text-destructive">*</span>
</Label>
<Input
type="number"
min="0"
value={anzahlOpfer}
onChange={(e) => setAnzahlOpfer(e.target.value)}
placeholder="0"
disabled={!canEdit}
className="h-8 text-sm"
/>
</div>
</div>
)}
{/* Gefahrstoff specific fields */}
{isGefahrstoff && (
<div className="pt-2 border-t border-border">
<div className="bg-orange-500/10 p-2 rounded-md mb-3">
<p className="text-xs font-medium text-orange-600">Gefahrstoff-Symbol</p>
</div>
<div className="space-y-3">
<div>
<Label className="text-xs">
UN-Nummer <span className="text-destructive">*</span>
</Label>
<Input
value={unNummer}
onChange={(e) => setUnNummer(e.target.value)}
placeholder="z.B. 1203"
disabled={!canEdit}
className="h-8 text-sm"
/>
</div>
<div>
<Label className="text-xs">
Gefahrennummer <span className="text-destructive">*</span>
</Label>
<Input
value={gefahrennummer}
onChange={(e) => setGefahrennummer(e.target.value)}
placeholder="z.B. 33"
disabled={!canEdit}
className="h-8 text-sm"
/>
</div>
</div>
</div>
)}
{canEdit && (
<Button onClick={handleSave} className="w-full" size="sm">
Speichern
</Button>
)}
</div>
</ScrollArea>
</div>
)
}

View File

@@ -0,0 +1,393 @@
'use client'
import { useState, useEffect } from 'react'
import { useDrag } from 'react-dnd'
import { getEmptyImage } from 'react-dnd-html5-backend'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Search, Flame, Droplets, AlertTriangle, Car, Users,
Truck, Building, Target, Upload, Loader2, X, LayoutGrid,
ChevronLeft, ChevronRight, Map, ClipboardList, PanelRightClose, PanelRightOpen,
Shield, Wrench, Radio, MoreHorizontal, Heart,
} from 'lucide-react'
interface DisplaySymbol {
id: string
name: string
imageUrl: string
}
interface DisplayCategory {
id: string
name: string
symbols: DisplaySymbol[]
}
interface RightSidebarProps {
onSymbolDrop: (iconId: string, coordinates: [number, number], imageUrl?: string) => void
canEdit: boolean
isOpen?: boolean
onToggle?: () => void
activeTab?: 'map' | 'journal'
onTabChange?: (tab: 'map' | 'journal') => void
isCollapsed?: boolean
onToggleCollapse?: () => void
tenantId?: string | null
}
const categoryIcons: Record<string, typeof Flame> = {
'Feuer / Brand': Flame,
'Wasser': Droplets,
'Gefahren / Stoffe': AlertTriangle,
'Rettung / Personen': Heart,
'Leitern / Geräte': Wrench,
'Gebäude / Schäden': Building,
'Einsatzführung': Radio,
'Organisationen': Shield,
'Entwicklung / Taktik': Target,
'Verschiedenes': MoreHorizontal,
'Eigene': Upload,
}
function DraggableSymbol({ symbol, canEdit }: {
symbol: DisplaySymbol
canEdit: boolean
}) {
const [{ isDragging }, drag, preview] = useDrag(() => ({
type: 'SYMBOL',
item: { iconId: symbol.id, imageUrl: symbol.imageUrl },
canDrag: canEdit,
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
}), [symbol.id, symbol.imageUrl, canEdit])
// Suppress native HTML5 drag ghost — we use CustomDragLayer instead
useEffect(() => {
preview(getEmptyImage(), { captureDraggingState: true })
}, [preview])
return (
<div
ref={drag as unknown as React.LegacyRef<HTMLDivElement>}
className={`
flex flex-col items-center gap-1 p-1.5 md:p-2 lg:p-2.5 rounded-lg border-2 transition-all
border-transparent hover:border-border hover:bg-accent
cursor-grab active:cursor-grabbing active:scale-95
${isDragging ? 'opacity-0' : ''}
${!canEdit ? 'opacity-50 cursor-not-allowed' : ''}
`}
title={symbol.name}
>
<div className="w-10 h-10 md:w-11 md:h-11 lg:w-14 lg:h-14 flex items-center justify-center rounded-lg bg-white border border-gray-200 dark:border-gray-600">
<img
src={symbol.imageUrl}
alt={symbol.name}
className="w-8 h-8 md:w-9 md:h-9 lg:w-12 lg:h-12 object-contain"
crossOrigin="anonymous"
/>
</div>
<span className="text-[10px] md:text-[11px] text-center truncate w-full font-medium text-muted-foreground">
{symbol.name}
</span>
</div>
)
}
export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTab, onTabChange, isCollapsed, onToggleCollapse, tenantId }: RightSidebarProps) {
const [searchQuery, setSearchQuery] = useState('')
const [activeCategory, setActiveCategory] = useState<string>('')
const [categories, setCategories] = useState<DisplayCategory[]>([])
const [tenantIcons, setTenantIcons] = useState<DisplaySymbol[]>([])
const [isLoading, setIsLoading] = useState(true)
const [showTenantSection, setShowTenantSection] = useState(true)
const [showLibrarySection, setShowLibrarySection] = useState(true)
useEffect(() => {
async function fetchIcons() {
setIsLoading(true)
try {
const res = await fetch('/api/icons')
if (res.ok) {
const data = await res.json()
const allCats: DisplayCategory[] = (data.categories || [])
.filter((cat: any) => cat.icons && cat.icons.length > 0)
.map((cat: any) => ({
id: cat.id,
name: cat.name,
symbols: cat.icons.map((icon: any) => ({
id: icon.id,
name: icon.name,
imageUrl: icon.url || `/api/icons/${icon.id}/image`,
})),
}))
// Separate tenant-specific icons ("Eigene" category) from global library
const eigene = allCats.find(c => c.name === 'Eigene')
const globalCats = allCats.filter(c => c.name !== 'Eigene')
setTenantIcons(eigene?.symbols || [])
setCategories(globalCats)
if (globalCats.length > 0) setActiveCategory(globalCats[0].id)
}
} catch (err) {
console.error('Failed to load icons:', err)
} finally {
setIsLoading(false)
}
}
fetchIcons()
}, [])
const filteredCategories = categories.map((cat) => ({
...cat,
symbols: cat.symbols.filter((s) =>
s.name.toLowerCase().includes(searchQuery.toLowerCase())
),
}))
const filteredTenantIcons = tenantIcons.filter(s =>
s.name.toLowerCase().includes(searchQuery.toLowerCase())
)
const currentCategory = filteredCategories.find((c) => c.id === activeCategory)
const totalSymbols = categories.reduce((sum, c) => sum + c.symbols.length, 0) + tenantIcons.length
return (
<>
{/* Mobile toggle button */}
{!isOpen && onToggle && (
<button
onClick={onToggle}
className="md:hidden fixed bottom-4 right-4 z-50 w-14 h-14 bg-primary text-primary-foreground rounded-full shadow-lg flex items-center justify-center active:scale-95 transition-transform"
title="Symbole"
>
<LayoutGrid className="w-6 h-6" />
</button>
)}
{/* Backdrop on mobile */}
{isOpen && onToggle && (
<div className="md:hidden fixed inset-0 bg-black/40 z-40" onClick={onToggle} />
)}
{/* Collapsed state: thin strip with toggle + tab icons */}
{isCollapsed && (
<aside className="hidden md:flex w-10 border-l border-border bg-card flex-col items-center py-2 gap-2 shrink-0">
<button
onClick={onToggleCollapse}
className="p-1.5 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
title="Seitenleiste einblenden"
>
<PanelRightOpen className="w-4 h-4" />
</button>
<div className="w-6 h-px bg-border" />
{onTabChange && (
<>
<button
onClick={() => onTabChange('map')}
className={`p-1.5 rounded-md transition-colors ${
activeTab === 'map' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground'
}`}
title="Karte"
>
<Map className="w-4 h-4" />
</button>
<button
onClick={() => onTabChange('journal')}
className={`p-1.5 rounded-md transition-colors ${
activeTab === 'journal' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground'
}`}
title="Journal"
>
<ClipboardList className="w-4 h-4" />
</button>
</>
)}
</aside>
)}
<aside className={`
w-72 md:w-48 lg:w-56 xl:w-72 border-l border-border bg-card flex flex-col shrink-0
md:relative md:translate-x-0 md:z-auto
fixed right-0 top-0 bottom-0 z-50 transition-transform duration-200
${isOpen ? 'translate-x-0' : 'translate-x-full md:translate-x-0'}
${isCollapsed ? 'md:hidden' : ''}
`}>
{/* Tab switcher: Karte / Journal */}
{onTabChange && (
<div className="flex items-center border-b border-border shrink-0">
<button
onClick={() => onTabChange('map')}
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors ${
activeTab === 'map'
? 'bg-primary/10 text-primary border-b-2 border-primary'
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
}`}
>
<Map className="w-3.5 h-3.5" />
Karte
</button>
<button
onClick={() => onTabChange('journal')}
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors ${
activeTab === 'journal'
? 'bg-primary/10 text-primary border-b-2 border-primary'
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
}`}
>
<ClipboardList className="w-3.5 h-3.5" />
Journal
</button>
</div>
)}
{/* Symbol panel — only shown on map tab */}
{activeTab === 'map' && (
<>
<div className="p-2 md:p-2 lg:p-3 border-b border-border">
<div className="flex items-center justify-between mb-1.5">
<h3 className="font-semibold text-sm md:text-base">Symbole</h3>
<div className="flex items-center gap-1">
{onToggleCollapse && (
<button onClick={onToggleCollapse} className="hidden md:block p-1 text-muted-foreground hover:text-foreground" title="Seitenleiste einklappen">
<PanelRightClose className="w-4 h-4" />
</button>
)}
{onToggle && (
<button onClick={onToggle} className="md:hidden p-1 text-muted-foreground hover:text-foreground">
<X className="w-5 h-5" />
</button>
)}
</div>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 md:h-5 md:w-5 text-muted-foreground" />
<Input
placeholder="Suchen..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 md:pl-10 h-9 md:h-10 lg:h-11 text-sm md:text-base"
/>
</div>
<p className="text-xs text-muted-foreground mt-1.5">{totalSymbols} Symbole verfügbar</p>
</div>
{isLoading && (
<div className="flex items-center justify-center gap-2 p-6 text-muted-foreground text-xs">
<Loader2 className="w-4 h-4 animate-spin" />
Lade Symbole...
</div>
)}
<ScrollArea className="flex-1">
{/* ─── Section 1: Meine Symbole (Tenant-specific) ─── */}
{tenantId && (
<div className="border-b border-border">
<button
onClick={() => setShowTenantSection(!showTenantSection)}
className="w-full flex items-center justify-between px-2 py-1.5 text-xs font-semibold text-muted-foreground hover:bg-accent/50 transition-colors"
>
<span className="flex items-center gap-1.5">
<Upload className="w-3.5 h-3.5" />
Meine Symbole ({filteredTenantIcons.length})
</span>
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${showTenantSection ? 'rotate-90' : ''}`} />
</button>
{showTenantSection && (
<div className="p-2 pt-0">
{filteredTenantIcons.length === 0 ? (
<div className="text-center text-muted-foreground py-4 text-xs">
Keine eigenen Symbole vorhanden.
<br />
<span className="text-[10px]">Symbole können unter Einstellungen Symbole hochgeladen werden.</span>
</div>
) : (
<div className="grid grid-cols-3 md:grid-cols-2 lg:grid-cols-3 gap-1">
{filteredTenantIcons.map((symbol) => (
<DraggableSymbol key={symbol.id} symbol={symbol} canEdit={canEdit} />
))}
</div>
)}
</div>
)}
</div>
)}
{/* ─── Section 2: Bibliothek (Global categories) ─── */}
<div>
<button
onClick={() => setShowLibrarySection(!showLibrarySection)}
className="w-full flex items-center justify-between px-2 py-1.5 text-xs font-semibold text-muted-foreground hover:bg-accent/50 transition-colors"
>
<span className="flex items-center gap-1.5">
<LayoutGrid className="w-3.5 h-3.5" />
Bibliothek ({categories.reduce((s, c) => s + c.symbols.length, 0)})
</span>
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${showLibrarySection ? 'rotate-90' : ''}`} />
</button>
{showLibrarySection && (
<>
{/* Category tabs */}
<div className="px-1.5 pb-1.5 md:px-2 md:pb-2">
<div className="flex flex-wrap gap-0.5 md:gap-1">
{categories.map((cat) => {
const IconComponent = categoryIcons[cat.name] || Target
return (
<button
key={cat.id}
onClick={() => setActiveCategory(cat.id)}
className={`
flex items-center gap-1 px-2 py-1.5 md:px-2.5 md:py-1.5 lg:px-3 lg:py-2 rounded-md md:rounded-lg text-xs md:text-sm font-medium transition-colors
${activeCategory === cat.id
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-accent text-muted-foreground'}
`}
title={`${cat.name} (${cat.symbols.length})`}
>
<IconComponent className="w-3.5 h-3.5 md:w-4 md:h-4" />
<span className="hidden lg:inline max-w-[70px] truncate">{cat.name}</span>
</button>
)
})}
</div>
</div>
{currentCategory && (
<div className="p-2 pt-0">
<h4 className="text-xs md:text-sm font-medium text-muted-foreground mb-1.5">
{currentCategory.name} ({currentCategory.symbols.length})
</h4>
{currentCategory.symbols.length === 0 ? (
<div className="text-center text-muted-foreground py-8 text-sm">
Keine Symbole gefunden
</div>
) : (
<div className="grid grid-cols-3 md:grid-cols-2 lg:grid-cols-3 gap-1">
{currentCategory.symbols.map((symbol) => (
<DraggableSymbol key={symbol.id} symbol={symbol} canEdit={canEdit} />
))}
</div>
)}
</div>
)}
</>
)}
</div>
</ScrollArea>
</>
)}
{/* Journal tab: collapse button only, no symbols */}
{activeTab === 'journal' && onToggleCollapse && (
<div className="p-2 border-b border-border flex items-center justify-between">
<span className="text-xs text-muted-foreground">Journal aktiv</span>
<button onClick={onToggleCollapse} className="hidden md:block p-1 text-muted-foreground hover:text-foreground" title="Seitenleiste einklappen">
<PanelRightClose className="w-4 h-4" />
</button>
</div>
)}
</aside>
</>
)
}

View File

@@ -0,0 +1,542 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Plus,
Save,
FolderOpen,
Download,
Clock,
Sun,
Moon,
Maximize,
Minimize,
ClipboardList,
Settings,
LogOut,
User,
MoreVertical,
Trash2,
AlertTriangle,
List,
Eraser,
ImagePlus,
Key,
Shield,
Building2,
} from 'lucide-react'
import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog'
import type { Project, DrawFeature } from '@/app/app/page'
import { formatDateTime } from '@/lib/utils'
import { Logo } from '@/components/ui/logo'
interface TopbarProps {
project: Project | null
onNewProject: () => void
onSaveProject: () => void
onLoadProject: (project: Project, features: DrawFeature[]) => void
onDeleteProject?: (projectId: string) => void
onExport: (format: 'png' | 'pdf') => void
onClearAll?: () => void
onPlanUpload?: () => void
isSaving: boolean
isDarkMode: boolean
onToggleTheme: () => void
isFullscreen: boolean
onToggleFullscreen: () => void
auditLog: { time: string; action: string }[]
isAuditOpen: boolean
onToggleAudit: () => void
userName?: string
userRole?: string
onLogout?: () => void
}
export function Topbar({
project,
onNewProject,
onSaveProject,
onLoadProject,
onDeleteProject,
onExport,
onClearAll,
onPlanUpload,
isSaving,
isDarkMode,
onToggleTheme,
isFullscreen,
onToggleFullscreen,
auditLog,
isAuditOpen,
onToggleAudit,
userName,
userRole,
onLogout,
}: TopbarProps) {
const [isLoadDialogOpen, setIsLoadDialogOpen] = useState(false)
const [isHoseSettingsOpen, setIsHoseSettingsOpen] = useState(false)
const [showPasswordDialog, setShowPasswordDialog] = useState(false)
const [pwOld, setPwOld] = useState('')
const [pwNew, setPwNew] = useState('')
const [pwConfirm, setPwConfirm] = useState('')
const [pwLoading, setPwLoading] = useState(false)
const [pwStatus, setPwStatus] = useState<'success' | 'error' | null>(null)
const [pwError, setPwError] = useState('')
const [projects, setProjects] = useState<Project[]>([])
const [isLoadingProjects, setIsLoadingProjects] = useState(false)
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
// Live clock
const [now, setNow] = useState(new Date())
useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 1000)
return () => clearInterval(timer)
}, [])
const handleOpenLoadDialog = async () => {
setIsLoadDialogOpen(true)
setIsLoadingProjects(true)
try {
const res = await fetch('/api/projects')
if (res.ok) {
const data = await res.json()
setProjects(data.projects)
}
} catch (error) {
console.error('Error loading projects:', error)
} finally {
setIsLoadingProjects(false)
}
}
const handleLoadProject = async (projectId: string) => {
try {
const res = await fetch(`/api/projects/${projectId}`)
if (res.ok) {
const data = await res.json()
onLoadProject(data.project, data.project.features || [])
setIsLoadDialogOpen(false)
}
} catch (error) {
console.error('Error loading project:', error)
}
}
const handleExport = (format: 'png' | 'pdf') => {
onExport(format)
}
return (
<header className="h-14 md:h-16 border-b border-border bg-card flex items-center justify-between px-2 md:px-4 shrink-0 overflow-x-auto print:hidden">
<div className="flex items-center gap-1.5 md:gap-4 shrink-0">
<div className="flex items-center gap-1.5 md:gap-2">
<Logo size={32} />
<span className="font-semibold text-lg hidden md:inline">Lageplan</span>
</div>
<div className="h-6 w-px bg-border hidden md:block" />
<div className="flex items-center gap-1 md:gap-2">
<Button
variant="outline"
className="h-9 md:h-10 px-2 md:px-4 text-sm"
onClick={onSaveProject}
disabled={!project || isSaving}
title="Speichern"
>
<Save className="w-5 h-5 md:mr-1.5" />
<span className="hidden lg:inline">{isSaving ? 'Speichern...' : 'Speichern'}</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="h-9 md:h-10 px-2 md:px-3 text-sm" title="Menü">
<MoreVertical className="w-5 h-5 md:mr-1" />
<span className="hidden lg:inline">Menü</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-52">
<DropdownMenuItem onClick={onNewProject} className="py-2.5 px-3">
<Plus className="w-4 h-4 mr-2" />
Neuer Einsatz
</DropdownMenuItem>
<DropdownMenuItem onClick={handleOpenLoadDialog} className="py-2.5 px-3">
<List className="w-4 h-4 mr-2" />
Einsätze verwalten
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport('png')} className="py-2.5 px-3">
<Download className="w-4 h-4 mr-2" />
Als PNG exportieren
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport('pdf')} className="py-2.5 px-3">
<Download className="w-4 h-4 mr-2" />
Als PDF exportieren
</DropdownMenuItem>
{onClearAll && (
<DropdownMenuItem onClick={onClearAll} className="py-2.5 px-3 text-destructive focus:text-destructive">
<Eraser className="w-4 h-4 mr-2" />
Zeichnung leeren
</DropdownMenuItem>
)}
{(userRole === 'SERVER_ADMIN' || userRole === 'TENANT_ADMIN') && (
<DropdownMenuItem onClick={() => window.location.href = '/admin'} className="py-2.5 px-3">
<Settings className="w-4 h-4 mr-2" />
Einstellungen
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="flex items-center gap-1 md:gap-2 shrink-0">
{/* Project info - desktop only */}
{project && (
<div className="hidden xl:flex items-center gap-2 text-sm border border-border rounded-lg px-3 py-1.5 bg-muted/30">
{(project as any).einsatzNr && (
<span className="font-mono text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded">{(project as any).einsatzNr}</span>
)}
<span className="font-semibold truncate max-w-[180px]">{project.title}</span>
{project.location && (
<><span className="text-muted-foreground">|</span><span className="text-muted-foreground truncate max-w-[150px]">{project.location}</span></>
)}
</div>
)}
{/* Clock - desktop only */}
<div className="hidden lg:flex items-center gap-2 text-sm font-medium tabular-nums">
<Clock className="w-4 h-4 text-muted-foreground" />
<span className="text-primary font-bold">{now.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })}</span>
</div>
{/* Desktop: individual buttons */}
<Button
variant={isDarkMode ? 'default' : 'outline'}
className="hidden md:flex h-9 px-2 gap-1.5 text-sm font-semibold"
onClick={onToggleTheme}
title={isDarkMode ? 'Tagmodus' : 'Nachtmodus'}
>
{isDarkMode ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</Button>
<Button
variant="outline"
className="hidden md:flex h-9 px-2"
onClick={onToggleFullscreen}
title={isFullscreen ? 'Vollbild verlassen' : 'Vollbild'}
>
{isFullscreen ? <Minimize className="w-4 h-4" /> : <Maximize className="w-4 h-4" />}
</Button>
<Button
variant={isAuditOpen ? 'default' : 'outline'}
className="hidden md:flex h-9 px-2 relative"
onClick={onToggleAudit}
title="Audit Trail"
>
<ClipboardList className="w-4 h-4" />
{auditLog.length > 0 && (
<span className="absolute -top-1 -right-1 w-4 h-4 bg-primary text-primary-foreground text-[10px] rounded-full flex items-center justify-center font-bold">
{auditLog.length > 99 ? '99' : auditLog.length}
</span>
)}
</Button>
{/* Desktop: User menu dropdown */}
{userName && onLogout && (
<div className="hidden md:flex items-center ml-1 pl-2 border-l border-border">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-9 px-2 gap-1.5 text-sm text-muted-foreground hover:text-foreground">
<User className="w-4 h-4" />
<span className="hidden lg:inline max-w-[100px] truncate">{userName}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<div className="px-3 py-2 border-b">
<p className="text-sm font-medium">{userName}</p>
{userRole && <p className="text-xs text-muted-foreground">{userRole}</p>}
</div>
<DropdownMenuItem onClick={() => setShowPasswordDialog(true)}>
<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" />
Administration
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={onLogout} className="text-destructive focus:text-destructive">
<LogOut className="w-4 h-4 mr-2" />
Abmelden
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{/* Mobile: overflow menu with all secondary actions */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="md:hidden h-9 px-2">
<MoreVertical className="w-5 h-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={onToggleTheme}>
{isDarkMode ? <Sun className="w-4 h-4 mr-2" /> : <Moon className="w-4 h-4 mr-2" />}
{isDarkMode ? 'Tagmodus' : 'Nachtmodus'}
</DropdownMenuItem>
<DropdownMenuItem onClick={onToggleFullscreen}>
{isFullscreen ? <Minimize className="w-4 h-4 mr-2" /> : <Maximize className="w-4 h-4 mr-2" />}
{isFullscreen ? 'Vollbild verlassen' : 'Vollbild'}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setIsHoseSettingsOpen(true)}>
<Settings className="w-4 h-4 mr-2" />
Einstellungen
</DropdownMenuItem>
<DropdownMenuItem onClick={onToggleAudit}>
<ClipboardList className="w-4 h-4 mr-2" />
Audit Trail {auditLog.length > 0 && `(${auditLog.length})`}
</DropdownMenuItem>
{onLogout && (
<DropdownMenuItem onClick={onLogout} className="text-destructive">
<LogOut className="w-4 h-4 mr-2" />
Abmelden {userName && `(${userName})`}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Audit Trail Panel */}
{isAuditOpen && (
<div className="absolute right-0 top-16 w-96 max-h-[60vh] z-50 bg-card border border-border rounded-bl-lg shadow-xl overflow-hidden flex flex-col">
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-muted/50">
<span className="font-semibold text-sm flex items-center gap-2">
<ClipboardList className="w-4 h-4" /> Audit Trail
</span>
<button onClick={onToggleAudit} className="text-muted-foreground hover:text-foreground text-lg leading-none"></button>
</div>
<ScrollArea className="flex-1 max-h-[calc(60vh-48px)]">
<div className="p-2">
{auditLog.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">Noch keine Aktionen</p>
) : (
auditLog.map((entry, i) => (
<div key={i} className="flex gap-3 px-3 py-2 text-sm border-b border-border/50 last:border-0">
<span className="text-muted-foreground font-mono text-xs whitespace-nowrap pt-0.5">{entry.time}</span>
<span>{entry.action}</span>
</div>
))
)}
</div>
</ScrollArea>
</div>
)}
{/* Project Management Dialog */}
<Dialog open={isLoadDialogOpen} onOpenChange={setIsLoadDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<List className="w-5 h-5" />
Einsätze verwalten
</DialogTitle>
</DialogHeader>
<ScrollArea className="h-[450px] pr-4">
{isLoadingProjects ? (
<div className="flex items-center justify-center h-32">
<span className="text-muted-foreground">Laden...</span>
</div>
) : projects.length === 0 ? (
<div className="flex items-center justify-center h-32">
<span className="text-muted-foreground">Keine Einsätze vorhanden</span>
</div>
) : (
<div className="space-y-2">
{projects.map((p) => (
<div
key={p.id}
className={`p-4 border rounded-lg transition-colors ${
project?.id === p.id ? 'border-primary bg-primary/5' : 'hover:bg-accent'
}`}
>
<div className="flex items-start justify-between gap-3">
<button
onClick={() => handleLoadProject(p.id)}
className="flex-1 text-left"
>
<div className="flex items-center gap-2">
<h4 className="font-medium">{p.title}</h4>
{project?.id === p.id && (
<span className="text-xs bg-primary text-primary-foreground px-1.5 py-0.5 rounded">Aktiv</span>
)}
</div>
{p.location && (
<p className="text-sm text-muted-foreground mt-1">
<MapPin className="w-3 h-3 inline mr-1" />{p.location}
</p>
)}
<p className="text-xs text-muted-foreground mt-1">
Erstellt: {formatDateTime(p.createdAt)} | Geändert: {formatDateTime(p.updatedAt)}
</p>
</button>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => handleLoadProject(p.id)}
title="Einsatz öffnen"
>
<FolderOpen className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8 text-destructive hover:bg-destructive hover:text-destructive-foreground"
onClick={() => setDeleteConfirmId(p.id)}
title="Einsatz löschen"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</div>
))}
</div>
)}
</ScrollArea>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={!!deleteConfirmId} onOpenChange={() => setDeleteConfirmId(null)}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="w-5 h-5" />
Einsatz löschen?
</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Dieser Einsatz wird unwiderruflich gelöscht, inklusive aller Zeichnungen, Journal-Einträge und Daten.
</p>
<div className="flex justify-end gap-2 mt-2">
<Button variant="outline" onClick={() => setDeleteConfirmId(null)} disabled={isDeleting}>
Abbrechen
</Button>
<Button
variant="destructive"
disabled={isDeleting}
onClick={async () => {
if (!deleteConfirmId) return
setIsDeleting(true)
try {
const res = await fetch(`/api/projects/${deleteConfirmId}`, { method: 'DELETE' })
if (res.ok) {
setProjects(prev => prev.filter(p => p.id !== deleteConfirmId))
if (onDeleteProject) onDeleteProject(deleteConfirmId)
setDeleteConfirmId(null)
}
} catch (e) {
console.error('Delete failed:', e)
} finally {
setIsDeleting(false)
}
}}
>
{isDeleting ? 'Löschen...' : 'Endgültig löschen'}
</Button>
</div>
</DialogContent>
</Dialog>
<HoseSettingsDialog open={isHoseSettingsOpen} onOpenChange={setIsHoseSettingsOpen} />
{/* Password Change Dialog */}
<Dialog open={showPasswordDialog} onOpenChange={(open) => {
setShowPasswordDialog(open)
if (!open) { setPwOld(''); setPwNew(''); setPwConfirm(''); setPwStatus(null); setPwError('') }
}}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Key className="w-5 h-5" />
Kennwort ändern
</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-muted-foreground">Aktuelles Kennwort</label>
<input type="password" value={pwOld} onChange={e => setPwOld(e.target.value)} className="w-full border rounded-md px-3 py-2 text-sm mt-1" autoComplete="current-password" />
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Neues Kennwort</label>
<input type="password" value={pwNew} onChange={e => setPwNew(e.target.value)} className="w-full border rounded-md px-3 py-2 text-sm mt-1" autoComplete="new-password" />
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Kennwort bestätigen</label>
<input type="password" value={pwConfirm} onChange={e => setPwConfirm(e.target.value)} className="w-full border rounded-md px-3 py-2 text-sm mt-1" autoComplete="new-password" />
</div>
{pwStatus === 'error' && <p className="text-sm text-destructive">{pwError}</p>}
{pwStatus === 'success' && <p className="text-sm text-green-600">Kennwort erfolgreich geändert!</p>}
</div>
<div className="flex justify-end gap-2 mt-2">
<Button variant="outline" size="sm" onClick={() => setShowPasswordDialog(false)}>Abbrechen</Button>
<Button
size="sm"
disabled={pwLoading || !pwOld || !pwNew || pwNew !== pwConfirm || pwNew.length < 6}
onClick={async () => {
setPwLoading(true)
setPwStatus(null)
setPwError('')
try {
const res = await fetch('/api/auth/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword: pwOld, newPassword: pwNew }),
})
const data = await res.json()
if (res.ok) {
setPwStatus('success')
setPwOld(''); setPwNew(''); setPwConfirm('')
} else {
setPwStatus('error')
setPwError(data.error || 'Fehler beim Ändern')
}
} catch { setPwStatus('error'); setPwError('Verbindungsfehler') }
finally { setPwLoading(false) }
}}
>
{pwLoading ? 'Speichern...' : 'Kennwort ändern'}
</Button>
</div>
</DialogContent>
</Dialog>
</header>
)
}

View File

@@ -0,0 +1,76 @@
'use client'
import { useState } from 'react'
import { X, Clock, AlertTriangle, Info } from 'lucide-react'
import type { TenantInfo } from '@/components/providers/auth-provider'
interface TrialBannerProps {
tenant: TenantInfo
}
export function TrialBanner({ tenant }: TrialBannerProps) {
const [dismissed, setDismissed] = useState(false)
if (dismissed) return null
if (tenant.subscriptionStatus !== 'TRIAL') return null
if (!tenant.trialEndsAt) return null
const now = new Date()
const trialEnd = new Date(tenant.trialEndsAt)
const diffMs = trialEnd.getTime() - now.getTime()
const daysLeft = Math.ceil(diffMs / (1000 * 60 * 60 * 24))
// Don't show if trial already expired (handled elsewhere) or more than 14 days left
if (daysLeft > 14) return null
const isExpired = daysLeft <= 0
const isUrgent = daysLeft <= 3
const isWarning = daysLeft <= 7
let bgColor = 'bg-blue-50 border-blue-200 text-blue-800'
let Icon = Info
let message = ''
if (isExpired) {
bgColor = 'bg-red-50 border-red-200 text-red-800'
Icon = AlertTriangle
message = `Ihre Testphase ist abgelaufen. Ihr Konto wurde auf den Free-Plan umgestellt. Upgraden Sie für erweiterte Funktionen.`
} else if (isUrgent) {
bgColor = 'bg-orange-50 border-orange-200 text-orange-800'
Icon = AlertTriangle
message = `Ihre Testphase endet ${daysLeft === 1 ? 'morgen' : `in ${daysLeft} Tagen`}. Danach wechselt Ihr Konto automatisch zum Free-Plan.`
} else if (isWarning) {
bgColor = 'bg-yellow-50 border-yellow-200 text-yellow-800'
Icon = Clock
message = `Noch ${daysLeft} Tage in Ihrer Testphase. Upgraden Sie rechtzeitig, um alle Funktionen zu behalten.`
} else {
bgColor = 'bg-blue-50 border-blue-200 text-blue-800'
Icon = Info
message = `Testphase: Noch ${daysLeft} Tage. Sie nutzen aktuell alle Funktionen kostenlos.`
}
const endDateStr = trialEnd.toLocaleDateString('de-CH', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
return (
<div className={`${bgColor} border-b px-4 py-2 flex items-center gap-3 text-sm shrink-0 print:hidden`}>
<Icon className="w-4 h-4 shrink-0" />
<div className="flex-1 min-w-0">
<span className="font-medium">{message}</span>
<span className="text-xs opacity-75 ml-2">(Ablauf: {endDateStr})</span>
</div>
{!isExpired && (
<button
onClick={() => setDismissed(true)}
className="p-1 rounded hover:bg-black/10 transition-colors shrink-0"
title="Ausblenden"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,37 @@
'use client'
import { useDragLayer } from 'react-dnd'
export function CustomDragLayer() {
const { isDragging, item, currentOffset } = useDragLayer((monitor) => ({
isDragging: monitor.isDragging(),
item: monitor.getItem() as { iconId: string; imageUrl?: string } | null,
currentOffset: monitor.getClientOffset(),
}))
if (!isDragging || !currentOffset || !item?.imageUrl) {
return null
}
return (
<div
style={{
position: 'fixed',
pointerEvents: 'none',
zIndex: 9999,
left: 0,
top: 0,
transform: `translate(${currentOffset.x - 24}px, ${currentOffset.y - 24}px)`,
}}
>
<div className="w-12 h-12 bg-white/90 rounded-lg shadow-lg border-2 border-primary flex items-center justify-center backdrop-blur-sm">
<img
src={item.imageUrl}
alt=""
className="w-10 h-10 object-contain"
crossOrigin="anonymous"
/>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,120 @@
'use client'
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'
export interface User {
id: string
email: string
name: string
role: 'SERVER_ADMIN' | 'TENANT_ADMIN' | 'OPERATOR' | 'VIEWER'
tenantId?: string
tenantSlug?: string
}
export interface TenantInfo {
id: string
name: string
slug: string
plan: string
subscriptionStatus: string
trialEndsAt: string | null
subscriptionEndsAt: string | null
maxUsers: number
maxProjects: number
logoUrl: string | null
}
interface AuthContextType {
user: User | null
tenant: TenantInfo | null
loading: boolean
login: (email: string, password: string) => Promise<{ success: boolean; error?: string }>
logout: () => Promise<void>
canEdit: () => boolean
isAdmin: () => boolean
isServerAdmin: () => boolean
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [tenant, setTenant] = useState<TenantInfo | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
checkAuth()
}, [])
const checkAuth = async () => {
try {
const res = await fetch('/api/auth/me')
if (res.ok) {
const data = await res.json()
setUser(data.user)
setTenant(data.tenant || null)
}
} catch {
// Expected 401 for unauthenticated visitors — no console error
} finally {
setLoading(false)
}
}
const login = async (email: string, password: string) => {
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const data = await res.json()
if (res.ok && data.user) {
setUser(data.user)
return { success: true }
}
return { success: false, error: data.error || 'Login fehlgeschlagen' }
} catch (error) {
return { success: false, error: 'Verbindungsfehler' }
}
}
const logout = async () => {
try {
await fetch('/api/auth/logout', { method: 'POST' })
setUser(null)
setTenant(null)
} catch (error) {
console.error('Logout failed:', error)
}
}
const canEdit = () => {
return user?.role === 'SERVER_ADMIN' || user?.role === 'TENANT_ADMIN' || user?.role === 'OPERATOR'
}
const isAdmin = () => {
return user?.role === 'SERVER_ADMIN' || user?.role === 'TENANT_ADMIN'
}
const isServerAdmin = () => {
return user?.role === 'SERVER_ADMIN'
}
return (
<AuthContext.Provider value={{ user, tenant, loading, login, logout, canEdit, isAdmin, isServerAdmin }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}

View File

@@ -0,0 +1,13 @@
'use client'
import { useEffect } from 'react'
export function ServiceWorkerRegister() {
useEffect(() => {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {})
}
}, [])
return null
}

View File

@@ -0,0 +1,52 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }

View File

@@ -0,0 +1,118 @@
'use client'
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Schliessen</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-1.5 text-center sm:text-left',
className
)}
{...props}
/>
)
DialogHeader.displayName = 'DialogHeader'
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
)
DialogFooter.displayName = 'DialogFooter'
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,191 @@
'use client'
import * as React from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { Check, ChevronRight, Circle } from 'lucide-react'
import { cn } from '@/lib/utils'
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold',
inset && 'pl-8',
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -0,0 +1,24 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = 'Input'
export { Input }

View File

@@ -0,0 +1,25 @@
'use client'
import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,32 @@
import Image from 'next/image'
interface LogoProps {
size?: number
className?: string
}
export function Logo({ size = 36, className = '' }: LogoProps) {
return (
<Image
src="/logo.svg"
alt="Lageplan"
width={size}
height={size}
className={`rounded-lg ${className}`}
priority
/>
)
}
export function LogoRound({ size = 56, className = '' }: LogoProps) {
return (
<Image
src="/logo.svg"
alt="Lageplan"
width={size}
height={size}
className={`rounded-full ${className}`}
priority
/>
)
}

View File

@@ -0,0 +1,47 @@
'use client'
import * as React from 'react'
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
import { cn } from '@/lib/utils'
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' &&
'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,157 @@
'use client'
import * as React from 'react'
import * as SelectPrimitive from '@radix-ui/react-select'
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
import { cn } from '@/lib/utils'
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,54 @@
'use client'
import * as React from 'react'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { cn } from '@/lib/utils'
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

127
src/components/ui/toast.tsx Normal file
View File

@@ -0,0 +1,127 @@
'use client'
import * as React from 'react'
import * as ToastPrimitives from '@radix-ui/react-toast'
import { cva, type VariantProps } from 'class-variance-authority'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
{
variants: {
variant: {
default: 'border bg-background text-foreground',
destructive:
'destructive group border-destructive bg-destructive text-destructive-foreground',
},
},
defaultVariants: {
variant: 'default',
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn('text-sm font-semibold', className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn('text-sm opacity-90', className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

View File

@@ -0,0 +1,35 @@
'use client'
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from '@/components/ui/toast'
import { useToast } from '@/components/ui/use-toast'
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@@ -0,0 +1,27 @@
'use client'
import * as React from 'react'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { cn } from '@/lib/utils'
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,187 @@
'use client'
import * as React from 'react'
import type { ToastActionElement, ToastProps } from '@/components/ui/toast'
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: 'ADD_TOAST',
UPDATE_TOAST: 'UPDATE_TOAST',
DISMISS_TOAST: 'DISMISS_TOAST',
REMOVE_TOAST: 'REMOVE_TOAST',
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType['ADD_TOAST']
toast: ToasterToast
}
| {
type: ActionType['UPDATE_TOAST']
toast: Partial<ToasterToast>
}
| {
type: ActionType['DISMISS_TOAST']
toastId?: ToasterToast['id']
}
| {
type: ActionType['REMOVE_TOAST']
toastId?: ToasterToast['id']
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: 'REMOVE_TOAST',
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'ADD_TOAST':
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case 'UPDATE_TOAST':
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case 'DISMISS_TOAST': {
const { toastId } = action
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case 'REMOVE_TOAST':
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, 'id'>
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: 'UPDATE_TOAST',
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id })
dispatch({
type: 'ADD_TOAST',
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
}
}
export { useToast, toast }