Files
Lageplan/src/components/admin/tenant-detail-dialog.tsx

477 lines
22 KiB
TypeScript

'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>
)
}