feat: rebuild admin account management and ticket assignment
This commit is contained in:
@@ -1,80 +1,78 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { FaEye, FaEyeSlash, FaCheck } from 'react-icons/fa6'
|
import { FaCheck, FaCopy, FaEye, FaEyeSlash } from 'react-icons/fa6'
|
||||||
|
|
||||||
/**
|
async function copyText(text) {
|
||||||
* Anzeige mit Auge: Klick kopiert den Wert und schaltet Sichtbarkeit um.
|
try {
|
||||||
*/
|
await navigator.clipboard.writeText(text)
|
||||||
export default function CopyableCredential({ value }) {
|
} catch {
|
||||||
const [visible, setVisible] = useState(false)
|
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 [copied, setCopied] = useState(false)
|
||||||
const text = (value || '').trim()
|
const timeoutRef = useRef(null)
|
||||||
|
const text = String(value || '').trim()
|
||||||
|
|
||||||
if (!text) {
|
useEffect(() => {
|
||||||
return <span style={{ color: '#9ca3af' }}>–</span>
|
setVisible(!secret)
|
||||||
}
|
}, [secret, text])
|
||||||
|
|
||||||
const handleClick = async (e) => {
|
useEffect(() => () => clearTimeout(timeoutRef.current), [])
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
if (!text) return <span className="credential-empty">–</span>
|
||||||
setVisible((v) => !v)
|
|
||||||
try {
|
const handleCopy = async (event) => {
|
||||||
await navigator.clipboard.writeText(text)
|
event.preventDefault()
|
||||||
} catch {
|
event.stopPropagation()
|
||||||
const ta = document.createElement('textarea')
|
await copyText(text)
|
||||||
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)
|
|
||||||
}
|
|
||||||
setCopied(true)
|
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 displayValue = secret && !visible ? '••••••••••••' : text
|
||||||
const display = visible ? text : masked
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span className="credential">
|
||||||
className="copyable-credential"
|
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', gap: '6px', maxWidth: '100%' }}
|
|
||||||
>
|
|
||||||
<span
|
<span
|
||||||
style={{
|
className={`credential-value ${secret && !visible ? 'is-masked' : ''}`}
|
||||||
fontFamily: visible ? 'inherit' : 'monospace',
|
title={secret && !visible ? `${label} anzeigen` : text}
|
||||||
letterSpacing: visible ? 'normal' : '1px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
maxWidth: '180px',
|
|
||||||
}}
|
|
||||||
title={visible ? text : 'Klick auf das Auge zum Anzeigen und Kopieren'}
|
|
||||||
>
|
>
|
||||||
{display}
|
{displayValue}
|
||||||
</span>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn"
|
className="credential-action"
|
||||||
onClick={handleClick}
|
onClick={handleCopy}
|
||||||
title={copied ? 'Kopiert!' : 'Anzeigen und in Zwischenablage kopieren'}
|
title={copied ? 'Kopiert' : `${label} kopieren`}
|
||||||
style={{
|
aria-label={copied ? `${label} kopiert` : `${label} kopieren`}
|
||||||
padding: '2px 6px',
|
|
||||||
minWidth: '28px',
|
|
||||||
lineHeight: 1,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
aria-label={copied ? 'Kopiert' : 'Anzeigen und kopieren'}
|
|
||||||
>
|
>
|
||||||
{copied ? (
|
{copied ? <FaCheck className="credential-check" /> : <FaCopy />}
|
||||||
<FaCheck style={{ color: '#059669' }} />
|
|
||||||
) : visible ? (
|
|
||||||
<FaEyeSlash />
|
|
||||||
) : (
|
|
||||||
<FaEye />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -55,8 +55,6 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
|
|||||||
topic: '',
|
topic: '',
|
||||||
requestedBy: '',
|
requestedBy: '',
|
||||||
requestedFor: '',
|
requestedFor: '',
|
||||||
assignedTo: '', // Zugewiesener Mitarbeiter (User ID)
|
|
||||||
status: 'Open', // Status wird automatisch gesetzt basierend auf assignedTo
|
|
||||||
startDate: today,
|
startDate: today,
|
||||||
startTime: '',
|
startTime: '',
|
||||||
deadline: today,
|
deadline: today,
|
||||||
@@ -235,30 +233,13 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group automatic-assignment-note">
|
||||||
<label className="form-label">Assigned To</label>
|
<label className="form-label">Zuweisung</label>
|
||||||
<select
|
<p>
|
||||||
className="form-control"
|
{employees.length > 0
|
||||||
value={formData.assignedTo}
|
? `Automatisch an den Mitarbeiter mit der geringsten Anzahl offener Tickets (${employees.length} verfügbar).`
|
||||||
onChange={(e) => {
|
: 'Es ist noch kein Mitarbeiter verfügbar. Das Ticket bleibt zunächst offen.'}
|
||||||
const userId = e.target.value
|
</p>
|
||||||
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>
|
</div>
|
||||||
</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() {
|
export function useAdminConfig() {
|
||||||
const [config, setConfig] = useState(DEFAULT_CONFIG)
|
const [config, setConfig] = useState(DEFAULT_CONFIG)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -56,7 +73,7 @@ export function useAdminConfig() {
|
|||||||
systems: doc.systems || DEFAULT_CONFIG.systems,
|
systems: doc.systems || DEFAULT_CONFIG.systems,
|
||||||
responseLevels: doc.responseLevels || DEFAULT_CONFIG.responseLevels,
|
responseLevels: doc.responseLevels || DEFAULT_CONFIG.responseLevels,
|
||||||
serviceTypes: doc.serviceTypes || DEFAULT_CONFIG.serviceTypes,
|
serviceTypes: doc.serviceTypes || DEFAULT_CONFIG.serviceTypes,
|
||||||
priorities: doc.priorities || DEFAULT_CONFIG.priorities
|
priorities: normalizePriorities(doc.priorities)
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Config existiert noch nicht (404) - das ist normal, verwende Defaults
|
// Config existiert noch nicht (404) - das ist normal, verwende Defaults
|
||||||
@@ -99,7 +116,9 @@ export function useAdminConfig() {
|
|||||||
systems: newConfig.systems,
|
systems: newConfig.systems,
|
||||||
responseLevels: newConfig.responseLevels,
|
responseLevels: newConfig.responseLevels,
|
||||||
serviceTypes: newConfig.serviceTypes,
|
serviceTypes: newConfig.serviceTypes,
|
||||||
priorities: newConfig.priorities
|
priorities: normalizePriorities(newConfig.priorities).map((priority) =>
|
||||||
|
JSON.stringify(priority)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -149,4 +168,3 @@ export function useAdminConfig() {
|
|||||||
refresh: fetchConfig
|
refresh: fetchConfig
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
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 {
|
import {
|
||||||
createCustomerWithPortalAccess,
|
createCustomerWithPortalAccess,
|
||||||
|
deleteCustomerWithPortalAccess,
|
||||||
|
listCustomersForAdmin,
|
||||||
updateCustomerWithPortalAccess,
|
updateCustomerWithPortalAccess,
|
||||||
} from '../lib/customerAdminApi'
|
} from '../lib/customerAdminApi'
|
||||||
|
|
||||||
@@ -26,12 +34,17 @@ export function useCustomers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await databases.listDocuments(
|
try {
|
||||||
DATABASE_ID,
|
const response = await listCustomersForAdmin()
|
||||||
COLLECTIONS.CUSTOMERS,
|
setCustomers(response.customers || [])
|
||||||
[Query.orderAsc('name')]
|
} catch {
|
||||||
)
|
const response = await databases.listDocuments(
|
||||||
setCustomers(response.documents)
|
DATABASE_ID,
|
||||||
|
COLLECTIONS.CUSTOMERS,
|
||||||
|
[Query.orderAsc('name')]
|
||||||
|
)
|
||||||
|
setCustomers(response.documents || [])
|
||||||
|
}
|
||||||
setError(null)
|
setError(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching customers:', err)
|
console.error('Error fetching customers:', err)
|
||||||
@@ -127,11 +140,7 @@ export function useCustomers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await databases.deleteDocument(
|
await deleteCustomerWithPortalAccess(id)
|
||||||
DATABASE_ID,
|
|
||||||
COLLECTIONS.CUSTOMERS,
|
|
||||||
id
|
|
||||||
)
|
|
||||||
setCustomers(prev => prev.filter(c => c.$id !== id))
|
setCustomers(prev => prev.filter(c => c.$id !== id))
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -149,4 +158,3 @@ export function useCustomers() {
|
|||||||
deleteCustomer
|
deleteCustomer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,47 +1,65 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { databases, account, DATABASE_ID, COLLECTIONS, ID, Query, isDemoMode } from '../lib/appwrite'
|
import {
|
||||||
|
COLLECTIONS,
|
||||||
|
DATABASE_ID,
|
||||||
|
Query,
|
||||||
|
databases,
|
||||||
|
isDemoMode,
|
||||||
|
} from '../lib/appwrite'
|
||||||
|
import {
|
||||||
|
createEmployeeWithLogin,
|
||||||
|
deleteEmployeeWithLogin,
|
||||||
|
listEmployeesForAdmin,
|
||||||
|
updateEmployeeWithLogin,
|
||||||
|
} from '../lib/employeeAdminApi'
|
||||||
|
|
||||||
const DEMO_MODE = isDemoMode
|
const DEMO_MODE = isDemoMode
|
||||||
|
|
||||||
// Demo-Mitarbeiter für Testing
|
|
||||||
const DEMO_EMPLOYEES = [
|
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() {
|
export function useEmployees() {
|
||||||
const [employees, setEmployees] = useState([])
|
const [employees, setEmployees] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [syncing, setSyncing] = useState(false)
|
|
||||||
|
|
||||||
const fetchEmployees = useCallback(async () => {
|
const fetchEmployees = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
if (DEMO_MODE) {
|
|
||||||
setEmployees(DEMO_EMPLOYEES)
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await databases.listDocuments(
|
if (DEMO_MODE) {
|
||||||
DATABASE_ID,
|
setEmployees(DEMO_EMPLOYEES)
|
||||||
COLLECTIONS.EMPLOYEES,
|
} else {
|
||||||
[Query.orderAsc('displayName')]
|
try {
|
||||||
)
|
const result = await listEmployeesForAdmin()
|
||||||
setEmployees(response.documents)
|
setEmployees(result.employees || [])
|
||||||
|
} catch {
|
||||||
|
const result = await databases.listDocuments(
|
||||||
|
DATABASE_ID,
|
||||||
|
COLLECTIONS.EMPLOYEES,
|
||||||
|
[Query.orderAsc('displayName')]
|
||||||
|
)
|
||||||
|
setEmployees(result.documents || [])
|
||||||
|
}
|
||||||
|
}
|
||||||
setError(null)
|
setError(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching employees:', err)
|
setEmployees([])
|
||||||
// Wenn Collection nicht existiert, setze leeres Array (kein Fehler)
|
setError(err.message || 'Mitarbeiter konnten nicht geladen werden')
|
||||||
if (err.code === 404 || err.message?.includes('not found')) {
|
|
||||||
setEmployees([])
|
|
||||||
setError(null) // Kein Fehler, Collection existiert einfach noch nicht
|
|
||||||
} else {
|
|
||||||
setError(err.message)
|
|
||||||
setEmployees([])
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -52,141 +70,48 @@ export function useEmployees() {
|
|||||||
}, [fetchEmployees])
|
}, [fetchEmployees])
|
||||||
|
|
||||||
const createEmployee = async (data) => {
|
const createEmployee = async (data) => {
|
||||||
if (DEMO_MODE) {
|
|
||||||
const newEmployee = { ...data, $id: Date.now().toString() }
|
|
||||||
setEmployees(prev => [...prev, newEmployee])
|
|
||||||
return { success: true, data: newEmployee }
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Validierung
|
if (DEMO_MODE) {
|
||||||
if (!data.userId || !data.displayName) {
|
const employee = {
|
||||||
return { success: false, error: 'userId und displayName sind erforderlich' }
|
...data,
|
||||||
}
|
userId: crypto.randomUUID(),
|
||||||
|
$id: Date.now().toString(),
|
||||||
const response = await databases.createDocument(
|
|
||||||
DATABASE_ID,
|
|
||||||
COLLECTIONS.EMPLOYEES,
|
|
||||||
ID.unique(),
|
|
||||||
{
|
|
||||||
userId: data.userId,
|
|
||||||
displayName: data.displayName,
|
|
||||||
email: data.email || '',
|
|
||||||
shortcode: data.shortcode || ''
|
|
||||||
}
|
}
|
||||||
)
|
setEmployees((current) => [...current, employee])
|
||||||
setEmployees(prev => [...prev, response])
|
return { success: true, data: employee }
|
||||||
return { success: true, data: response }
|
}
|
||||||
|
const result = await createEmployeeWithLogin(data)
|
||||||
|
setEmployees((current) => [...current, result.employee])
|
||||||
|
return { success: true, data: result.employee }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error creating employee:', err)
|
|
||||||
return { success: false, error: err.message }
|
return { success: false, error: err.message }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateEmployee = async (id, data) => {
|
const updateEmployee = async (id, data) => {
|
||||||
if (DEMO_MODE) {
|
|
||||||
setEmployees(prev => prev.map(e => e.$id === id ? { ...e, ...data } : e))
|
|
||||||
return { success: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await databases.updateDocument(
|
if (DEMO_MODE) {
|
||||||
DATABASE_ID,
|
setEmployees((current) =>
|
||||||
COLLECTIONS.EMPLOYEES,
|
current.map((employee) => employee.$id === id ? { ...employee, ...data } : employee)
|
||||||
id,
|
)
|
||||||
data
|
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: result.employee }
|
||||||
return { success: true, data: response }
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error updating employee:', err)
|
|
||||||
return { success: false, error: err.message }
|
return { success: false, error: err.message }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteEmployee = async (id) => {
|
const deleteEmployee = async (id) => {
|
||||||
if (DEMO_MODE) {
|
|
||||||
setEmployees(prev => prev.filter(e => e.$id !== id))
|
|
||||||
return { success: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await databases.deleteDocument(
|
if (!DEMO_MODE) await deleteEmployeeWithLogin(id)
|
||||||
DATABASE_ID,
|
setEmployees((current) => current.filter((employee) => employee.$id !== id))
|
||||||
COLLECTIONS.EMPLOYEES,
|
|
||||||
id
|
|
||||||
)
|
|
||||||
setEmployees(prev => prev.filter(e => e.$id !== id))
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (err) {
|
} 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 }
|
return { success: false, error: err.message }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,13 +120,9 @@ export function useEmployees() {
|
|||||||
employees,
|
employees,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
syncing,
|
|
||||||
refresh: fetchEmployees,
|
refresh: fetchEmployees,
|
||||||
createEmployee,
|
createEmployee,
|
||||||
updateEmployee,
|
updateEmployee,
|
||||||
deleteEmployee,
|
deleteEmployee,
|
||||||
syncWithAuthUsers,
|
|
||||||
createSelfEmployee
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -195,6 +195,54 @@ export function useWorkorders(filters = {}) {
|
|||||||
fetchWorkorders()
|
fetchWorkorders()
|
||||||
}, [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) => {
|
const createWorkorder = async (data) => {
|
||||||
if (DEMO_MODE) {
|
if (DEMO_MODE) {
|
||||||
// Finde höchste WOID und +1
|
// Finde höchste WOID und +1
|
||||||
@@ -214,38 +262,16 @@ export function useWorkorders(filters = {}) {
|
|||||||
return { success: false, error: 'Das Feld "Topic" ist erforderlich.' }
|
return { success: false, error: 'Das Feld "Topic" ist erforderlich.' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status-Automatik: Wenn Mitarbeiter zugewiesen → Status = "Assigned", sonst "Open"
|
const automatic = await resolveAutomaticTicketFields(data.assignedTo)
|
||||||
const autoStatus = (data.assignedTo && data.assignedTo !== '') ? 'Assigned' : 'Open'
|
const autoStatus = automatic.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();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bereite Daten für Appwrite vor
|
// Bereite Daten für Appwrite vor
|
||||||
const workorderData = {
|
const workorderData = {
|
||||||
// Required fields
|
// Required fields
|
||||||
topic: data.topic.trim(),
|
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,
|
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
|
// Optional fields - nur senden wenn vorhanden
|
||||||
type: data.type || '',
|
type: data.type || '',
|
||||||
@@ -253,7 +279,8 @@ export function useWorkorders(filters = {}) {
|
|||||||
responseLevel: data.responseLevel || '',
|
responseLevel: data.responseLevel || '',
|
||||||
serviceType: data.serviceType || 'Remote',
|
serviceType: data.serviceType || 'Remote',
|
||||||
customerId: data.customerId || '',
|
customerId: data.customerId || '',
|
||||||
assignedTo: data.assignedTo || '', // Zugewiesener Mitarbeiter
|
assignedTo: automatic.assignedTo,
|
||||||
|
assignedName: automatic.assignedName,
|
||||||
requestedBy: data.requestedBy || '',
|
requestedBy: data.requestedBy || '',
|
||||||
requestedFor: data.requestedFor || '',
|
requestedFor: data.requestedFor || '',
|
||||||
startDate: data.startDate || '',
|
startDate: data.startDate || '',
|
||||||
|
|||||||
@@ -24,9 +24,17 @@ export async function createCustomerWithPortalAccess(payload) {
|
|||||||
return adminFetch('/api/admin/customers', { method: 'POST', body: 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) {
|
export async function updateCustomerWithPortalAccess(customerId, payload) {
|
||||||
return adminFetch(`/api/admin/customers/${customerId}`, {
|
return adminFetch(`/api/admin/customers/${customerId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: payload,
|
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; }
|
.wrap { flex-wrap: wrap; }
|
||||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
|
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
|
||||||
.text-ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.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