feat: Reseller-Rolle + SUPER_ADMIN-Mandantenübersicht
Mandantenfähigkeit ausgebaut: - Neue Rolle RESELLER (company_id NULL); companies.reseller_id + is_active - RLS-Erweiterung (Migration 0034): companies/users zusätzlich auf app.reseller_id gefenced → Reseller sieht/verwaltet DB-seitig nur eigene Firmen, keine personenbezogenen Zeit-/Abwesenheitsdaten (DSGVO: nur Verwaltung) - get_current_user setzt app.reseller_id + Bypass aus für RESELLER - tenant_service: Firma + Erst-Admin (Einladung), Übersicht mit Kennzahlen - Router /reseller/* (Self-Service) und /admin/* (SUPER_ADMIN: Mandanten + Reseller) - Login-Sperre bei deaktiviertem Mandanten - Frontend: TenantsPage (/admin/tenants), eigene ResellerCompaniesPage (/reseller), rollenbasierte Login-Weiterleitung, Nav "Mandanten" für SUPER_ADMIN - 4 neue Tests inkl. Cross-Reseller-RLS-Isolation; 172/172 grün Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
interface Tenant {
|
||||
id: string; name: string; slug: string; plan: string; is_active: boolean
|
||||
country: string; user_count: number; active_user_count: number; admin_email: string | null
|
||||
}
|
||||
interface Me { first_name: string; last_name: string; role: string }
|
||||
|
||||
const empty = { name: '', country: 'DE', plan: 'trial', admin_email: '', admin_first_name: '', admin_last_name: '' }
|
||||
|
||||
export function ResellerCompaniesPage() {
|
||||
const { logout } = useAuth()
|
||||
const [me, setMe] = useState<Me | null>(null)
|
||||
const [companies, setCompanies] = useState<Tenant[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showNew, setShowNew] = useState(false)
|
||||
const [form, setForm] = useState({ ...empty })
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function load() {
|
||||
try { setCompanies(await api.get<Tenant[]>('/reseller/companies')) }
|
||||
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler beim Laden') }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Me>('/auth/me').then(setMe).catch(() => {})
|
||||
load()
|
||||
}, [])
|
||||
|
||||
async function createCompany() {
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
await api.post('/reseller/companies', form)
|
||||
setShowNew(false); setForm({ ...empty }); await load()
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Anlegen')
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
async function toggle(t: Tenant) {
|
||||
try { await api.patch(`/reseller/companies/${t.id}`, { is_active: !t.is_active }); await load() }
|
||||
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
|
||||
}
|
||||
|
||||
const active = companies.filter(c => c.is_active).length
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="bg-white border-b border-gray-200 sticky top-0 z-30">
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 h-14 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-sm">TM</span>
|
||||
</div>
|
||||
<span className="font-bold text-gray-800 text-sm">TimeMaster · Reseller</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-500 hidden sm:block">{me ? `${me.first_name} ${me.last_name}` : ''}</span>
|
||||
<button onClick={logout} title="Abmelden"
|
||||
className="p-2 rounded-lg text-gray-500 hover:text-red-600 hover:bg-red-50">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75}
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-4 sm:px-6 py-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Meine Firmen</h1>
|
||||
<p className="text-sm text-gray-500">{active} aktiv · {companies.length} gesamt</p>
|
||||
</div>
|
||||
<button onClick={() => setShowNew(true)}
|
||||
className="px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700">
|
||||
+ Firma anlegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{companies.map(t => (
|
||||
<div key={t.id} className={`bg-white rounded-xl shadow-sm border p-5 space-y-3 ${t.is_active ? 'border-gray-200' : 'border-gray-200 opacity-60'}`}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-800">{t.name}</h3>
|
||||
<p className="text-xs text-gray-400">{t.slug}</p>
|
||||
</div>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-600">{t.plan}</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">👥 {t.active_user_count}/{t.user_count} Nutzer</div>
|
||||
{t.admin_email && <div className="text-xs text-gray-400 truncate">Admin: {t.admin_email}</div>}
|
||||
<button onClick={() => toggle(t)}
|
||||
className={`w-full text-xs px-2 py-1.5 rounded-lg font-medium ${
|
||||
t.is_active ? 'bg-green-50 text-green-700 hover:bg-green-100' : 'bg-red-50 text-red-700 hover:bg-red-100'
|
||||
}`}>
|
||||
{t.is_active ? '● aktiv – zum Deaktivieren klicken' : '○ deaktiviert – zum Aktivieren klicken'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{companies.length === 0 && (
|
||||
<div className="col-span-full text-center text-gray-400 py-12">Noch keine Firmen angelegt.</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{showNew && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4" onClick={() => setShowNew(false)}>
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-md p-6 space-y-4" onClick={e => e.stopPropagation()}>
|
||||
<h2 className="text-lg font-bold text-gray-900">Neue Firma anlegen</h2>
|
||||
<div className="space-y-3">
|
||||
<L label="Firmenname"><input className={inp} value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} /></L>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<L label="Land"><input className={inp} value={form.country} onChange={e => setForm({ ...form, country: e.target.value })} /></L>
|
||||
<L label="Plan"><input className={inp} value={form.plan} onChange={e => setForm({ ...form, plan: e.target.value })} /></L>
|
||||
</div>
|
||||
<hr />
|
||||
<p className="text-xs text-gray-500">Erster Administrator (erhält Einladung per E-Mail):</p>
|
||||
<L label="Admin E-Mail"><input className={inp} value={form.admin_email} onChange={e => setForm({ ...form, admin_email: e.target.value })} /></L>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<L label="Vorname"><input className={inp} value={form.admin_first_name} onChange={e => setForm({ ...form, admin_first_name: e.target.value })} /></L>
|
||||
<L label="Nachname"><input className={inp} value={form.admin_last_name} onChange={e => setForm({ ...form, admin_last_name: e.target.value })} /></L>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button onClick={() => setShowNew(false)} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Abbrechen</button>
|
||||
<button onClick={createCompany} disabled={busy}
|
||||
className="px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 disabled:opacity-50">
|
||||
{busy ? 'Speichern…' : 'Anlegen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const inp = 'w-full px-3 py-2 border border-gray-300 rounded-lg text-sm'
|
||||
function L({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return <label className="block space-y-1"><span className="text-xs font-medium text-gray-600">{label}</span>{children}</label>
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api/client'
|
||||
import { Layout } from '../components/Layout'
|
||||
|
||||
interface Tenant {
|
||||
id: string; name: string; slug: string; plan: string; is_active: boolean
|
||||
country: string; reseller_id: string | null; reseller_name: string | null
|
||||
user_count: number; active_user_count: number; admin_email: string | null
|
||||
}
|
||||
interface Reseller {
|
||||
id: string; email: string; first_name: string; last_name: string
|
||||
is_active: boolean; company_count: number
|
||||
}
|
||||
interface Me { first_name: string; last_name: string; role: string }
|
||||
|
||||
const empty = { name: '', country: 'DE', plan: 'trial', admin_email: '', admin_first_name: '', admin_last_name: '' }
|
||||
const emptyReseller = { email: '', first_name: '', last_name: '' }
|
||||
|
||||
export function TenantsPage() {
|
||||
const [me, setMe] = useState<Me | null>(null)
|
||||
const [tab, setTab] = useState<'tenants' | 'resellers'>('tenants')
|
||||
const [tenants, setTenants] = useState<Tenant[]>([])
|
||||
const [resellers, setResellers] = useState<Reseller[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showNew, setShowNew] = useState(false)
|
||||
const [form, setForm] = useState({ ...empty, reseller_id: '' })
|
||||
const [showNewReseller, setShowNewReseller] = useState(false)
|
||||
const [rForm, setRForm] = useState({ ...emptyReseller })
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [t, r] = await Promise.all([
|
||||
api.get<Tenant[]>('/admin/tenants'),
|
||||
api.get<Reseller[]>('/admin/resellers'),
|
||||
])
|
||||
setTenants(t); setResellers(r)
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Me>('/auth/me').then(setMe).catch(() => {})
|
||||
load()
|
||||
}, [])
|
||||
|
||||
async function createTenant() {
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
const q = form.reseller_id ? `?reseller_id=${form.reseller_id}` : ''
|
||||
const { reseller_id, ...body } = form
|
||||
void reseller_id
|
||||
await api.post(`/admin/tenants${q}`, body)
|
||||
setShowNew(false); setForm({ ...empty, reseller_id: '' }); await load()
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Anlegen')
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
async function toggleTenant(t: Tenant) {
|
||||
try { await api.patch(`/admin/tenants/${t.id}`, { is_active: !t.is_active }); await load() }
|
||||
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
|
||||
}
|
||||
|
||||
async function assignReseller(t: Tenant, reseller_id: string) {
|
||||
try { await api.patch(`/admin/tenants/${t.id}/reseller`, { reseller_id: reseller_id || null }); await load() }
|
||||
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
|
||||
}
|
||||
|
||||
async function createReseller() {
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
await api.post('/admin/resellers', rForm)
|
||||
setShowNewReseller(false); setRForm({ ...emptyReseller }); await load()
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Anlegen')
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
async function toggleReseller(r: Reseller) {
|
||||
try { await api.patch(`/admin/resellers/${r.id}?is_active=${!r.is_active}`, {}); await load() }
|
||||
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout userRole={me?.role ?? ''} userName={me ? `${me.first_name} ${me.last_name}` : ''}>
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Mandanten</h1>
|
||||
{tab === 'tenants' ? (
|
||||
<button onClick={() => setShowNew(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700">
|
||||
+ Firma anlegen
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={() => setShowNewReseller(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700">
|
||||
+ Reseller anlegen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 border-b border-gray-200">
|
||||
{(['tenants', 'resellers'] as const).map(t => (
|
||||
<button key={t} onClick={() => setTab(t)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
|
||||
tab === t ? 'border-blue-600 text-blue-700' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}>
|
||||
{t === 'tenants' ? `Firmen (${tenants.length})` : `Reseller (${resellers.length})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
|
||||
|
||||
{tab === 'tenants' && (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-gray-500 text-xs uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Firma</th>
|
||||
<th className="text-left px-4 py-3">Plan</th>
|
||||
<th className="text-left px-4 py-3">Nutzer</th>
|
||||
<th className="text-left px-4 py-3">Admin</th>
|
||||
<th className="text-left px-4 py-3">Reseller</th>
|
||||
<th className="text-left px-4 py-3">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{tenants.map(t => (
|
||||
<tr key={t.id} className={t.is_active ? '' : 'bg-gray-50 text-gray-400'}>
|
||||
<td className="px-4 py-3 font-medium text-gray-800">{t.name}<div className="text-xs text-gray-400">{t.slug}</div></td>
|
||||
<td className="px-4 py-3">{t.plan}</td>
|
||||
<td className="px-4 py-3">{t.active_user_count}/{t.user_count}</td>
|
||||
<td className="px-4 py-3 text-xs">{t.admin_email ?? '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<select value={t.reseller_id ?? ''} onChange={e => assignReseller(t, e.target.value)}
|
||||
className="text-xs border border-gray-200 rounded px-1 py-1 bg-white">
|
||||
<option value="">— Plattform —</option>
|
||||
{resellers.map(r => <option key={r.id} value={r.id}>{r.first_name} {r.last_name}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button onClick={() => toggleTenant(t)}
|
||||
className={`text-xs px-2 py-1 rounded-full font-medium ${
|
||||
t.is_active ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
|
||||
}`}>
|
||||
{t.is_active ? 'aktiv' : 'deaktiviert'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{tenants.length === 0 && <tr><td colSpan={6} className="px-4 py-8 text-center text-gray-400">Keine Firmen.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'resellers' && (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-gray-500 text-xs uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Name</th>
|
||||
<th className="text-left px-4 py-3">E-Mail</th>
|
||||
<th className="text-left px-4 py-3">Firmen</th>
|
||||
<th className="text-left px-4 py-3">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{resellers.map(r => (
|
||||
<tr key={r.id}>
|
||||
<td className="px-4 py-3 font-medium text-gray-800">{r.first_name} {r.last_name}</td>
|
||||
<td className="px-4 py-3 text-xs">{r.email}</td>
|
||||
<td className="px-4 py-3">{r.company_count}</td>
|
||||
<td className="px-4 py-3">
|
||||
<button onClick={() => toggleReseller(r)}
|
||||
className={`text-xs px-2 py-1 rounded-full font-medium ${
|
||||
r.is_active ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
|
||||
}`}>
|
||||
{r.is_active ? 'aktiv' : 'deaktiviert'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{resellers.length === 0 && <tr><td colSpan={4} className="px-4 py-8 text-center text-gray-400">Keine Reseller.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNew && (
|
||||
<Modal title="Neue Firma anlegen" onClose={() => setShowNew(false)} onSubmit={createTenant} busy={busy}>
|
||||
<Field label="Firmenname"><input className={inp} value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} /></Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Land"><input className={inp} value={form.country} onChange={e => setForm({ ...form, country: e.target.value })} /></Field>
|
||||
<Field label="Plan"><input className={inp} value={form.plan} onChange={e => setForm({ ...form, plan: e.target.value })} /></Field>
|
||||
</div>
|
||||
<Field label="Reseller (optional)">
|
||||
<select className={inp} value={form.reseller_id} onChange={e => setForm({ ...form, reseller_id: e.target.value })}>
|
||||
<option value="">— Plattform (kein Reseller) —</option>
|
||||
{resellers.map(r => <option key={r.id} value={r.id}>{r.first_name} {r.last_name}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<hr className="my-1" />
|
||||
<p className="text-xs text-gray-500">Erster Administrator (erhält Einladung per E-Mail):</p>
|
||||
<Field label="Admin E-Mail"><input className={inp} value={form.admin_email} onChange={e => setForm({ ...form, admin_email: e.target.value })} /></Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Vorname"><input className={inp} value={form.admin_first_name} onChange={e => setForm({ ...form, admin_first_name: e.target.value })} /></Field>
|
||||
<Field label="Nachname"><input className={inp} value={form.admin_last_name} onChange={e => setForm({ ...form, admin_last_name: e.target.value })} /></Field>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showNewReseller && (
|
||||
<Modal title="Neuen Reseller anlegen" onClose={() => setShowNewReseller(false)} onSubmit={createReseller} busy={busy}>
|
||||
<p className="text-xs text-gray-500">Der Reseller erhält eine Einladung per E-Mail und kann danach eigene Firmen anlegen und verwalten.</p>
|
||||
<Field label="E-Mail"><input className={inp} value={rForm.email} onChange={e => setRForm({ ...rForm, email: e.target.value })} /></Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Vorname"><input className={inp} value={rForm.first_name} onChange={e => setRForm({ ...rForm, first_name: e.target.value })} /></Field>
|
||||
<Field label="Nachname"><input className={inp} value={rForm.last_name} onChange={e => setRForm({ ...rForm, last_name: e.target.value })} /></Field>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
const inp = 'w-full px-3 py-2 border border-gray-300 rounded-lg text-sm'
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return <label className="block space-y-1"><span className="text-xs font-medium text-gray-600">{label}</span>{children}</label>
|
||||
}
|
||||
|
||||
function Modal({ title, children, onClose, onSubmit, busy }: {
|
||||
title: string; children: React.ReactNode; onClose: () => void; onSubmit: () => void; busy: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4" onClick={onClose}>
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-md p-6 space-y-4" onClick={e => e.stopPropagation()}>
|
||||
<h2 className="text-lg font-bold text-gray-900">{title}</h2>
|
||||
<div className="space-y-3">{children}</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Abbrechen</button>
|
||||
<button onClick={onSubmit} disabled={busy}
|
||||
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50">
|
||||
{busy ? 'Speichern…' : 'Anlegen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user