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 ( -
-
+

Wird geladen …

) 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 (
-
-
- Systemverwaltung -

Admin

-

Mitarbeiter und ihre Zugänge verwalten. Kunden pflegst du unter „Kunden“.

-
-
- Angemeldet als - {user?.name || user?.email} -
-
+ + Angemeldet als {user?.name || user?.email} + + + } + /> - + {activeSection === 'employees' && ( -
+ <> {createdCredential && ( -
-
Login wurde angelegtDiese Zugangsdaten jetzt sicher weitergeben.
- - - -
- )} -
-
-
-
-

{editingEmployee ? 'Mitarbeiter bearbeiten' : 'Mitarbeiter anlegen'}

- Die User-ID wird automatisch vergeben. -
- {editingEmployee && } + setCreatedCredential(null)} aria-label="Hinweis schließen">× + } + > +

Diese Zugangsdaten jetzt sicher weitergeben.

+
+ +
- - - - - - - +
+ )} -
-

Mitarbeiterkonten

Aktive Logins für das Ticketsystem
- {employeesError && } - {employeesLoading ? ( -
Mitarbeiter werden geladen …
- ) : employees.length === 0 ? ( -

Noch keine Mitarbeiter

Lege links den ersten Login an.

- ) : ( -
- - - - {employees.map((employee) => ( - - - - - - - - - ))} - -
MitarbeiterE-MailKürzelRolleUser-IDAktionen
{employee.displayName || '–'}{employee.shortcode || '–'}{employee.isAdmin ? 'Admin' : 'Mitarbeiter'} - - {employee.userId !== user?.$id && } -
-
- )} -
-
-
+ +

Aktive Logins für das Ticketsystem

+ {employeesError &&
{employeesError}
} + {employeesLoading ? ( +
Mitarbeiter werden geladen …
+ ) : employees.length === 0 ? ( + + ) : ( + + + + + + + + + + + + + {employees.map((employee) => ( + + + + + + + + + ))} + +
MitarbeiterE-MailKürzelRolleUser-IDAktionen
{employee.displayName || '–'}{employee.shortcode || '–'} + {employee.isAdmin ? Admin : Mitarbeiter} + +
+ + {employee.userId !== user?.$id && ( + + )} +
+
+ )} +
+ + )} + + {formOpen && ( + + + + + + } + > + {formError &&
{formError}
} +

Die User-ID wird automatisch vergeben.

+ + + + + + + + + + + setEmployeeForm({ ...employeeForm, shortcode: event.target.value.toUpperCase() })} + placeholder="z. B. KNSO" + /> + + +
)}
)