diff --git a/src/components/AdminRoute.jsx b/src/components/AdminRoute.jsx
index 5ea4444..2734180 100644
--- a/src/components/AdminRoute.jsx
+++ b/src/components/AdminRoute.jsx
@@ -7,8 +7,7 @@ export default function AdminRoute({ children }) {
if (loading) {
return (
-
-
+
)
diff --git a/src/pages/AdminPage.css b/src/pages/AdminPage.css
index a33ec73..1705b54 100644
--- a/src/pages/AdminPage.css
+++ b/src/pages/AdminPage.css
@@ -1,364 +1,20 @@
-/* .page (global.css, wird danach geladen) gewinnt bei gleicher Spezifitaet: darum .page.admin-page */
-.page.admin-page {
- max-width: 1480px;
-}
-.admin-page {
- padding-bottom: 48px;
-}
+/* Admin: Mitarbeiter-Tabelle, Zugangsdaten, Dialog. Karten, Rahmen und Schatten gibt es nicht mehr; Abstände zwischen den */
+/* Abschnitten liefert .page section + section (global.css). AdminPage.css lädt vor global.css, darum haben Regeln, die eine */
+/* Global-Klasse überstimmen, die Spezifität 0,2,0. Zugangsdaten (.credential*) und .sr-only stehen in global.css. */
+.page.admin-page { max-width: 1480px; }
+.admin-notice { margin-bottom: 16px; }
+.admin-lead { margin: 0 0 8px; }
+.admin-shortcode { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; letter-spacing: .04em; }
+.admin-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px 16px; }
+.credential-list { display: flex; flex-wrap: wrap; gap: 8px 32px; margin-top: 8px; }
-.admin-header {
- display: flex;
- align-items: flex-end;
- justify-content: space-between;
- gap: 24px;
- margin-bottom: 24px;
-}
+/* Admin-Haken im Dialog: Titel und Erklärung untereinander, der Haken oben links */
+.check.admin-check { align-items: flex-start; }
+.admin-check small { display: block; margin-top: 2px; font-size: 13px; line-height: 18px; color: var(--text-2); }
+.admin-check.is-disabled { cursor: not-allowed; opacity: .55; }
-.admin-header h1 {
- margin: 4px 0 6px;
- font-size: clamp(28px, 3vw, 38px);
- line-height: 1.1;
- letter-spacing: -0.03em;
-}
-
-.admin-header p,
-.admin-current-user span,
-.admin-section-head span {
- color: var(--text-muted);
- font-size: 13px;
-}
-
-.admin-eyebrow {
- color: var(--accent);
- font-size: 11px;
- font-weight: 700;
- letter-spacing: 0.11em;
- text-transform: uppercase;
-}
-
-.admin-current-user {
- min-width: 220px;
- padding-left: 16px;
- border-left: 2px solid var(--accent);
-}
-
-.admin-current-user span,
-.admin-current-user strong {
- display: block;
-}
-
-.admin-current-user strong {
- margin-top: 3px;
- font-size: 14px;
-}
-
-.admin-tabs {
- display: flex;
- gap: 0;
- margin-bottom: 22px;
- border-bottom: 1px solid var(--admin-line);
-}
-
-.admin-tabs button {
- position: relative;
- padding: 12px 18px;
- border: 0;
- background: transparent;
- color: var(--text-muted);
- font: inherit;
- font-size: 14px;
- font-weight: 650;
- cursor: pointer;
-}
-
-.admin-tabs button::after {
- content: '';
- position: absolute;
- right: 18px;
- bottom: -1px;
- left: 18px;
- height: 2px;
- background: transparent;
-}
-
-.admin-tabs button:hover,
-.admin-tabs button.active {
- color: var(--text);
-}
-
-.admin-tabs button.active::after {
- background: var(--accent);
-}
-
-.admin-notice {
- margin-bottom: 16px;
- padding: 11px 14px;
- border: 1px solid var(--admin-line);
- border-left-width: 3px;
- background: var(--surface-1);
- font-size: 14px;
-}
-
-.admin-notice.success { border-left-color: var(--ok); }
-.admin-notice.error { border-left-color: var(--danger); }
-
-.admin-form-card,
-.admin-list-card {
- border: 1px solid var(--admin-line);
- background: var(--surface-1);
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
-}
-
-.admin-section-head {
- display: flex;
- min-height: 62px;
- align-items: center;
- justify-content: space-between;
- gap: 14px;
- padding: 14px 16px;
- border-bottom: 1px solid var(--admin-line);
-}
-
-.admin-section-head h2,
-.admin-section-head h3 {
- margin: 0 0 3px;
- font-size: 15px;
- line-height: 1.3;
-}
-
-.admin-icon-button {
- display: inline-flex;
- min-width: 34px;
- min-height: 34px;
- align-items: center;
- justify-content: center;
- gap: 7px;
- padding: 7px 9px;
- border: 1px solid var(--admin-line-strong);
- border-radius: 6px;
- background: transparent;
- color: var(--text-muted);
- font: inherit;
- font-size: 12px;
- font-weight: 650;
- cursor: pointer;
-}
-
-.admin-icon-button:hover {
- border-color: var(--accent-ring);
- color: var(--text);
- background: var(--accent-soft);
-}
-
-.admin-icon-button.danger:hover {
- border-color: var(--danger);
- color: var(--red-fg);
- background: var(--red-bg);
-}
-
-.admin-primary-button {
- display: inline-flex;
- min-height: 40px;
- align-items: center;
- justify-content: center;
- gap: 8px;
- padding: 9px 15px;
- border: 1px solid var(--accent);
- border-radius: 6px;
- background: var(--accent);
- color: #fff;
- font: inherit;
- font-size: 13px;
- font-weight: 750;
- cursor: pointer;
-}
-
-.admin-primary-button:hover { background: var(--accent-hover); }
-.admin-primary-button:disabled { opacity: 0.55; cursor: wait; }
-
-.admin-split-layout {
- display: grid;
- grid-template-columns: minmax(290px, 360px) minmax(0, 1fr);
- gap: 16px;
- align-items: start;
-}
-
-.admin-form-card {
- position: sticky;
- top: 20px;
- padding-bottom: 16px;
-}
-
-.admin-form-card > .admin-field,
-.admin-form-card > .admin-checkbox,
-.admin-form-card > .admin-primary-button {
- margin-right: 16px;
- margin-left: 16px;
-}
-
-.admin-field {
- display: block;
- margin-top: 13px;
-}
-
-.admin-field > span {
- display: flex;
- align-items: baseline;
- justify-content: space-between;
- margin-bottom: 6px;
- color: var(--text-muted);
- font-size: 12px;
- font-weight: 650;
-}
-
-.admin-field em {
- color: var(--text-faint);
- font-style: normal;
- font-weight: 500;
-}
-
-.admin-form-card > .admin-primary-button {
- width: calc(100% - 32px);
- margin-top: 16px;
-}
-
-.admin-checkbox {
- display: flex;
- align-items: flex-start;
- gap: 10px;
- margin-top: 16px;
- padding: 11px;
- border: 1px solid var(--admin-line);
- cursor: pointer;
-}
-
-.admin-checkbox input { margin-top: 2px; accent-color: var(--accent); }
-.admin-checkbox span,
-.admin-checkbox strong,
-.admin-checkbox small { display: block; }
-.admin-checkbox strong { font-size: 13px; }
-.admin-checkbox small { margin-top: 3px; color: var(--text-muted); line-height: 1.35; }
-.admin-checkbox.disabled { cursor: not-allowed; opacity: 0.65; }
-
-.admin-list-card { min-width: 0; }
-.admin-table-wrap { width: 100%; overflow-x: auto; }
-
-.admin-table {
- width: 100%;
- border-collapse: collapse;
- white-space: nowrap;
-}
-
-.admin-table th {
- padding: 10px 13px;
- border-bottom: 1px solid var(--admin-line);
- color: var(--text-faint);
- font-size: 10px;
- font-weight: 700;
- letter-spacing: 0.06em;
- text-align: left;
- text-transform: uppercase;
-}
-
-.admin-table td {
- padding: 12px 13px;
- border-bottom: 1px solid var(--line);
- color: var(--text);
- font-size: 13px;
- vertical-align: middle;
-}
-
-.admin-table tbody tr:last-child td { border-bottom: 0; }
-.admin-table tbody tr:hover td { background: var(--bg-subtle); }
-.admin-table td > strong,
-.admin-table td > span { display: block; }
-.admin-table td > span { margin-top: 3px; color: var(--text-muted); font-size: 12px; }
-
-.admin-row-actions {
- display: flex;
- justify-content: flex-end;
- gap: 6px;
-}
-
-/* Tabellenzellen: .admin-table td > span (0,1,2) setzt display/Farbe/Schrift; die Selektoren hier sind spezifischer (0,2,1) */
-.admin-table td > .admin-status,
-.admin-table td > .admin-role {
- display: inline-flex;
- width: fit-content;
- align-items: center;
- padding: 3px 7px;
- border: 1px solid var(--admin-line-strong);
- border-radius: 4px;
- color: var(--text-muted);
- font-size: 11px;
- font-weight: 700;
-}
-
-.admin-table td > .admin-status.active,
-.admin-table td > .admin-role.admin {
- border-color: var(--green-fg);
- color: var(--green-fg);
-}
-
-.admin-table td > .admin-shortcode {
- color: var(--text);
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
- font-size: 12px;
- letter-spacing: 0.04em;
-}
-
-.admin-credential-result {
- display: grid;
- grid-template-columns: minmax(190px, 1fr) minmax(180px, auto) minmax(180px, auto) auto;
- gap: 16px;
- align-items: center;
- margin-bottom: 16px;
- padding: 13px 15px;
- border: 1px solid var(--line-strong);
- background: var(--bg-subtle);
-}
-
-/* Nur der Hinweistext im ersten Feld: ein Nachfahrenselektor `span` (0,1,1) schlug .credential (display: flex, gap) und .credential-value, die Textlinks klebten ohne Abstand aneinander */
-.admin-credential-result strong,
-.admin-credential-result > div > span { display: block; }
-.admin-credential-result > div > span { margin-top: 3px; color: var(--text-muted); font-size: 12px; }
-
-.admin-loading,
-.admin-empty-state,
-.admin-empty-line {
- padding: 28px 18px;
- color: var(--text-muted);
- text-align: center;
-}
-
-.admin-loading .spinner { width: 22px; height: 22px; margin-bottom: 9px; }
-.admin-empty-state h3 { margin-bottom: 5px; color: var(--text); font-size: 15px; }
-.admin-empty-state p { font-size: 13px; }
-.admin-empty-line { padding: 16px 8px; font-size: 13px; }
-
-.admin-access-denied {
- max-width: 540px;
- margin: 12vh auto 0;
- padding: 32px;
- border-left: 3px solid var(--danger);
- background: var(--surface-1);
-}
-
-.admin-access-denied > span { color: var(--danger); font-size: 12px; font-weight: 800; letter-spacing: 0.1em; }
-.admin-access-denied h1 { margin: 8px 0; }
-.admin-access-denied p { color: var(--text-muted); }
-
-@media (max-width: 1060px) {
- .admin-split-layout { grid-template-columns: 1fr; }
- .admin-form-card { position: static; }
- .admin-credential-result { grid-template-columns: 1fr auto; }
-}
-
-@media (max-width: 760px) {
- .admin-page { padding: 16px; }
- .admin-header { align-items: flex-start; flex-direction: column; }
- .admin-current-user { width: 100%; }
- .admin-tabs { overflow-x: auto; }
- .admin-tabs button { flex: 0 0 auto; padding: 11px 12px; }
- .admin-credential-result { grid-template-columns: 1fr; }
-}
+/* 403 */
+.admin-access-denied { max-width: 540px; margin: 12vh auto 0; }
+.admin-access-denied > span { font-size: 12px; font-weight: 500; color: var(--red-fg); }
+.admin-access-denied h1 { margin: 4px 0 8px; font-size: 28px; line-height: 34px; font-weight: 700; letter-spacing: -.02em; }
+.admin-access-denied p { color: var(--text-2); }
diff --git a/src/pages/AdminPage.jsx b/src/pages/AdminPage.jsx
index 12befa6..13123ea 100644
--- a/src/pages/AdminPage.jsx
+++ b/src/pages/AdminPage.jsx
@@ -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
{notice.text}
+ return (
+
+ {notice.text}
+
+ )
}
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 (
-
+
+ Angemeldet als {user?.name || user?.email}
+
+ >
+ }
+ />
-
+
{activeSection === 'employees' && (
-
+ <>
{createdCredential && (
-
-
Login wurde angelegtDiese Zugangsdaten jetzt sicher weitergeben.
-
-
-
-
- )}
-
)