diff --git a/src/components/CopyableCredential.jsx b/src/components/CopyableCredential.jsx index 510cae4..3369156 100644 --- a/src/components/CopyableCredential.jsx +++ b/src/components/CopyableCredential.jsx @@ -1,80 +1,78 @@ -import { useState } from 'react' -import { FaEye, FaEyeSlash, FaCheck } from 'react-icons/fa6' +import { useEffect, useRef, useState } from 'react' +import { FaCheck, FaCopy, FaEye, FaEyeSlash } from 'react-icons/fa6' -/** - * Anzeige mit Auge: Klick kopiert den Wert und schaltet Sichtbarkeit um. - */ -export default function CopyableCredential({ value }) { - const [visible, setVisible] = useState(false) +async function copyText(text) { + try { + await navigator.clipboard.writeText(text) + } catch { + const textarea = document.createElement('textarea') + textarea.value = text + textarea.setAttribute('readonly', '') + textarea.style.position = 'fixed' + textarea.style.left = '-9999px' + document.body.appendChild(textarea) + textarea.select() + document.execCommand('copy') + document.body.removeChild(textarea) + } +} + +export default function CopyableCredential({ value, secret = false, label = 'Wert' }) { + const [visible, setVisible] = useState(!secret) const [copied, setCopied] = useState(false) - const text = (value || '').trim() + const timeoutRef = useRef(null) + const text = String(value || '').trim() - if (!text) { - return - } + useEffect(() => { + setVisible(!secret) + }, [secret, text]) - const handleClick = async (e) => { - e.preventDefault() - e.stopPropagation() - setVisible((v) => !v) - try { - await navigator.clipboard.writeText(text) - } catch { - const ta = document.createElement('textarea') - ta.value = text - ta.setAttribute('readonly', '') - ta.style.position = 'fixed' - ta.style.left = '-9999px' - document.body.appendChild(ta) - ta.select() - document.execCommand('copy') - document.body.removeChild(ta) - } + useEffect(() => () => clearTimeout(timeoutRef.current), []) + + if (!text) return + + const handleCopy = async (event) => { + event.preventDefault() + event.stopPropagation() + await copyText(text) setCopied(true) - setTimeout(() => setCopied(false), 2000) + clearTimeout(timeoutRef.current) + timeoutRef.current = setTimeout(() => setCopied(false), 1800) } - const masked = '•'.repeat(Math.min(Math.max(text.length, 8), 16)) - const display = visible ? text : masked + const displayValue = secret && !visible ? '••••••••••••' : text return ( - + - {display} + {displayValue} + {secret && ( + + )} ) diff --git a/src/components/CreateTicketModal.jsx b/src/components/CreateTicketModal.jsx index a203888..11d3a31 100644 --- a/src/components/CreateTicketModal.jsx +++ b/src/components/CreateTicketModal.jsx @@ -55,8 +55,6 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers topic: '', requestedBy: '', requestedFor: '', - assignedTo: '', // Zugewiesener Mitarbeiter (User ID) - status: 'Open', // Status wird automatisch gesetzt basierend auf assignedTo startDate: today, startTime: '', deadline: today, @@ -235,30 +233,13 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers -
- - +
+ +

+ {employees.length > 0 + ? `Automatisch an den Mitarbeiter mit der geringsten Anzahl offener Tickets (${employees.length} verfügbar).` + : 'Es ist noch kein Mitarbeiter verfügbar. Das Ticket bleibt zunächst offen.'} +

diff --git a/src/hooks/useAdminConfig.js b/src/hooks/useAdminConfig.js index 3d8dbb0..3096750 100644 --- a/src/hooks/useAdminConfig.js +++ b/src/hooks/useAdminConfig.js @@ -31,6 +31,23 @@ const DEFAULT_CONFIG = { ] } +function normalizePriorities(values) { + if (!Array.isArray(values)) return DEFAULT_CONFIG.priorities + const parsed = values.map((item, index) => { + if (typeof item === 'object' && item !== null) return item + if (typeof item === 'string') { + try { + const value = JSON.parse(item) + if (value && typeof value === 'object') return value + } catch { + return { value: index, label: item } + } + } + return null + }).filter(Boolean) + return parsed.length > 0 ? parsed : DEFAULT_CONFIG.priorities +} + export function useAdminConfig() { const [config, setConfig] = useState(DEFAULT_CONFIG) const [loading, setLoading] = useState(true) @@ -56,7 +73,7 @@ export function useAdminConfig() { systems: doc.systems || DEFAULT_CONFIG.systems, responseLevels: doc.responseLevels || DEFAULT_CONFIG.responseLevels, serviceTypes: doc.serviceTypes || DEFAULT_CONFIG.serviceTypes, - priorities: doc.priorities || DEFAULT_CONFIG.priorities + priorities: normalizePriorities(doc.priorities) }) } catch (e) { // Config existiert noch nicht (404) - das ist normal, verwende Defaults @@ -99,7 +116,9 @@ export function useAdminConfig() { systems: newConfig.systems, responseLevels: newConfig.responseLevels, serviceTypes: newConfig.serviceTypes, - priorities: newConfig.priorities + priorities: normalizePriorities(newConfig.priorities).map((priority) => + JSON.stringify(priority) + ) } try { @@ -149,4 +168,3 @@ export function useAdminConfig() { refresh: fetchConfig } } - diff --git a/src/hooks/useCustomers.js b/src/hooks/useCustomers.js index 5a09d64..4a738e9 100644 --- a/src/hooks/useCustomers.js +++ b/src/hooks/useCustomers.js @@ -1,7 +1,15 @@ import { useState, useEffect, useCallback } from 'react' -import { databases, DATABASE_ID, COLLECTIONS, ID, Query, isDemoMode } from '../lib/appwrite' +import { + COLLECTIONS, + DATABASE_ID, + Query, + databases, + isDemoMode, +} from '../lib/appwrite' import { createCustomerWithPortalAccess, + deleteCustomerWithPortalAccess, + listCustomersForAdmin, updateCustomerWithPortalAccess, } from '../lib/customerAdminApi' @@ -26,12 +34,17 @@ export function useCustomers() { } try { - const response = await databases.listDocuments( - DATABASE_ID, - COLLECTIONS.CUSTOMERS, - [Query.orderAsc('name')] - ) - setCustomers(response.documents) + try { + const response = await listCustomersForAdmin() + setCustomers(response.customers || []) + } catch { + const response = await databases.listDocuments( + DATABASE_ID, + COLLECTIONS.CUSTOMERS, + [Query.orderAsc('name')] + ) + setCustomers(response.documents || []) + } setError(null) } catch (err) { console.error('Error fetching customers:', err) @@ -127,11 +140,7 @@ export function useCustomers() { } try { - await databases.deleteDocument( - DATABASE_ID, - COLLECTIONS.CUSTOMERS, - id - ) + await deleteCustomerWithPortalAccess(id) setCustomers(prev => prev.filter(c => c.$id !== id)) return { success: true } } catch (err) { @@ -149,4 +158,3 @@ export function useCustomers() { deleteCustomer } } - diff --git a/src/hooks/useEmployees.js b/src/hooks/useEmployees.js index e19dd00..f215347 100644 --- a/src/hooks/useEmployees.js +++ b/src/hooks/useEmployees.js @@ -1,47 +1,65 @@ -import { useState, useEffect, useCallback } from 'react' -import { databases, account, DATABASE_ID, COLLECTIONS, ID, Query, isDemoMode } from '../lib/appwrite' +import { useCallback, useEffect, useState } from 'react' +import { + COLLECTIONS, + DATABASE_ID, + Query, + databases, + isDemoMode, +} from '../lib/appwrite' +import { + createEmployeeWithLogin, + deleteEmployeeWithLogin, + listEmployeesForAdmin, + updateEmployeeWithLogin, +} from '../lib/employeeAdminApi' const DEMO_MODE = isDemoMode - -// Demo-Mitarbeiter für Testing const DEMO_EMPLOYEES = [ - { $id: '1', userId: 'user1', displayName: 'Kenso Grimm', email: 'kenso@example.com', shortcode: 'KNSO' }, - { $id: '2', userId: 'user2', displayName: 'Christian Lehmann', email: 'christian@example.com', shortcode: 'CHLE' } + { + $id: '1', + userId: 'user1', + displayName: 'Kenso Grimm', + email: 'kenso@example.com', + shortcode: 'KNSO', + isAdmin: true, + }, + { + $id: '2', + userId: 'user2', + displayName: 'Christian Lehmann', + email: 'christian@example.com', + shortcode: 'CHLE', + isAdmin: false, + }, ] export function useEmployees() { const [employees, setEmployees] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - const [syncing, setSyncing] = useState(false) const fetchEmployees = useCallback(async () => { setLoading(true) - - if (DEMO_MODE) { - setEmployees(DEMO_EMPLOYEES) - setLoading(false) - return - } - try { - const response = await databases.listDocuments( - DATABASE_ID, - COLLECTIONS.EMPLOYEES, - [Query.orderAsc('displayName')] - ) - setEmployees(response.documents) + if (DEMO_MODE) { + setEmployees(DEMO_EMPLOYEES) + } else { + try { + const result = await listEmployeesForAdmin() + setEmployees(result.employees || []) + } catch { + const result = await databases.listDocuments( + DATABASE_ID, + COLLECTIONS.EMPLOYEES, + [Query.orderAsc('displayName')] + ) + setEmployees(result.documents || []) + } + } setError(null) } catch (err) { - console.error('Error fetching employees:', err) - // Wenn Collection nicht existiert, setze leeres Array (kein Fehler) - if (err.code === 404 || err.message?.includes('not found')) { - setEmployees([]) - setError(null) // Kein Fehler, Collection existiert einfach noch nicht - } else { - setError(err.message) - setEmployees([]) - } + setEmployees([]) + setError(err.message || 'Mitarbeiter konnten nicht geladen werden') } finally { setLoading(false) } @@ -52,141 +70,48 @@ export function useEmployees() { }, [fetchEmployees]) const createEmployee = async (data) => { - if (DEMO_MODE) { - const newEmployee = { ...data, $id: Date.now().toString() } - setEmployees(prev => [...prev, newEmployee]) - return { success: true, data: newEmployee } - } - try { - // Validierung - if (!data.userId || !data.displayName) { - return { success: false, error: 'userId und displayName sind erforderlich' } - } - - const response = await databases.createDocument( - DATABASE_ID, - COLLECTIONS.EMPLOYEES, - ID.unique(), - { - userId: data.userId, - displayName: data.displayName, - email: data.email || '', - shortcode: data.shortcode || '' + if (DEMO_MODE) { + const employee = { + ...data, + userId: crypto.randomUUID(), + $id: Date.now().toString(), } - ) - setEmployees(prev => [...prev, response]) - return { success: true, data: response } + setEmployees((current) => [...current, employee]) + return { success: true, data: employee } + } + const result = await createEmployeeWithLogin(data) + setEmployees((current) => [...current, result.employee]) + return { success: true, data: result.employee } } catch (err) { - console.error('Error creating employee:', err) return { success: false, error: err.message } } } const updateEmployee = async (id, data) => { - if (DEMO_MODE) { - setEmployees(prev => prev.map(e => e.$id === id ? { ...e, ...data } : e)) - return { success: true } - } - try { - const response = await databases.updateDocument( - DATABASE_ID, - COLLECTIONS.EMPLOYEES, - id, - data + if (DEMO_MODE) { + setEmployees((current) => + current.map((employee) => employee.$id === id ? { ...employee, ...data } : employee) + ) + return { success: true } + } + const result = await updateEmployeeWithLogin(id, data) + setEmployees((current) => + current.map((employee) => employee.$id === id ? result.employee : employee) ) - setEmployees(prev => prev.map(e => e.$id === id ? response : e)) - return { success: true, data: response } + return { success: true, data: result.employee } } catch (err) { - console.error('Error updating employee:', err) return { success: false, error: err.message } } } const deleteEmployee = async (id) => { - if (DEMO_MODE) { - setEmployees(prev => prev.filter(e => e.$id !== id)) - return { success: true } - } - try { - await databases.deleteDocument( - DATABASE_ID, - COLLECTIONS.EMPLOYEES, - id - ) - setEmployees(prev => prev.filter(e => e.$id !== id)) + if (!DEMO_MODE) await deleteEmployeeWithLogin(id) + setEmployees((current) => current.filter((employee) => employee.$id !== id)) return { success: true } } catch (err) { - console.error('Error deleting employee:', err) - return { success: false, error: err.message } - } - } - - /** - * Synchronisiert Appwrite Auth Users mit der employees Collection - * Erstellt fehlende Einträge für neue Users - */ - const syncWithAuthUsers = async () => { - if (DEMO_MODE) { - return { success: true, message: 'Demo-Modus: Keine Synchronisierung nötig' } - } - - setSyncing(true) - - try { - // 1. Lade alle Appwrite Auth Users - // Hinweis: In Appwrite 1.5.7 gibt es möglicherweise keine direkte List-Users API - // für normale User. Diese Funktion benötigt Server-Side Code oder Admin-API-Key. - // Für jetzt implementieren wir einen Workaround: Wir bieten ein manuelles Add-Interface. - - // Alternative: Wenn der User Appwrite Admin ist, können wir versuchen: - // const users = await account.listUsers() // Funktioniert nur mit Admin-Rechten - - // Da das nicht direkt möglich ist, geben wir eine Info zurück - return { - success: false, - error: 'Automatische Synchronisierung erfordert Admin-API-Zugriff. Bitte füge Mitarbeiter manuell hinzu oder verwende die Appwrite Server API.' - } - } catch (err) { - console.error('Error syncing auth users:', err) - return { success: false, error: err.message } - } finally { - setSyncing(false) - } - } - - /** - * Erstellt einen Employee-Eintrag für den aktuell eingeloggten User - * Nützlich für Self-Service - */ - const createSelfEmployee = async (shortcode = '') => { - if (DEMO_MODE) { - return { success: true, message: 'Demo-Modus' } - } - - try { - // Hole aktuellen User - const currentUser = await account.get() - - // Prüfe, ob Employee bereits existiert - const existing = employees.find(e => e.userId === currentUser.$id) - if (existing) { - return { success: false, error: 'Mitarbeiter-Eintrag existiert bereits' } - } - - // Erstelle Employee-Eintrag - const result = await createEmployee({ - userId: currentUser.$id, - displayName: currentUser.name || currentUser.email, - email: currentUser.email, - shortcode: shortcode - }) - - return result - } catch (err) { - console.error('Error creating self employee:', err) return { success: false, error: err.message } } } @@ -195,13 +120,9 @@ export function useEmployees() { employees, loading, error, - syncing, refresh: fetchEmployees, createEmployee, updateEmployee, deleteEmployee, - syncWithAuthUsers, - createSelfEmployee } } - diff --git a/src/hooks/useWorkorders.js b/src/hooks/useWorkorders.js index c319198..b65f4a8 100644 --- a/src/hooks/useWorkorders.js +++ b/src/hooks/useWorkorders.js @@ -195,6 +195,54 @@ export function useWorkorders(filters = {}) { fetchWorkorders() }, [fetchWorkorders]) + const resolveAutomaticTicketFields = async (requestedAssignee = '') => { + const [allTicketsResponse, employeesResponse] = await Promise.all([ + databases.listDocuments( + DATABASE_ID, + COLLECTIONS.WORKORDERS, + [Query.limit(5000)] + ), + databases.listDocuments( + DATABASE_ID, + COLLECTIONS.EMPLOYEES, + [Query.orderAsc('displayName'), Query.limit(500)] + ), + ]) + + const allTickets = allTicketsResponse.documents || [] + const employees = employeesResponse.documents || [] + const maxWoid = allTickets.reduce((highest, ticket) => { + const numericId = Number.parseInt(ticket.woid, 10) + return Number.isFinite(numericId) ? Math.max(highest, numericId) : highest + }, 9999) + + let assignee = requestedAssignee + ? employees.find((employee) => employee.userId === requestedAssignee) + : null + + if (!assignee && employees.length > 0) { + const finishedStatuses = new Set(['Closed', 'Completed', 'Cancelled', 'Done']) + const activeCounts = new Map(employees.map((employee) => [employee.userId, 0])) + for (const ticket of allTickets) { + if (!ticket.assignedTo || finishedStatuses.has(ticket.status)) continue + if (activeCounts.has(ticket.assignedTo)) { + activeCounts.set(ticket.assignedTo, activeCounts.get(ticket.assignedTo) + 1) + } + } + assignee = [...employees].sort((left, right) => { + const loadDifference = activeCounts.get(left.userId) - activeCounts.get(right.userId) + if (loadDifference !== 0) return loadDifference + return (left.displayName || '').localeCompare(right.displayName || '', 'de') + })[0] + } + + return { + woid: String(maxWoid + 1), + assignedTo: assignee?.userId || '', + assignedName: assignee?.displayName || '', + } + } + const createWorkorder = async (data) => { if (DEMO_MODE) { // Finde höchste WOID und +1 @@ -214,38 +262,16 @@ export function useWorkorders(filters = {}) { return { success: false, error: 'Das Feld "Topic" ist erforderlich.' } } - // Status-Automatik: Wenn Mitarbeiter zugewiesen → Status = "Assigned", sonst "Open" - const autoStatus = (data.assignedTo && data.assignedTo !== '') ? 'Assigned' : 'Open' - - // Generiere sequentielle 5-stellige WOID (wie im Original-System) - const generateWOID = () => { - // Finde die höchste bestehende WOID - if (workorders.length === 0) { - return '10000'; // Starte bei 10000 wenn keine Tickets existieren - } - - const maxWoid = Math.max( - ...workorders - .map(wo => parseInt(wo.woid)) - .filter(woid => !isNaN(woid) && woid > 0) - ); - - // Wenn keine gültige WOID gefunden wurde, starte bei 10000 - if (maxWoid === -Infinity || isNaN(maxWoid)) { - return '10000'; - } - - // Gib die nächste Nummer zurück (sequentiell) - return (maxWoid + 1).toString(); - } + const automatic = await resolveAutomaticTicketFields(data.assignedTo) + const autoStatus = automatic.assignedTo ? 'Assigned' : 'Open' // Bereite Daten für Appwrite vor const workorderData = { // Required fields topic: data.topic.trim(), - status: data.status || autoStatus, // Verwende übergebenen Status oder automatischen Status + status: autoStatus, priority: typeof data.priority === 'number' ? data.priority : parseInt(data.priority) || 1, - woid: generateWOID(), // 5-stellige Zahl + woid: automatic.woid, // Optional fields - nur senden wenn vorhanden type: data.type || '', @@ -253,7 +279,8 @@ export function useWorkorders(filters = {}) { responseLevel: data.responseLevel || '', serviceType: data.serviceType || 'Remote', customerId: data.customerId || '', - assignedTo: data.assignedTo || '', // Zugewiesener Mitarbeiter + assignedTo: automatic.assignedTo, + assignedName: automatic.assignedName, requestedBy: data.requestedBy || '', requestedFor: data.requestedFor || '', startDate: data.startDate || '', diff --git a/src/lib/customerAdminApi.js b/src/lib/customerAdminApi.js index 1787525..21f6829 100644 --- a/src/lib/customerAdminApi.js +++ b/src/lib/customerAdminApi.js @@ -24,9 +24,17 @@ export async function createCustomerWithPortalAccess(payload) { return adminFetch('/api/admin/customers', { method: 'POST', body: payload }) } +export async function listCustomersForAdmin() { + return adminFetch('/api/admin/customers', { method: 'GET' }) +} + export async function updateCustomerWithPortalAccess(customerId, payload) { return adminFetch(`/api/admin/customers/${customerId}`, { method: 'PATCH', body: payload, }) } + +export async function deleteCustomerWithPortalAccess(customerId) { + return adminFetch(`/api/admin/customers/${customerId}`, { method: 'DELETE' }) +} diff --git a/src/lib/employeeAdminApi.js b/src/lib/employeeAdminApi.js new file mode 100644 index 0000000..f4c73fe --- /dev/null +++ b/src/lib/employeeAdminApi.js @@ -0,0 +1,40 @@ +import { account } from './appwrite' + +const PROJECT_ADMIN_URL = + import.meta.env.VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com' + +async function adminFetch(path, { method = 'GET', body } = {}) { + const jwt = await account.createJWT() + const response = await fetch(`${PROJECT_ADMIN_URL}${path}`, { + method, + headers: { + Authorization: `Bearer ${jwt.jwt}`, + 'Content-Type': 'application/json', + }, + body: body ? JSON.stringify(body) : undefined, + }) + const data = await response.json().catch(() => ({})) + if (!response.ok) { + throw new Error(data.error || `API-Fehler ${response.status}`) + } + return data +} + +export function listEmployeesForAdmin() { + return adminFetch('/api/admin/employees') +} + +export function createEmployeeWithLogin(payload) { + return adminFetch('/api/admin/employees', { method: 'POST', body: payload }) +} + +export function updateEmployeeWithLogin(employeeId, payload) { + return adminFetch(`/api/admin/employees/${employeeId}`, { + method: 'PATCH', + body: payload, + }) +} + +export function deleteEmployeeWithLogin(employeeId) { + return adminFetch(`/api/admin/employees/${employeeId}`, { method: 'DELETE' }) +} diff --git a/src/pages/AdminPage.css b/src/pages/AdminPage.css new file mode 100644 index 0000000..36cd377 --- /dev/null +++ b/src/pages/AdminPage.css @@ -0,0 +1,456 @@ +.admin-page { + --admin-line: rgba(255, 255, 255, 0.1); + --admin-line-strong: rgba(255, 255, 255, 0.17); + max-width: 1480px; + padding-bottom: 48px; +} + +.admin-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + margin-bottom: 24px; +} + +.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-config-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.admin-config-card, +.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-config-card.priorities { grid-column: 1 / -1; } + +.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-config-list { padding: 10px; } + +.admin-config-row, +.admin-priority-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + padding: 6px; + border-bottom: 1px solid rgba(255, 255, 255, 0.055); +} + +.admin-priority-row { grid-template-columns: 90px minmax(0, 1fr) auto; } +.admin-config-row:last-child, +.admin-priority-row:last-child { border-bottom: 0; } + +.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: rgba(16, 185, 129, 0.5); + color: var(--text); + background: rgba(16, 185, 129, 0.07); +} + +.admin-icon-button.danger:hover { + border-color: rgba(239, 68, 68, 0.55); + color: #fca5a5; + background: rgba(239, 68, 68, 0.08); +} + +.admin-savebar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-top: 14px; + padding: 13px 16px; + border: 1px solid var(--admin-line); + background: var(--surface-1); +} + +.admin-savebar span { color: var(--text-muted); font-size: 13px; } + +.admin-primary-button { + display: inline-flex; + min-height: 40px; + align-items: center; + justify-content: center; + gap: 8px; + padding: 9px 15px; + border: 1px solid #34d399; + border-radius: 6px; + background: var(--accent); + color: #05251c; + font: inherit; + font-size: 13px; + font-weight: 750; + cursor: pointer; +} + +.admin-primary-button:hover { background: #34d399; } +.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 rgba(255, 255, 255, 0.065); + 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: rgba(255, 255, 255, 0.018); } +.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; +} + +.admin-status, +.admin-role { + display: inline-flex !important; + width: fit-content; + align-items: center; + padding: 3px 7px; + border: 1px solid var(--admin-line-strong); + border-radius: 4px; + color: var(--text-muted) !important; + font-size: 11px !important; + font-weight: 700; +} + +.admin-status.active, +.admin-role.admin { + border-color: rgba(16, 185, 129, 0.45); + color: #6ee7b7 !important; +} + +.admin-shortcode { + color: var(--text) !important; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px !important; + letter-spacing: 0.04em; +} + +.credential { + display: flex; + max-width: 300px; + align-items: center; + gap: 5px; + margin: 2px 0; +} + +.credential-value { + min-width: 0; + overflow: hidden; + color: var(--text); + font-size: 12px; + text-overflow: ellipsis; +} + +.credential-value.is-masked { + color: var(--text-muted); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.08em; +} + +.credential-action { + display: inline-flex; + width: 25px; + height: 25px; + flex: 0 0 25px; + align-items: center; + justify-content: center; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--text-faint); + cursor: pointer; +} + +.credential-action:hover { background: var(--surface-2); color: var(--text); } +.credential-check { color: var(--ok); } +.credential-empty { color: var(--text-faint); } + +.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 rgba(16, 185, 129, 0.42); + background: rgba(16, 185, 129, 0.065); +} + +.admin-credential-result strong, +.admin-credential-result span { display: block; } +.admin-credential-result 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); } + +.spin { animation: admin-spin 0.8s linear infinite; } +@keyframes admin-spin { to { transform: rotate(360deg); } } + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@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-config-grid { grid-template-columns: 1fr; } + .admin-config-card.priorities { grid-column: auto; } + .admin-savebar { align-items: stretch; flex-direction: column; } + .admin-credential-result { grid-template-columns: 1fr; } +} diff --git a/src/pages/AdminPage.jsx b/src/pages/AdminPage.jsx index ab82ba8..15c6d14 100644 --- a/src/pages/AdminPage.jsx +++ b/src/pages/AdminPage.jsx @@ -1,818 +1,553 @@ -import { useState, useEffect } from 'react' -import { useAdminConfig } from '../hooks/useAdminConfig' +import { useEffect, useState } from 'react' +import { + FaFloppyDisk, + FaPlus, + FaSpinner, + FaTrash, + FaXmark, +} from 'react-icons/fa6' +import { FaEdit } from 'react-icons/fa' +import CopyableCredential, { + getCustomerPortalPassword, +} from '../components/CopyableCredential' import { useAuth } from '../context/AuthContext' +import { useAdminConfig } from '../hooks/useAdminConfig' import { useCustomers } from '../hooks/useCustomers' import { useEmployees } from '../hooks/useEmployees' -import { FaPlus, FaTrash, FaFloppyDisk, FaSpinner } from 'react-icons/fa6' -import { FaEdit } from 'react-icons/fa' -import CopyableCredential, { getCustomerPortalPassword } from '../components/CopyableCredential' +import './AdminPage.css' + +const EMPTY_CONFIG = { + ticketTypes: [], + systems: [], + responseLevels: [], + serviceTypes: [], + priorities: [], +} + +const EMPTY_CUSTOMER = { + code: '', + name: '', + location: '', + email: '', + phone: '', + password: '', +} + +const EMPTY_EMPLOYEE = { + displayName: '', + email: '', + password: '', + shortcode: '', + isAdmin: false, +} + +const CONFIG_GROUPS = [ + { field: 'ticketTypes', title: 'Ticketarten', placeholder: 'Neue Ticketart' }, + { field: 'systems', title: 'Betroffene Systeme', placeholder: 'Neues System' }, + { field: 'responseLevels', title: 'Reaktionsstufen', placeholder: 'Neue Stufe' }, + { field: 'serviceTypes', title: 'Leistungsarten', placeholder: 'Neue Leistungsart' }, +] + +function Notice({ notice }) { + if (!notice?.text) return null + return
{notice.text}
+} + +function ConfigList({ title, field, values, placeholder, onAdd, onRemove, onChange }) { + return ( +
+
+
+

{title}

+ {values.length} Einträge +
+ +
+
+ {values.length === 0 &&

Noch keine Einträge.

} + {values.map((value, index) => ( +
+ onChange(field, index, event.target.value)} + /> + +
+ ))} +
+
+ ) +} export default function AdminPage() { const { user, isAdmin } = useAuth() const { config, loading, error, updateConfig } = useAdminConfig() - const { customers, loading: customersLoading, createCustomer, updateCustomer, deleteCustomer, refresh: refreshCustomers } = useCustomers() - const { employees, loading: employeesLoading, createEmployee, updateEmployee, deleteEmployee, refresh: refreshEmployees } = useEmployees() - const [localConfig, setLocalConfig] = useState(() => { - // Initialisiere mit Default-Werten falls config noch nicht geladen - if (config && Object.keys(config).length > 0) { - return config - } - return { - ticketTypes: [], - systems: [], - responseLevels: [], - serviceTypes: [], - priorities: [] - } - }) - const [saving, setSaving] = useState(false) - const [saveMessage, setSaveMessage] = useState('') - const [editingCustomer, setEditingCustomer] = useState(null) - const [customerForm, setCustomerForm] = useState({ - code: '', - name: '', - location: '', - email: '', - phone: '', - password: '', - }) - const emptyCustomerForm = { - code: '', - name: '', - location: '', - email: '', - phone: '', - password: '', - } - const [editingEmployee, setEditingEmployee] = useState(null) - const [employeeForm, setEmployeeForm] = useState({ userId: '', displayName: '', email: '', shortcode: '' }) - const [adminSection, setAdminSection] = useState('config') + const { + customers, + loading: customersLoading, + createCustomer, + updateCustomer, + deleteCustomer, + } = useCustomers() + const { + employees, + loading: employeesLoading, + error: employeesError, + createEmployee, + updateEmployee, + deleteEmployee, + } = useEmployees() + + const [activeSection, setActiveSection] = useState('config') + const [localConfig, setLocalConfig] = useState(EMPTY_CONFIG) + const [savingConfig, setSavingConfig] = useState(false) + const [customerForm, setCustomerForm] = useState(EMPTY_CUSTOMER) + const [editingCustomer, setEditingCustomer] = useState(null) + const [customerBusy, setCustomerBusy] = useState(false) + const [employeeForm, setEmployeeForm] = useState(EMPTY_EMPLOYEE) + const [editingEmployee, setEditingEmployee] = useState(null) + const [employeeBusy, setEmployeeBusy] = useState(false) + const [createdCredential, setCreatedCredential] = useState(null) + const [notice, setNotice] = useState(null) - // Update localConfig when config loads useEffect(() => { if (config && Object.keys(config).length > 0) { - setLocalConfig(config) + setLocalConfig({ ...EMPTY_CONFIG, ...config }) } }, [config]) + const showNotice = (type, text) => { + setNotice({ type, text }) + window.setTimeout(() => setNotice(null), 4500) + } + if (!isAdmin) { return ( -
-
-
-

Zugriff verweigert

-
-
-

Du hast keine Berechtigung, auf diese Seite zuzugreifen.

-
-
-
+
+
+ 403 +

Zugriff verweigert

+

Für diesen Bereich ist ein Admin-Account erforderlich.

+
+
) } - const handleAddItem = (field) => { - setLocalConfig(prev => ({ - ...prev, - [field]: [...prev[field], field === 'priorities' ? { value: prev[field].length, label: 'New' } : 'New Item'] + const addConfigItem = (field) => { + setLocalConfig((current) => ({ + ...current, + [field]: [ + ...(current[field] || []), + field === 'priorities' + ? { value: current.priorities?.length || 0, label: 'Neue Priorität' } + : '', + ], })) } - const handleRemoveItem = (field, index) => { - setLocalConfig(prev => ({ - ...prev, - [field]: prev[field].filter((_, i) => i !== index) + const removeConfigItem = (field, index) => { + setLocalConfig((current) => ({ + ...current, + [field]: (current[field] || []).filter((_, itemIndex) => itemIndex !== index), })) } - const handleUpdateItem = (field, index, value) => { - setLocalConfig(prev => { - const newArray = [...prev[field]] - if (field === 'priorities') { - newArray[index] = { ...newArray[index], ...value } - } else { - newArray[index] = value - } - return { ...prev, [field]: newArray } + const updateConfigItem = (field, index, value) => { + setLocalConfig((current) => { + const next = [...(current[field] || [])] + next[index] = value + return { ...current, [field]: next } }) } - const handleSave = async () => { - setSaving(true) - setSaveMessage('') - + const saveConfiguration = async () => { + setSavingConfig(true) const result = await updateConfig(localConfig) - - if (result.success) { - setSaveMessage('Konfiguration erfolgreich gespeichert!') - setTimeout(() => setSaveMessage(''), 3000) - } else { - setSaveMessage('Fehler beim Speichern: ' + (result.error || 'Unbekannter Fehler')) - } - - setSaving(false) + setSavingConfig(false) + showNotice( + result.success ? 'success' : 'error', + result.success + ? 'Konfiguration gespeichert.' + : `Speichern fehlgeschlagen: ${result.error || 'Unbekannter Fehler'}` + ) } - if (loading && !config) { - return ( -
- -

Lade Konfiguration...

-
+ const resetCustomerForm = () => { + setCustomerForm(EMPTY_CUSTOMER) + setEditingCustomer(null) + } + + const submitCustomer = async (event) => { + event.preventDefault() + if (!customerForm.name.trim() || !customerForm.email.trim()) { + showNotice('error', 'Name und E-Mail sind erforderlich.') + return + } + if (!editingCustomer && customerForm.password.length < 8) { + showNotice('error', 'Das Portal-Passwort muss mindestens 8 Zeichen haben.') + return + } + if (editingCustomer && customerForm.password && customerForm.password.length < 8) { + showNotice('error', 'Ein neues Passwort muss mindestens 8 Zeichen haben.') + return + } + + setCustomerBusy(true) + const result = editingCustomer + ? await updateCustomer(editingCustomer, customerForm) + : await createCustomer(customerForm) + setCustomerBusy(false) + if (!result.success) { + showNotice('error', result.error || 'Kunde konnte nicht gespeichert werden.') + return + } + showNotice('success', editingCustomer ? 'Kunde aktualisiert.' : 'Kunde angelegt.') + resetCustomerForm() + } + + const editCustomer = (customer) => { + setEditingCustomer(customer.$id) + setCustomerForm({ + code: customer.code || '', + name: customer.name || '', + location: customer.location || '', + email: customer.email || '', + phone: customer.phone || '', + password: '', + }) + } + + const removeCustomer = async (customer) => { + if (!window.confirm(`${customer.name || customer.email} wirklich löschen?`)) return + const result = await deleteCustomer(customer.$id) + showNotice( + result.success ? 'success' : 'error', + result.success ? 'Kunde und Portalzugang gelöscht.' : result.error + ) + } + + const resetEmployeeForm = () => { + 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 editEmployee = (employee) => { + setEditingEmployee(employee.$id) + setEmployeeForm({ + displayName: employee.displayName || '', + email: employee.email || '', + password: '', + shortcode: employee.shortcode || '', + isAdmin: Boolean(employee.isAdmin), + }) + } + + const removeEmployee = async (employee) => { + if (!window.confirm(`${employee.displayName || employee.email} und dessen Login wirklich löschen?`)) return + const result = await deleteEmployee(employee.$id) + showNotice( + result.success ? 'success' : 'error', + result.success ? 'Mitarbeiter und Login gelöscht.' : result.error ) } return ( -
-
-

Admin Panel - Dropdown Konfiguration

+
+
+
+ Systemverwaltung +

Admin

+

Konfiguration, Kundenkonten und Mitarbeiterzugänge zentral verwalten.

+
+
+ Angemeldet als + {user?.name || user?.email} +
- {error && ( -
- Fehler: {error} -
- )} + + {error && } - {saveMessage && ( -
- {saveMessage} -
- )} - -
+
- - {adminSection === 'config' && ( - <> -
- {/* Ticket Types */} -
-
-
-

Work Order Types

-
-
- {localConfig.ticketTypes?.map((type, index) => ( -
- handleUpdateItem('ticketTypes', index, e.target.value)} - style={{ flex: 1 }} - /> - -
- ))} - -
-
-
- - {/* Systems */} -
-
-
-

Affected Systems

-
-
- {localConfig.systems?.map((system, index) => ( -
- handleUpdateItem('systems', index, e.target.value)} - style={{ flex: 1 }} - /> - -
- ))} - -
-
-
-
- -
- {/* Response Levels */} -
-
-
-

Response Levels

-
-
- {localConfig.responseLevels?.map((level, index) => ( -
- handleUpdateItem('responseLevels', index, e.target.value)} - style={{ flex: 1 }} - /> - -
- ))} - -
-
-
- - {/* Service Types */} -
-
-
-

Service Types

-
-
- {localConfig.serviceTypes?.map((type, index) => ( -
- handleUpdateItem('serviceTypes', index, e.target.value)} - style={{ flex: 1 }} - /> - -
- ))} - -
-
-
-
- - {/* Priorities */} -
-
-

Priorities

-
-
- {localConfig.priorities?.map((priority, index) => ( -
- handleUpdateItem('priorities', index, { ...priority, value: parseInt(e.target.value) })} - style={{ width: '100px' }} - placeholder="Value" - /> - handleUpdateItem('priorities', index, { ...priority, label: e.target.value })} - style={{ flex: 1 }} - placeholder="Label" - /> - -
- ))} + ['config', 'Konfiguration'], + ['customers', `Kunden (${customers.length})`], + ['employees', `Mitarbeiter (${employees.length})`], + ].map(([id, label]) => ( -
-
+ ))} + -
- -
- - )} - - {adminSection === 'customers' && ( -
-
-

Kunden

-
-
- {customersLoading ? ( -
- Lade Kunden... -
- ) : ( - <> - - - - - - - - - - - - - - - {customers.map((customer) => ( - - {editingCustomer === customer.$id ? ( - <> - - - - - - - - - ) : ( - <> - - - - - - - - - - )} - - ))} - -
CodeNameLocationEmailPasswortPhonePortalAktionen
- setCustomerForm(prev => ({ ...prev, code: e.target.value }))} - style={{ width: '100px' }} - /> - - setCustomerForm(prev => ({ ...prev, name: e.target.value }))} - /> - - setCustomerForm(prev => ({ ...prev, location: e.target.value }))} - /> - - setCustomerForm(prev => ({ ...prev, email: e.target.value }))} - /> - - setCustomerForm(prev => ({ ...prev, phone: e.target.value }))} - /> - - setCustomerForm(prev => ({ ...prev, password: e.target.value }))} - placeholder="Neu (optional)" - autoComplete="new-password" - /> - - - - {customer.code || '-'}{customer.name || '-'}{customer.location || '-'} - - - - {customer.phone || '-'} - {customer.portalAccessEnabled ? ( - aktiv - ) : ( - inaktiv - )} - - - -
- -
-

Neuen Kunden hinzufügen

-
-
-
- - setCustomerForm(prev => ({ ...prev, code: e.target.value }))} - placeholder="C001" - /> -
+
+ {CONFIG_GROUPS.map((group) => ( + + ))} +
+
+

Prioritäten

{localConfig.priorities?.length || 0} Einträge
+
-
-
- - setCustomerForm(prev => ({ ...prev, name: e.target.value }))} - placeholder="Kundenname" - required - /> -
+
+ {(localConfig.priorities || []).map((priority, index) => ( +
+ updateConfigItem('priorities', index, { + ...priority, + value: Number(event.target.value), + })} + /> + updateConfigItem('priorities', index, { + ...priority, + label: event.target.value, + })} + /> + +
+ ))}
-
-
- - setCustomerForm(prev => ({ ...prev, location: e.target.value }))} - placeholder="Stadt" - /> -
-
-
-
- - setCustomerForm(prev => ({ ...prev, email: e.target.value }))} - placeholder="email@example.com" - /> -
-
-
-
-
-
- - setCustomerForm(prev => ({ ...prev, phone: e.target.value }))} - placeholder="030-123456" - /> -
-
-
-
- - setCustomerForm(prev => ({ ...prev, password: e.target.value }))} - placeholder="mind. 8 Zeichen" - autoComplete="new-password" - required - /> -
-
-
-
-

