feat: rebuild admin account management and ticket assignment
This commit is contained in:
@@ -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 <span style={{ color: '#9ca3af' }}>–</span>
|
||||
}
|
||||
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 <span className="credential-empty">–</span>
|
||||
|
||||
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 (
|
||||
<span
|
||||
className="copyable-credential"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: '6px', maxWidth: '100%' }}
|
||||
>
|
||||
<span className="credential">
|
||||
<span
|
||||
style={{
|
||||
fontFamily: visible ? 'inherit' : 'monospace',
|
||||
letterSpacing: visible ? 'normal' : '1px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: '180px',
|
||||
}}
|
||||
title={visible ? text : 'Klick auf das Auge zum Anzeigen und Kopieren'}
|
||||
className={`credential-value ${secret && !visible ? 'is-masked' : ''}`}
|
||||
title={secret && !visible ? `${label} anzeigen` : text}
|
||||
>
|
||||
{display}
|
||||
{displayValue}
|
||||
</span>
|
||||
{secret && (
|
||||
<button
|
||||
type="button"
|
||||
className="credential-action"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setVisible((current) => !current)
|
||||
}}
|
||||
title={visible ? `${label} ausblenden` : `${label} anzeigen`}
|
||||
aria-label={visible ? `${label} ausblenden` : `${label} anzeigen`}
|
||||
>
|
||||
{visible ? <FaEyeSlash /> : <FaEye />}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={handleClick}
|
||||
title={copied ? 'Kopiert!' : 'Anzeigen und in Zwischenablage kopieren'}
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
minWidth: '28px',
|
||||
lineHeight: 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
aria-label={copied ? 'Kopiert' : 'Anzeigen und kopieren'}
|
||||
className="credential-action"
|
||||
onClick={handleCopy}
|
||||
title={copied ? 'Kopiert' : `${label} kopieren`}
|
||||
aria-label={copied ? `${label} kopiert` : `${label} kopieren`}
|
||||
>
|
||||
{copied ? (
|
||||
<FaCheck style={{ color: '#059669' }} />
|
||||
) : visible ? (
|
||||
<FaEyeSlash />
|
||||
) : (
|
||||
<FaEye />
|
||||
)}
|
||||
{copied ? <FaCheck className="credential-check" /> : <FaCopy />}
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Assigned To</label>
|
||||
<select
|
||||
className="form-control"
|
||||
value={formData.assignedTo}
|
||||
onChange={(e) => {
|
||||
const userId = e.target.value
|
||||
handleChange('assignedTo', userId)
|
||||
// Status-Automatik: Wenn Mitarbeiter zugewiesen → Status = "Assigned"
|
||||
// Wenn kein Mitarbeiter → Status = "Open"
|
||||
if (userId) {
|
||||
handleChange('status', 'Assigned')
|
||||
} else {
|
||||
handleChange('status', 'Open')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
{employees.map(emp => (
|
||||
<option key={emp.$id} value={emp.userId}>
|
||||
{emp.displayName}{emp.shortcode ? ` (${emp.shortcode})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="form-group automatic-assignment-note">
|
||||
<label className="form-label">Zuweisung</label>
|
||||
<p>
|
||||
{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.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 || '',
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
40
src/lib/employeeAdminApi.js
Normal file
40
src/lib/employeeAdminApi.js
Normal file
@@ -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' })
|
||||
}
|
||||
456
src/pages/AdminPage.css
Normal file
456
src/pages/AdminPage.css
Normal file
@@ -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; }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user