feat: rebuild admin account management and ticket assignment

This commit is contained in:
Webklar Deploy
2026-07-11 15:19:54 +00:00
parent 27b365622e
commit f76b738f5f
11 changed files with 1241 additions and 1037 deletions

View File

@@ -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>
)

View File

@@ -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>

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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 || '',

View File

@@ -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' })
}

View 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
View 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

View File

@@ -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;
}