- Der Kunde meldet sich damit unter{' '} - - project.webklar.com - {' '} - an (E-Mail + Passwort). -

- -
-
-
+
+
+
+ Änderungen gelten für neue und bestehende Ticketformulare. +
)} -
-
+ )} - {adminSection === 'employees' && ( -
-
-

Mitarbeiter & Kürzel

-
-
- {employeesLoading ? ( -
- Lade Mitarbeiter... + {activeSection === 'customers' && ( +
+
+
+
+

{editingCustomer ? 'Kunde bearbeiten' : 'Kunde anlegen'}

+ Portalzugang für project.webklar.com +
+ {editingCustomer && ( + + )}
- ) : ( - <> - {employees.length === 0 ? ( -
-

- Noch keine Mitarbeiter in der Liste -

-

- Sobald sich Benutzer einloggen, werden sie automatisch hier angezeigt. -

-
- ) : ( - - - - - - - - - - + + + + + + + + + +
+
+

Kundenkonten

E-Mail sichtbar, Passwort geschützt
+
+ {customersLoading ? ( +
Kunden werden geladen …
+ ) : customers.length === 0 ? ( +

Noch keine Kunden

Lege links den ersten Portalzugang an.

+ ) : ( +
+
NameEmailKürzelUser IDAktionen
+ - {employees.map((employee) => ( - - {editingEmployee === employee.$id ? ( - <> - - - - - - - ) : ( - <> - - - - - - - )} + {customers.map((customer) => ( + + + + + + ))}
Code / KundeZugangStandort / TelefonStatusAktionen
{employee.displayName || '-'}{employee.email || '-'} - setEmployeeForm(prev => ({ ...prev, shortcode: e.target.value.toUpperCase() }))} - placeholder="KNSO" - style={{ width: '100px' }} - maxLength={10} - autoFocus - /> - - {employee.userId.substring(0, 12)}... - - - - {employee.displayName || '-'}{employee.email || '-'} - - {employee.shortcode || '(kein Kürzel)'} - - - {employee.userId.substring(0, 12)}... - - - -
{customer.code || 'Ohne Code'}{customer.name || '–'} + + + {customer.location || '–'}{customer.phone || '–'}{customer.portalAccessEnabled ? 'Aktiv' : 'Inaktiv'} + + +
- )} - - )} -
-
+
+ )} + + )} -
+ {activeSection === 'employees' && ( +
+ {createdCredential && ( +
+
Login wurde angelegtDiese Zugangsdaten jetzt sicher weitergeben.
+ + + +
+ )} +
+
+
+
+

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

+ Die User-ID wird automatisch vergeben. +
+ {editingEmployee && } +
+ + + + + + +
+ +
+

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 && } +
+
+ )} +
+
+
+ )} +
) } - diff --git a/src/styles/global.css b/src/styles/global.css index 8077f45..4dec182 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1117,3 +1117,15 @@ textarea.form-control { .wrap { flex-wrap: wrap; } .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; } .text-ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.automatic-assignment-note { + padding: 12px 14px; + border-left: 2px solid var(--accent); + background: var(--surface-2); +} +.automatic-assignment-note p { + margin: 5px 0 0; + color: var(--text-muted); + font-size: 13px; + line-height: 1.45; +}