Design: Admin mit Tabelle, Mitarbeiter-Formular als Dialog, AdminPage.css neu

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-06 14:42:34 +00:00
1 parent 0a881fce8f
commit 6adeac1642
3 files changed
+211 -498

No files matched your search

+192 -134
View File
@@ -1,15 +1,10 @@
import { useState } from 'react'
import {
FaFloppyDisk,
FaPlus,
FaSpinner,
FaTrash,
FaXmark,
} from 'react-icons/fa6'
import { FaEdit } from 'react-icons/fa'
import { useRef, useState } from 'react'
import CopyableCredential from '../components/CopyableCredential'
import { Badge, Button, Card, Dialog, EmptyState, Field, PageHeader, Tabs } from '../components/ui'
import { useAuth } from '../context/AuthContext'
import { useEmployees } from '../hooks/useEmployees'
import { useScrollIntoView } from '../hooks/useScrollIntoView'
import { validateEmployeeForm } from '../lib/employeeForm'
import './AdminPage.css'
const EMPTY_EMPLOYEE = {
@@ -22,7 +17,11 @@ const EMPTY_EMPLOYEE = {
function Notice({ notice }) {
if (!notice?.text) return null
return <div className={`admin-notice ${notice.type}`}>{notice.text}</div>
return (
<div className={`msg ${notice.type === 'error' ? 'err' : 'ok'} admin-notice`} role={notice.type === 'error' ? 'alert' : 'status'}>
{notice.text}
</div>
)
}
export default function AdminPage() {
@@ -40,9 +39,14 @@ export default function AdminPage() {
const [activeSection, setActiveSection] = useState('employees')
const [employeeForm, setEmployeeForm] = useState(EMPTY_EMPLOYEE)
const [editingEmployee, setEditingEmployee] = useState(null)
const [formOpen, setFormOpen] = useState(false)
const [formError, setFormError] = useState(null)
const [employeeBusy, setEmployeeBusy] = useState(false)
const [createdCredential, setCreatedCredential] = useState(null)
const [notice, setNotice] = useState(null)
const errorRef = useRef(null)
// Hooks stehen vor dem frühen return bei !isAdmin
useScrollIntoView(errorRef, formError)
const showNotice = (type, text) => {
setNotice({ type, text })
@@ -61,43 +65,21 @@ export default function AdminPage() {
)
}
const resetEmployeeForm = () => {
// Der Admin-Haken des eigenen Kontos ist gesperrt: man nimmt sich nicht selbst die Rechte
const editingSelf = Boolean(editingEmployee) && employees.find((item) => item.$id === editingEmployee)?.userId === user?.$id
const closeForm = () => {
setFormOpen(false)
setFormError(null)
setEmployeeForm(EMPTY_EMPLOYEE)
setEditingEmployee(null)
}
const submitEmployee = async (event) => {
event.preventDefault()
if (!employeeForm.displayName.trim() || !employeeForm.email.trim()) {
showNotice('error', 'Name und E-Mail sind erforderlich.')
return
}
if (!editingEmployee && employeeForm.password.length < 8) {
showNotice('error', 'Das Passwort muss mindestens 8 Zeichen haben.')
return
}
if (editingEmployee && employeeForm.password && employeeForm.password.length < 8) {
showNotice('error', 'Ein neues Passwort muss mindestens 8 Zeichen haben.')
return
}
setEmployeeBusy(true)
const result = editingEmployee
? await updateEmployee(editingEmployee, employeeForm)
: await createEmployee(employeeForm)
setEmployeeBusy(false)
if (!result.success) {
showNotice('error', result.error || 'Mitarbeiter konnte nicht gespeichert werden.')
return
}
if (!editingEmployee) {
setCreatedCredential({
email: employeeForm.email.trim(),
password: employeeForm.password,
})
}
showNotice('success', editingEmployee ? 'Mitarbeiter aktualisiert.' : 'Mitarbeiter angelegt. Der Login ist sofort aktiv.')
resetEmployeeForm()
const createNew = () => {
setEmployeeForm(EMPTY_EMPLOYEE)
setEditingEmployee(null)
setFormError(null)
setFormOpen(true)
}
const editEmployee = (employee) => {
@@ -109,6 +91,36 @@ export default function AdminPage() {
shortcode: employee.shortcode || '',
isAdmin: Boolean(employee.isAdmin),
})
setFormError(null)
setFormOpen(true)
}
const submitEmployee = async (event) => {
event.preventDefault()
const message = validateEmployeeForm(employeeForm, Boolean(editingEmployee))
if (message) {
setFormError(message)
return
}
setFormError(null)
setEmployeeBusy(true)
const result = editingEmployee
? await updateEmployee(editingEmployee, employeeForm)
: await createEmployee(employeeForm)
setEmployeeBusy(false)
if (!result.success) {
setFormError(result.error || 'Mitarbeiter konnte nicht gespeichert werden.')
return
}
if (!editingEmployee) {
setCreatedCredential({
email: employeeForm.email.trim(),
password: employeeForm.password,
})
}
showNotice('success', editingEmployee ? 'Mitarbeiter aktualisiert.' : 'Mitarbeiter angelegt. Der Login ist sofort aktiv.')
closeForm()
}
const removeEmployee = async (employee) => {
@@ -120,107 +132,153 @@ export default function AdminPage() {
)
}
const setField = (key) => (event) => setEmployeeForm({ ...employeeForm, [key]: event.target.value })
return (
<div className="page admin-page">
<header className="admin-header">
<div>
<span className="admin-eyebrow">Systemverwaltung</span>
<h1>Admin</h1>
<p>Mitarbeiter und ihre Zugänge verwalten. Kunden pflegst du unter „Kunden“.</p>
</div>
<div className="admin-current-user">
<span>Angemeldet als</span>
<strong>{user?.name || user?.email}</strong>
</div>
</header>
<PageHeader
eyebrow="Systemverwaltung"
title="Admin"
subtitle="Mitarbeiter und ihre Zugänge verwalten. Kunden pflegst du unter „Kunden“."
actions={
<>
<span className="muted small">Angemeldet als <strong>{user?.name || user?.email}</strong></span>
<Button variant="primary" type="button" onClick={createNew}>Mitarbeiter anlegen</Button>
</>
}
/>
<Notice notice={notice} />
<nav className="admin-tabs" aria-label="Admin-Bereiche">
{[
['employees', `Mitarbeiter (${employees.length})`],
].map(([id, label]) => (
<button
type="button"
key={id}
className={activeSection === id ? 'active' : ''}
onClick={() => setActiveSection(id)}
>
{label}
</button>
))}
</nav>
<Tabs
tabs={[{ id: 'employees', label: `Mitarbeiter (${employees.length})` }]}
active={activeSection}
onChange={setActiveSection}
/>
{activeSection === 'employees' && (
<section>
<>
{createdCredential && (
<div className="admin-credential-result">
<div><strong>Login wurde angelegt</strong><span>Diese Zugangsdaten jetzt sicher weitergeben.</span></div>
<CopyableCredential value={createdCredential.email} label="E-Mail" />
<CopyableCredential value={createdCredential.password} secret label="Passwort" />
<button type="button" className="admin-icon-button" onClick={() => setCreatedCredential(null)} aria-label="Hinweis schließen"><FaXmark /></button>
</div>
)}
<div className="admin-split-layout">
<form className="admin-form-card" onSubmit={submitEmployee}>
<div className="admin-section-head">
<div>
<h2>{editingEmployee ? 'Mitarbeiter bearbeiten' : 'Mitarbeiter anlegen'}</h2>
<span>Die User-ID wird automatisch vergeben.</span>
</div>
{editingEmployee && <button type="button" className="admin-icon-button" onClick={resetEmployeeForm} aria-label="Bearbeitung schließen"><FaXmark /></button>}
<Card
title="Login wurde angelegt"
actions={
<button type="button" className="btn btn-quiet" onClick={() => setCreatedCredential(null)} aria-label="Hinweis schließen">×</button>
}
>
<p className="muted small">Diese Zugangsdaten jetzt sicher weitergeben.</p>
<div className="credential-list">
<CopyableCredential value={createdCredential.email} label="E-Mail" />
<CopyableCredential value={createdCredential.password} secret label="Passwort" />
</div>
<label className="admin-field"><span>Name</span><input className="form-control" required value={employeeForm.displayName} onChange={(event) => setEmployeeForm({ ...employeeForm, displayName: event.target.value })} /></label>
<label className="admin-field"><span>E-Mail</span><input type="email" className="form-control" required value={employeeForm.email} onChange={(event) => setEmployeeForm({ ...employeeForm, email: event.target.value })} /></label>
<label className="admin-field"><span>{editingEmployee ? 'Neues Passwort (optional)' : 'Passwort'}</span><input type="password" className="form-control" autoComplete="new-password" required={!editingEmployee} value={employeeForm.password} onChange={(event) => setEmployeeForm({ ...employeeForm, password: event.target.value })} placeholder="Mindestens 8 Zeichen" /></label>
<label className="admin-field"><span>Kürzel</span><input className="form-control" maxLength={10} value={employeeForm.shortcode} onChange={(event) => setEmployeeForm({ ...employeeForm, shortcode: event.target.value.toUpperCase() })} placeholder="z. B. KNSO" /></label>
<label className={`admin-checkbox ${editingEmployee && employees.find((item) => item.$id === editingEmployee)?.userId === user?.$id ? 'disabled' : ''}`}>
<input
type="checkbox"
checked={employeeForm.isAdmin}
disabled={editingEmployee && employees.find((item) => item.$id === editingEmployee)?.userId === user?.$id}
onChange={(event) => setEmployeeForm({ ...employeeForm, isAdmin: event.target.checked })}
/>
<span><strong>Admin-Account</strong><small>Darf Systemkonfiguration und Benutzer verwalten.</small></span>
</label>
<button className="admin-primary-button" disabled={employeeBusy}>
{employeeBusy ? <FaSpinner className="spin" /> : editingEmployee ? <FaFloppyDisk /> : <FaPlus />}
{editingEmployee ? 'Änderungen speichern' : 'Mitarbeiter anlegen'}
</button>
</form>
</Card>
)}
<section className="admin-list-card">
<div className="admin-section-head"><div><h2>Mitarbeiterkonten</h2><span>Aktive Logins für das Ticketsystem</span></div></div>
{employeesError && <Notice notice={{ type: 'error', text: employeesError }} />}
{employeesLoading ? (
<div className="admin-loading"><FaSpinner className="spinner" /> Mitarbeiter werden geladen …</div>
) : employees.length === 0 ? (
<div className="admin-empty-state"><h3>Noch keine Mitarbeiter</h3><p>Lege links den ersten Login an.</p></div>
) : (
<div className="admin-table-wrap">
<table className="admin-table">
<thead><tr><th>Mitarbeiter</th><th>E-Mail</th><th>Kürzel</th><th>Rolle</th><th>User-ID</th><th><span className="sr-only">Aktionen</span></th></tr></thead>
<tbody>
{employees.map((employee) => (
<tr key={employee.$id}>
<td><strong>{employee.displayName || '–'}</strong></td>
<td><CopyableCredential value={employee.email} label="E-Mail" /></td>
<td><span className="admin-shortcode">{employee.shortcode || '–'}</span></td>
<td><span className={`admin-role ${employee.isAdmin ? 'admin' : ''}`}>{employee.isAdmin ? 'Admin' : 'Mitarbeiter'}</span></td>
<td><CopyableCredential value={employee.userId} label="User-ID" /></td>
<td className="admin-row-actions">
<button type="button" className="admin-icon-button" onClick={() => editEmployee(employee)} aria-label={`${employee.displayName} bearbeiten`}><FaEdit /></button>
{employee.userId !== user?.$id && <button type="button" className="admin-icon-button danger" onClick={() => removeEmployee(employee)} aria-label={`${employee.displayName} löschen`}><FaTrash /></button>}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
</section>
<Card title="Mitarbeiterkonten">
<p className="muted small admin-lead">Aktive Logins für das Ticketsystem</p>
{employeesError && <div className="msg err" role="alert">{employeesError}</div>}
{employeesLoading ? (
<div className="loading">Mitarbeiter werden geladen …</div>
) : employees.length === 0 ? (
<EmptyState title="Noch keine Mitarbeiter" hint="Lege mit „Mitarbeiter anlegen“ den ersten Login an." />
) : (
<table className="table">
<thead>
<tr>
<th>Mitarbeiter</th>
<th>E-Mail</th>
<th className="hide-sm">Kürzel</th>
<th className="hide-sm">Rolle</th>
<th className="hide-sm">User-ID</th>
<th className="right"><span className="sr-only">Aktionen</span></th>
</tr>
</thead>
<tbody>
{employees.map((employee) => (
<tr key={employee.$id}>
<td><strong>{employee.displayName || '–'}</strong></td>
<td><CopyableCredential value={employee.email} label="E-Mail" /></td>
<td className="hide-sm"><span className="admin-shortcode">{employee.shortcode || '–'}</span></td>
<td className="hide-sm">
{employee.isAdmin ? <Badge tone="ok">Admin</Badge> : <span className="muted">Mitarbeiter</span>}
</td>
<td className="hide-sm"><CopyableCredential value={employee.userId} label="User-ID" /></td>
<td className="right">
<div className="admin-actions">
<button type="button" className="link" onClick={() => editEmployee(employee)} aria-label={`${employee.displayName} bearbeiten`}>Bearbeiten</button>
{employee.userId !== user?.$id && (
<Button variant="danger" type="button" onClick={() => removeEmployee(employee)} aria-label={`${employee.displayName} löschen`}>Löschen</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
</>
)}
{formOpen && (
<Dialog
as="form"
title={editingEmployee ? 'Mitarbeiter bearbeiten' : 'Mitarbeiter anlegen'}
busy={employeeBusy}
onClose={closeForm}
onSubmit={submitEmployee}
footer={
<>
<span className="grow" />
<Button type="button" disabled={employeeBusy} onClick={closeForm}>Abbrechen</Button>
<Button variant="primary" type="submit" disabled={employeeBusy}>
{employeeBusy ? 'Speichert …' : editingEmployee ? 'Änderungen speichern' : 'Mitarbeiter anlegen'}
</Button>
</>
}
>
{formError && <div className="msg err" role="alert" ref={errorRef}>{formError}</div>}
<p className="muted small">Die User-ID wird automatisch vergeben.</p>
<Field label="Name" htmlFor="employee-displayName">
<input className="input" id="employee-displayName" required value={employeeForm.displayName} onChange={setField('displayName')} />
</Field>
<Field label="E-Mail" htmlFor="employee-email">
<input className="input" id="employee-email" type="email" required value={employeeForm.email} onChange={setField('email')} />
</Field>
<Field label={editingEmployee ? 'Neues Passwort (optional)' : 'Passwort'} htmlFor="employee-password">
<input
className="input"
id="employee-password"
type="password"
autoComplete="new-password"
required={!editingEmployee}
value={employeeForm.password}
onChange={setField('password')}
placeholder="Mindestens 8 Zeichen"
/>
</Field>
<Field label="Kürzel" htmlFor="employee-shortcode">
<input
className="input"
id="employee-shortcode"
maxLength={10}
value={employeeForm.shortcode}
onChange={(event) => setEmployeeForm({ ...employeeForm, shortcode: event.target.value.toUpperCase() })}
placeholder="z. B. KNSO"
/>
</Field>
<label className={`check admin-check${editingSelf ? ' is-disabled' : ''}`}>
<input
type="checkbox"
checked={employeeForm.isAdmin}
disabled={editingSelf}
onChange={(event) => setEmployeeForm({ ...employeeForm, isAdmin: event.target.checked })}
/>
<span>
<strong>Admin-Account</strong>
<small>Darf Systemkonfiguration und Benutzer verwalten.</small>
</span>
</label>
</Dialog>
)}
</div>
)