Initial commit: Lageplan v1.0 - Next.js 15.5, React 19
This commit is contained in:
476
src/components/admin/tenant-detail-dialog.tsx
Normal file
476
src/components/admin/tenant-detail-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
313
src/components/dialogs/hose-settings-dialog.tsx
Normal file
313
src/components/dialogs/hose-settings-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
88
src/components/dialogs/line-label-dialog.tsx
Normal file
88
src/components/dialogs/line-label-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
348
src/components/dialogs/project-dialog.tsx
Normal file
348
src/components/dialogs/project-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
90
src/components/dialogs/text-dialog.tsx
Normal file
90
src/components/dialogs/text-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1116
src/components/journal/journal-view.tsx
Normal file
1116
src/components/journal/journal-view.tsx
Normal file
File diff suppressed because it is too large
Load Diff
134
src/components/layout/layer-list.tsx
Normal file
134
src/components/layout/layer-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
164
src/components/layout/left-toolbar.tsx
Normal file
164
src/components/layout/left-toolbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
130
src/components/layout/legend.tsx
Normal file
130
src/components/layout/legend.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
215
src/components/layout/properties-panel.tsx
Normal file
215
src/components/layout/properties-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
393
src/components/layout/right-sidebar.tsx
Normal file
393
src/components/layout/right-sidebar.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
542
src/components/layout/topbar.tsx
Normal file
542
src/components/layout/topbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
76
src/components/layout/trial-banner.tsx
Normal file
76
src/components/layout/trial-banner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
src/components/map/custom-drag-layer.tsx
Normal file
37
src/components/map/custom-drag-layer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
2265
src/components/map/map-view.tsx
Normal file
2265
src/components/map/map-view.tsx
Normal file
File diff suppressed because it is too large
Load Diff
120
src/components/providers/auth-provider.tsx
Normal file
120
src/components/providers/auth-provider.tsx
Normal 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
|
||||
}
|
||||
13
src/components/providers/sw-register.tsx
Normal file
13
src/components/providers/sw-register.tsx
Normal 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
|
||||
}
|
||||
52
src/components/ui/button.tsx
Normal file
52
src/components/ui/button.tsx
Normal 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 }
|
||||
118
src/components/ui/dialog.tsx
Normal file
118
src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
191
src/components/ui/dropdown-menu.tsx
Normal file
191
src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
}
|
||||
24
src/components/ui/input.tsx
Normal file
24
src/components/ui/input.tsx
Normal 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 }
|
||||
25
src/components/ui/label.tsx
Normal file
25
src/components/ui/label.tsx
Normal 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 }
|
||||
32
src/components/ui/logo.tsx
Normal file
32
src/components/ui/logo.tsx
Normal 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
|
||||
/>
|
||||
)
|
||||
}
|
||||
47
src/components/ui/scroll-area.tsx
Normal file
47
src/components/ui/scroll-area.tsx
Normal 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 }
|
||||
157
src/components/ui/select.tsx
Normal file
157
src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
54
src/components/ui/tabs.tsx
Normal file
54
src/components/ui/tabs.tsx
Normal 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
127
src/components/ui/toast.tsx
Normal 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,
|
||||
}
|
||||
35
src/components/ui/toaster.tsx
Normal file
35
src/components/ui/toaster.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
src/components/ui/tooltip.tsx
Normal file
27
src/components/ui/tooltip.tsx
Normal 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 }
|
||||
187
src/components/ui/use-toast.ts
Normal file
187
src/components/ui/use-toast.ts
Normal 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 }
|
||||
Reference in New Issue
Block a user