mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 15:04:14 +00:00
- Extend admin component overrides to cover all Payload admin views (no-html-link-for-pages, no-img-element off for admin panel) - Rename useGeneratedReply to applyGeneratedReply (not a hook) - Fix useRealtimeUpdates: resolve circular dependency with connectRef, wrap ref assignments in useEffect for React 19 compiler compliance - Fix MetaBaseClient: let -> const for single-assignment variable ESLint now passes with 0 errors (68 warnings only). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1655 lines
60 KiB
TypeScript
1655 lines
60 KiB
TypeScript
'use client'
|
||
|
||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||
|
||
interface Interaction {
|
||
id: number
|
||
platform: { name: string; icon: string; slug: string }
|
||
socialAccount: { displayName: string }
|
||
linkedContent?: { title: string; youtubeVideoId: string }
|
||
type: string
|
||
externalId: string
|
||
author: {
|
||
name: string
|
||
handle: string
|
||
avatarUrl: string
|
||
isVerified: boolean
|
||
isSubscriber: boolean
|
||
subscriberCount: number
|
||
}
|
||
message: string
|
||
publishedAt: string
|
||
analysis: {
|
||
sentiment: string
|
||
sentimentScore: number
|
||
confidence: number
|
||
topics: { topic: string }[]
|
||
suggestedReply: string
|
||
}
|
||
flags: {
|
||
isMedicalQuestion: boolean
|
||
requiresEscalation: boolean
|
||
isSpam: boolean
|
||
isFromInfluencer: boolean
|
||
}
|
||
status: string
|
||
priority: string
|
||
assignedTo?: { id: number; email: string; name?: string }
|
||
response?: {
|
||
text: string
|
||
sentAt: string
|
||
sentBy: { email: string }
|
||
}
|
||
engagement: {
|
||
likes: number
|
||
replies: number
|
||
isHearted: boolean
|
||
isPinned: boolean
|
||
}
|
||
createdAt: string
|
||
updatedAt: string
|
||
}
|
||
|
||
interface Filters {
|
||
status: string[]
|
||
priority: string[]
|
||
platform: string[]
|
||
channel: string[]
|
||
sentiment: string[]
|
||
flags: string[]
|
||
assignedTo: string
|
||
search: string
|
||
dateFrom: string
|
||
dateTo: string
|
||
}
|
||
|
||
const DEFAULT_FILTERS: Filters = {
|
||
status: ['new', 'in_review', 'waiting'],
|
||
priority: [],
|
||
platform: [],
|
||
channel: [],
|
||
sentiment: [],
|
||
flags: [],
|
||
assignedTo: '',
|
||
search: '',
|
||
dateFrom: '',
|
||
dateTo: '',
|
||
}
|
||
|
||
export const CommunityInbox: React.FC = () => {
|
||
// State
|
||
const [interactions, setInteractions] = useState<Interaction[]>([])
|
||
const [selectedInteraction, setSelectedInteraction] = useState<Interaction | null>(null)
|
||
const [filters, setFilters] = useState<Filters>(DEFAULT_FILTERS)
|
||
const [loading, setLoading] = useState(true)
|
||
const [syncing, setSyncing] = useState(false)
|
||
const [replyText, setReplyText] = useState('')
|
||
const [sendingReply, setSendingReply] = useState(false)
|
||
const [exporting, setExporting] = useState(false)
|
||
|
||
// Mobile state
|
||
const [isMobile, setIsMobile] = useState(false)
|
||
const [mobileView, setMobileView] = useState<'list' | 'detail' | 'filters'>('list')
|
||
|
||
// Export state
|
||
const [showExportModal, setShowExportModal] = useState(false)
|
||
const [exportFormat, setExportFormat] = useState<'pdf' | 'excel' | 'csv'>('excel')
|
||
const [exportDateRange, setExportDateRange] = useState({ from: '', to: '' })
|
||
|
||
// AI Reply state
|
||
const [isGeneratingReply, setIsGeneratingReply] = useState(false)
|
||
const [generatedReplies, setGeneratedReplies] = useState<Array<{
|
||
text: string
|
||
tone: string
|
||
confidence: number
|
||
warnings: string[]
|
||
}>>([])
|
||
|
||
// Sync status state
|
||
const [syncStatus, setSyncStatus] = useState<{
|
||
isRunning: boolean
|
||
lastRunAt: string | null
|
||
nextRunAt: string | null
|
||
} | null>(null)
|
||
|
||
// Refs
|
||
const listRef = useRef<HTMLUListElement>(null)
|
||
|
||
// Metadata for filters
|
||
const [platforms, setPlatforms] = useState<any[]>([])
|
||
const [channels, setChannels] = useState<any[]>([])
|
||
const [templates, setTemplates] = useState<any[]>([])
|
||
const [users, setUsers] = useState<any[]>([])
|
||
|
||
// Stats
|
||
const [stats, setStats] = useState({
|
||
total: 0,
|
||
new: 0,
|
||
urgent: 0,
|
||
medical: 0,
|
||
unassigned: 0,
|
||
})
|
||
|
||
// Detect mobile
|
||
useEffect(() => {
|
||
const checkMobile = () => {
|
||
setIsMobile(window.innerWidth < 768)
|
||
}
|
||
|
||
checkMobile()
|
||
window.addEventListener('resize', checkMobile)
|
||
return () => window.removeEventListener('resize', checkMobile)
|
||
}, [])
|
||
|
||
// Load metadata
|
||
useEffect(() => {
|
||
const loadMetadata = async () => {
|
||
try {
|
||
const [platformsRes, channelsRes, templatesRes, usersRes] = await Promise.all([
|
||
fetch('/api/social-platforms?limit=100'),
|
||
fetch('/api/youtube-channels?limit=100'),
|
||
fetch('/api/community-templates?where[isActive][equals]=true&limit=100'),
|
||
fetch('/api/users?where[communityRole][not_equals]=none&limit=100'),
|
||
])
|
||
|
||
const [platformsData, channelsData, templatesData, usersData] = await Promise.all([
|
||
platformsRes.json(),
|
||
channelsRes.json(),
|
||
templatesRes.json(),
|
||
usersRes.json(),
|
||
])
|
||
|
||
setPlatforms(platformsData.docs || [])
|
||
setChannels(channelsData.docs || [])
|
||
setTemplates(templatesData.docs || [])
|
||
setUsers(usersData.docs || [])
|
||
} catch (error) {
|
||
console.error('Failed to load metadata:', error)
|
||
}
|
||
}
|
||
|
||
loadMetadata()
|
||
}, [])
|
||
|
||
// Load interactions
|
||
const loadInteractions = useCallback(async () => {
|
||
setLoading(true)
|
||
try {
|
||
const params = new URLSearchParams()
|
||
params.set('limit', '50')
|
||
params.set('sort', '-createdAt')
|
||
params.set('depth', '2')
|
||
|
||
// Apply filters
|
||
if (filters.status.length > 0) {
|
||
params.set('where[status][in]', filters.status.join(','))
|
||
}
|
||
if (filters.priority.length > 0) {
|
||
params.set('where[priority][in]', filters.priority.join(','))
|
||
}
|
||
if (filters.platform.length > 0) {
|
||
params.set('where[platform][in]', filters.platform.join(','))
|
||
}
|
||
if (filters.sentiment.length > 0) {
|
||
params.set('where[analysis.sentiment][in]', filters.sentiment.join(','))
|
||
}
|
||
if (filters.flags.includes('medical')) {
|
||
params.set('where[flags.isMedicalQuestion][equals]', 'true')
|
||
}
|
||
if (filters.flags.includes('escalation')) {
|
||
params.set('where[flags.requiresEscalation][equals]', 'true')
|
||
}
|
||
if (filters.flags.includes('influencer')) {
|
||
params.set('where[flags.isFromInfluencer][equals]', 'true')
|
||
}
|
||
if (filters.assignedTo) {
|
||
if (filters.assignedTo === 'unassigned') {
|
||
params.set('where[assignedTo][exists]', 'false')
|
||
} else {
|
||
params.set('where[assignedTo][equals]', filters.assignedTo)
|
||
}
|
||
}
|
||
if (filters.search) {
|
||
params.set('where[or][0][message][contains]', filters.search)
|
||
params.set('where[or][1][author.name][contains]', filters.search)
|
||
}
|
||
if (filters.dateFrom) {
|
||
params.set('where[publishedAt][greater_than_equal]', filters.dateFrom)
|
||
}
|
||
if (filters.dateTo) {
|
||
params.set('where[publishedAt][less_than_equal]', filters.dateTo)
|
||
}
|
||
|
||
const response = await fetch(`/api/community-interactions?${params}`)
|
||
const data = await response.json()
|
||
|
||
setInteractions(data.docs || [])
|
||
|
||
// Update stats
|
||
const statsResponse = await fetch('/api/community-interactions/stats')
|
||
if (statsResponse.ok) {
|
||
const statsData = await statsResponse.json()
|
||
setStats(statsData)
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load interactions:', error)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [filters])
|
||
|
||
useEffect(() => {
|
||
loadInteractions()
|
||
}, [loadInteractions])
|
||
|
||
// Fetch Sync Status
|
||
useEffect(() => {
|
||
const fetchSyncStatus = async () => {
|
||
try {
|
||
const response = await fetch('/api/community/sync')
|
||
if (response.ok) {
|
||
const status = await response.json()
|
||
setSyncStatus(status)
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to fetch sync status:', error)
|
||
}
|
||
}
|
||
|
||
fetchSyncStatus()
|
||
const interval = setInterval(fetchSyncStatus, 60000) // Jede Minute
|
||
|
||
return () => clearInterval(interval)
|
||
}, [])
|
||
|
||
// Sync comments (all accounts)
|
||
const handleSync = async () => {
|
||
setSyncing(true)
|
||
try {
|
||
const response = await fetch('/api/community/sync', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
})
|
||
|
||
if (response.ok) {
|
||
const result = await response.json()
|
||
if (result.success && result.results) {
|
||
const totalNew = result.results.reduce((sum: number, r: { newComments: number }) => sum + r.newComments, 0)
|
||
const totalUpdated = result.results.reduce((sum: number, r: { updatedComments: number }) => sum + r.updatedComments, 0)
|
||
alert(`Sync abgeschlossen: ${totalNew} neue, ${totalUpdated} aktualisierte Kommentare`)
|
||
}
|
||
loadInteractions()
|
||
// Refresh sync status
|
||
const statusResponse = await fetch('/api/community/sync')
|
||
if (statusResponse.ok) {
|
||
const status = await statusResponse.json()
|
||
setSyncStatus(status)
|
||
}
|
||
} else {
|
||
const error = await response.json()
|
||
throw new Error(error.error || 'Sync failed')
|
||
}
|
||
} catch (error) {
|
||
console.error('Sync error:', error)
|
||
alert('Sync fehlgeschlagen. Details in der Console.')
|
||
} finally {
|
||
setSyncing(false)
|
||
}
|
||
}
|
||
|
||
// Export function
|
||
const handleExport = async () => {
|
||
setExporting(true)
|
||
try {
|
||
const params = new URLSearchParams()
|
||
params.set('format', exportFormat)
|
||
|
||
if (exportDateRange.from) {
|
||
params.set('dateFrom', exportDateRange.from)
|
||
}
|
||
if (exportDateRange.to) {
|
||
params.set('dateTo', exportDateRange.to)
|
||
}
|
||
|
||
if (filters.status.length > 0) {
|
||
params.set('status', filters.status.join(','))
|
||
}
|
||
if (filters.platform.length > 0) {
|
||
params.set('platform', filters.platform.join(','))
|
||
}
|
||
if (filters.sentiment.length > 0) {
|
||
params.set('sentiment', filters.sentiment.join(','))
|
||
}
|
||
|
||
const response = await fetch(`/api/community/export?${params}`)
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Export failed')
|
||
}
|
||
|
||
const blob = await response.blob()
|
||
const url = window.URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
|
||
const extension = exportFormat === 'excel' ? 'xlsx' : exportFormat
|
||
const timestamp = new Date().toISOString().split('T')[0]
|
||
a.download = `community-report-${timestamp}.${extension}`
|
||
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
document.body.removeChild(a)
|
||
window.URL.revokeObjectURL(url)
|
||
|
||
setShowExportModal(false)
|
||
} catch (error) {
|
||
console.error('Export error:', error)
|
||
alert('Export fehlgeschlagen')
|
||
} finally {
|
||
setExporting(false)
|
||
}
|
||
}
|
||
|
||
// Update interaction status
|
||
const updateStatus = async (id: number, status: string) => {
|
||
try {
|
||
await fetch(`/api/community-interactions/${id}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ status }),
|
||
})
|
||
|
||
setInteractions((prev) => prev.map((i) => (i.id === id ? { ...i, status } : i)))
|
||
|
||
if (selectedInteraction?.id === id) {
|
||
setSelectedInteraction((prev) => (prev ? { ...prev, status } : null))
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to update status:', error)
|
||
}
|
||
}
|
||
|
||
// Assign interaction
|
||
const assignTo = async (id: number, userId: string) => {
|
||
try {
|
||
await fetch(`/api/community-interactions/${id}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ assignedTo: userId || null }),
|
||
})
|
||
|
||
loadInteractions()
|
||
} catch (error) {
|
||
console.error('Failed to assign:', error)
|
||
}
|
||
}
|
||
|
||
// Send reply
|
||
const sendReply = async () => {
|
||
if (!selectedInteraction || !replyText.trim()) return
|
||
|
||
setSendingReply(true)
|
||
try {
|
||
const response = await fetch('/api/community/reply', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
interactionId: selectedInteraction.id,
|
||
text: replyText,
|
||
}),
|
||
})
|
||
|
||
if (response.ok) {
|
||
setReplyText('')
|
||
updateStatus(selectedInteraction.id, 'replied')
|
||
loadInteractions()
|
||
alert('Antwort gesendet!')
|
||
} else {
|
||
throw new Error('Reply failed')
|
||
}
|
||
} catch (error) {
|
||
console.error('Reply error:', error)
|
||
alert('Antwort konnte nicht gesendet werden.')
|
||
} finally {
|
||
setSendingReply(false)
|
||
}
|
||
}
|
||
|
||
// Apply template
|
||
const applyTemplate = (template: any) => {
|
||
let text = template.template
|
||
|
||
if (selectedInteraction) {
|
||
text = text.replace(/\{\{author_name\}\}/g, selectedInteraction.author?.name || 'there')
|
||
text = text.replace(/\{\{video_title\}\}/g, selectedInteraction.linkedContent?.title || '')
|
||
text = text.replace(
|
||
/\{\{channel_name\}\}/g,
|
||
selectedInteraction.socialAccount?.displayName || '',
|
||
)
|
||
}
|
||
|
||
setReplyText(text)
|
||
}
|
||
|
||
// Generate AI reply
|
||
const generateAIReply = async (variants: boolean = false) => {
|
||
if (!selectedInteraction) return
|
||
|
||
setIsGeneratingReply(true)
|
||
setGeneratedReplies([])
|
||
|
||
try {
|
||
const response = await fetch('/api/community/generate-reply', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
interactionId: selectedInteraction.id,
|
||
variants,
|
||
}),
|
||
})
|
||
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
|
||
if (variants && data.variants) {
|
||
setGeneratedReplies(data.variants)
|
||
} else if (data.reply) {
|
||
// Einzelne Antwort direkt übernehmen
|
||
setReplyText(data.reply.text)
|
||
}
|
||
} else {
|
||
throw new Error('Generation failed')
|
||
}
|
||
} catch (error) {
|
||
console.error('AI generation error:', error)
|
||
alert('KI-Antwortgenerierung fehlgeschlagen')
|
||
} finally {
|
||
setIsGeneratingReply(false)
|
||
}
|
||
}
|
||
|
||
// Use generated reply
|
||
const applyGeneratedReply = (reply: { text: string }) => {
|
||
setReplyText(reply.text)
|
||
setGeneratedReplies([])
|
||
}
|
||
|
||
// Select interaction
|
||
const selectInteraction = (interaction: Interaction) => {
|
||
setSelectedInteraction(interaction)
|
||
|
||
if (interaction.status === 'new') {
|
||
updateStatus(interaction.id, 'in_review')
|
||
}
|
||
|
||
if (isMobile) {
|
||
setMobileView('detail')
|
||
}
|
||
}
|
||
|
||
// Mobile back navigation
|
||
const handleMobileBack = () => {
|
||
if (mobileView === 'detail') {
|
||
setMobileView('list')
|
||
setSelectedInteraction(null)
|
||
} else if (mobileView === 'filters') {
|
||
setMobileView('list')
|
||
}
|
||
}
|
||
|
||
// Render helpers
|
||
const getSentimentEmoji = (sentiment: string) => {
|
||
switch (sentiment) {
|
||
case 'positive':
|
||
return '😊'
|
||
case 'negative':
|
||
return '😟'
|
||
case 'mixed':
|
||
return '😐'
|
||
case 'question':
|
||
return '❓'
|
||
default:
|
||
return '•'
|
||
}
|
||
}
|
||
|
||
const getPriorityColor = (priority: string) => {
|
||
switch (priority) {
|
||
case 'urgent':
|
||
return '#dc2626'
|
||
case 'high':
|
||
return '#f97316'
|
||
case 'normal':
|
||
return '#3b82f6'
|
||
case 'low':
|
||
return '#22c55e'
|
||
default:
|
||
return '#6b7280'
|
||
}
|
||
}
|
||
|
||
const getStatusBadge = (status: string) => {
|
||
const badges: Record<string, { label: string; color: string }> = {
|
||
new: { label: 'Neu', color: '#3b82f6' },
|
||
in_review: { label: 'In Review', color: '#f97316' },
|
||
waiting: { label: 'Warten auf Info', color: '#8b5cf6' },
|
||
replied: { label: 'Beantwortet', color: '#22c55e' },
|
||
resolved: { label: 'Erledigt', color: '#10b981' },
|
||
archived: { label: 'Archiviert', color: '#1f2937' },
|
||
spam: { label: 'Spam', color: '#dc2626' },
|
||
}
|
||
return badges[status] || { label: status, color: '#6b7280' }
|
||
}
|
||
|
||
const formatDate = (dateString: string) => {
|
||
const date = new Date(dateString)
|
||
const now = new Date()
|
||
const diffMs = now.getTime() - date.getTime()
|
||
const diffMins = Math.floor(diffMs / 60000)
|
||
const diffHours = Math.floor(diffMs / 3600000)
|
||
const diffDays = Math.floor(diffMs / 86400000)
|
||
|
||
if (diffMins < 60) return `${diffMins}m`
|
||
if (diffHours < 24) return `${diffHours}h`
|
||
if (diffDays < 7) return `${diffDays}d`
|
||
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })
|
||
}
|
||
|
||
const activeFiltersCount = [
|
||
filters.status.length !== 3 ? 1 : 0,
|
||
filters.priority.length > 0 ? 1 : 0,
|
||
filters.platform.length > 0 ? 1 : 0,
|
||
filters.sentiment.length > 0 ? 1 : 0,
|
||
filters.flags.length > 0 ? 1 : 0,
|
||
filters.assignedTo ? 1 : 0,
|
||
filters.search ? 1 : 0,
|
||
].reduce((a, b) => a + b, 0)
|
||
|
||
// MOBILE RENDER
|
||
if (isMobile) {
|
||
return (
|
||
<div className="community-inbox mobile">
|
||
{/* Mobile Header */}
|
||
<div className="mobile-header">
|
||
{mobileView === 'list' ? (
|
||
<>
|
||
<h1>📬 Inbox</h1>
|
||
<div className="mobile-header-actions">
|
||
<a href="/admin/views/community/analytics" className="btn-icon">
|
||
📈
|
||
</a>
|
||
<button className="btn-icon" onClick={() => setMobileView('filters')}>
|
||
🎛️ {activeFiltersCount > 0 && <span className="filter-badge">{activeFiltersCount}</span>}
|
||
</button>
|
||
<button className="btn-icon" onClick={() => setShowExportModal(true)}>
|
||
📊
|
||
</button>
|
||
<button className="btn-icon" onClick={handleSync} disabled={syncing}>
|
||
{syncing ? '⏳' : '🔄'}
|
||
</button>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<button className="btn-back" onClick={handleMobileBack}>
|
||
← Zurück
|
||
</button>
|
||
<span className="mobile-title">{mobileView === 'detail' ? 'Details' : 'Filter'}</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Mobile Stats */}
|
||
{mobileView === 'list' && (
|
||
<div className="mobile-stats">
|
||
<div className="stat">
|
||
<strong>{stats.new}</strong>
|
||
<small>Neu</small>
|
||
</div>
|
||
<div className="stat urgent">
|
||
<strong>{stats.urgent}</strong>
|
||
<small>Dringend</small>
|
||
</div>
|
||
<div className="stat medical">
|
||
<strong>{stats.medical}</strong>
|
||
<small>Medizin</small>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Mobile List */}
|
||
{mobileView === 'list' && (
|
||
<div className="mobile-list">
|
||
{loading ? (
|
||
<div className="loading">Lade...</div>
|
||
) : interactions.length === 0 ? (
|
||
<div className="empty-state">Keine Interaktionen gefunden</div>
|
||
) : (
|
||
<ul>
|
||
{interactions.map((interaction) => (
|
||
<li
|
||
key={interaction.id}
|
||
className={`mobile-item ${interaction.status === 'new' ? 'unread' : ''}`}
|
||
onClick={() => selectInteraction(interaction)}
|
||
>
|
||
<div className="item-row">
|
||
<span
|
||
className="priority-bar"
|
||
style={{ backgroundColor: getPriorityColor(interaction.priority) }}
|
||
/>
|
||
<div className="item-content">
|
||
<div className="item-header">
|
||
<span className="author">
|
||
{interaction.platform?.icon} {interaction.author?.name}
|
||
</span>
|
||
<span className="time">{formatDate(interaction.publishedAt)}</span>
|
||
</div>
|
||
<p className="message">{interaction.message?.substring(0, 80)}...</p>
|
||
<div className="item-badges">
|
||
{interaction.analysis?.sentiment && (
|
||
<span className="badge">
|
||
{getSentimentEmoji(interaction.analysis.sentiment)}
|
||
</span>
|
||
)}
|
||
{interaction.flags?.isMedicalQuestion && (
|
||
<span className="badge medical">⚕️</span>
|
||
)}
|
||
{interaction.flags?.requiresEscalation && (
|
||
<span className="badge urgent">🚨</span>
|
||
)}
|
||
<span
|
||
className="badge status"
|
||
style={{ backgroundColor: getStatusBadge(interaction.status).color }}
|
||
>
|
||
{getStatusBadge(interaction.status).label}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<span className="chevron">›</span>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Mobile Detail View */}
|
||
{mobileView === 'detail' && selectedInteraction && (
|
||
<div className="mobile-detail">
|
||
<div className="author-card">
|
||
{selectedInteraction.author?.avatarUrl && (
|
||
<img src={selectedInteraction.author.avatarUrl} alt="" className="avatar" />
|
||
)}
|
||
<div className="author-info">
|
||
<h3>
|
||
{selectedInteraction.author?.name}
|
||
{selectedInteraction.author?.isVerified && ' ✓'}
|
||
</h3>
|
||
<p>
|
||
{selectedInteraction.author?.handle}
|
||
{selectedInteraction.author?.subscriberCount > 0 &&
|
||
` • ${selectedInteraction.author.subscriberCount.toLocaleString()} Follower`}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="quick-actions">
|
||
<select
|
||
value={selectedInteraction.status}
|
||
onChange={(e) => updateStatus(selectedInteraction.id, e.target.value)}
|
||
>
|
||
<option value="new">🆕 Neu</option>
|
||
<option value="in_review">👁️ In Review</option>
|
||
<option value="waiting">⏳ Warten auf Info</option>
|
||
<option value="replied">✅ Beantwortet</option>
|
||
<option value="resolved">✔️ Erledigt</option>
|
||
<option value="archived">📦 Archiviert</option>
|
||
<option value="spam">🚫 Spam</option>
|
||
</select>
|
||
<select
|
||
value={String(selectedInteraction.assignedTo?.id || '')}
|
||
onChange={(e) => assignTo(selectedInteraction.id, e.target.value)}
|
||
>
|
||
<option value="">Nicht zugewiesen</option>
|
||
{users.map((user) => (
|
||
<option key={user.id} value={user.id}>
|
||
{user.name || user.email}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{(selectedInteraction.flags?.isMedicalQuestion ||
|
||
selectedInteraction.flags?.requiresEscalation) && (
|
||
<div className="mobile-alerts">
|
||
{selectedInteraction.flags.isMedicalQuestion && (
|
||
<div className="alert medical">⚕️ Medizinische Frage – Auf Hotline verweisen!</div>
|
||
)}
|
||
{selectedInteraction.flags.requiresEscalation && (
|
||
<div className="alert urgent">🚨 Eskalation erforderlich</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{selectedInteraction.linkedContent && (
|
||
<a
|
||
href={`https://youtube.com/watch?v=${selectedInteraction.linkedContent.youtubeVideoId}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="context-link"
|
||
>
|
||
📺 {selectedInteraction.linkedContent.title}
|
||
</a>
|
||
)}
|
||
|
||
<div className="message-card">
|
||
<p>{selectedInteraction.message}</p>
|
||
<div className="message-meta">
|
||
<span>{formatDate(selectedInteraction.publishedAt)}</span>
|
||
<span>❤️ {selectedInteraction.engagement?.likes || 0}</span>
|
||
<span>💬 {selectedInteraction.engagement?.replies || 0}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{selectedInteraction.analysis && (
|
||
<div className="analysis-card">
|
||
<h4>🤖 KI-Analyse</h4>
|
||
<div className="analysis-row">
|
||
<span>Sentiment:</span>
|
||
<span>
|
||
{getSentimentEmoji(selectedInteraction.analysis.sentiment)}{' '}
|
||
{selectedInteraction.analysis.sentiment}
|
||
</span>
|
||
</div>
|
||
{selectedInteraction.analysis.topics?.length > 0 && (
|
||
<div className="analysis-row">
|
||
<span>Themen:</span>
|
||
<div className="topics">
|
||
{selectedInteraction.analysis.topics.map((t, i) => (
|
||
<span key={i} className="topic">
|
||
{t.topic}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{selectedInteraction.response?.text && (
|
||
<div className="response-card">
|
||
<h4>✅ Unsere Antwort</h4>
|
||
<p>{selectedInteraction.response.text}</p>
|
||
<small>{formatDate(selectedInteraction.response.sentAt)}</small>
|
||
</div>
|
||
)}
|
||
|
||
{!['resolved', 'archived', 'spam'].includes(selectedInteraction.status) &&
|
||
!selectedInteraction.response?.text && (
|
||
<div className="reply-section">
|
||
<div className="reply-header">
|
||
<select
|
||
onChange={(e) => {
|
||
const template = templates.find((t) => t.id === Number(e.target.value))
|
||
if (template) applyTemplate(template)
|
||
}}
|
||
defaultValue=""
|
||
>
|
||
<option value="">📝 Template...</option>
|
||
{templates.map((template) => (
|
||
<option key={template.id} value={template.id}>
|
||
{template.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<button
|
||
className="btn-ai"
|
||
onClick={() => generateAIReply(false)}
|
||
disabled={isGeneratingReply}
|
||
>
|
||
{isGeneratingReply ? '...' : '🤖 KI'}
|
||
</button>
|
||
</div>
|
||
<textarea
|
||
value={replyText}
|
||
onChange={(e) => setReplyText(e.target.value)}
|
||
placeholder="Antwort schreiben..."
|
||
rows={4}
|
||
/>
|
||
<button
|
||
className="btn btn-primary"
|
||
onClick={sendReply}
|
||
disabled={!replyText.trim() || sendingReply}
|
||
>
|
||
{sendingReply ? 'Sende...' : '📤 Antworten'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Mobile Filters */}
|
||
{mobileView === 'filters' && (
|
||
<div className="mobile-filters">
|
||
<div className="filter-section">
|
||
<h3>Status</h3>
|
||
<div className="filter-chips">
|
||
{['new', 'in_review', 'waiting', 'replied', 'resolved', 'archived', 'spam'].map(
|
||
(status) => (
|
||
<button
|
||
key={status}
|
||
className={`chip ${filters.status.includes(status) ? 'active' : ''}`}
|
||
onClick={() => {
|
||
setFilters((prev) => ({
|
||
...prev,
|
||
status: prev.status.includes(status)
|
||
? prev.status.filter((s) => s !== status)
|
||
: [...prev.status, status],
|
||
}))
|
||
}}
|
||
>
|
||
{getStatusBadge(status).label}
|
||
</button>
|
||
),
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="filter-section">
|
||
<h3>Priorität</h3>
|
||
<div className="filter-chips">
|
||
{['urgent', 'high', 'normal', 'low'].map((priority) => (
|
||
<button
|
||
key={priority}
|
||
className={`chip ${filters.priority.includes(priority) ? 'active' : ''}`}
|
||
onClick={() => {
|
||
setFilters((prev) => ({
|
||
...prev,
|
||
priority: prev.priority.includes(priority)
|
||
? prev.priority.filter((p) => p !== priority)
|
||
: [...prev.priority, priority],
|
||
}))
|
||
}}
|
||
>
|
||
{priority}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="filter-section">
|
||
<h3>Sentiment</h3>
|
||
<div className="filter-chips">
|
||
{['positive', 'neutral', 'negative', 'mixed'].map((sentiment) => (
|
||
<button
|
||
key={sentiment}
|
||
className={`chip ${filters.sentiment.includes(sentiment) ? 'active' : ''}`}
|
||
onClick={() => {
|
||
setFilters((prev) => ({
|
||
...prev,
|
||
sentiment: prev.sentiment.includes(sentiment)
|
||
? prev.sentiment.filter((s) => s !== sentiment)
|
||
: [...prev.sentiment, sentiment],
|
||
}))
|
||
}}
|
||
>
|
||
{getSentimentEmoji(sentiment)} {sentiment}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="filter-section">
|
||
<h3>Flags</h3>
|
||
<div className="filter-chips">
|
||
<button
|
||
className={`chip ${filters.flags.includes('medical') ? 'active' : ''}`}
|
||
onClick={() => {
|
||
setFilters((prev) => ({
|
||
...prev,
|
||
flags: prev.flags.includes('medical')
|
||
? prev.flags.filter((f) => f !== 'medical')
|
||
: [...prev.flags, 'medical'],
|
||
}))
|
||
}}
|
||
>
|
||
⚕️ Medizinisch
|
||
</button>
|
||
<button
|
||
className={`chip ${filters.flags.includes('escalation') ? 'active' : ''}`}
|
||
onClick={() => {
|
||
setFilters((prev) => ({
|
||
...prev,
|
||
flags: prev.flags.includes('escalation')
|
||
? prev.flags.filter((f) => f !== 'escalation')
|
||
: [...prev.flags, 'escalation'],
|
||
}))
|
||
}}
|
||
>
|
||
🚨 Eskalation
|
||
</button>
|
||
<button
|
||
className={`chip ${filters.flags.includes('influencer') ? 'active' : ''}`}
|
||
onClick={() => {
|
||
setFilters((prev) => ({
|
||
...prev,
|
||
flags: prev.flags.includes('influencer')
|
||
? prev.flags.filter((f) => f !== 'influencer')
|
||
: [...prev.flags, 'influencer'],
|
||
}))
|
||
}}
|
||
>
|
||
⭐ Influencer
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<button className="btn btn-full" onClick={() => setFilters(DEFAULT_FILTERS)}>
|
||
Filter zurücksetzen
|
||
</button>
|
||
<button className="btn btn-primary btn-full" onClick={() => setMobileView('list')}>
|
||
Anwenden
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Export Modal */}
|
||
{showExportModal && (
|
||
<div className="modal-overlay" onClick={() => setShowExportModal(false)}>
|
||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||
<div className="modal-header">
|
||
<h2>📊 Report exportieren</h2>
|
||
<button className="modal-close" onClick={() => setShowExportModal(false)}>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
<div className="modal-body">
|
||
<div className="form-group">
|
||
<label>Format</label>
|
||
<div className="format-options">
|
||
{(['excel', 'csv', 'pdf'] as const).map((format) => (
|
||
<label
|
||
key={format}
|
||
className={`format-option ${exportFormat === format ? 'selected' : ''}`}
|
||
>
|
||
<input
|
||
type="radio"
|
||
name="format"
|
||
value={format}
|
||
checked={exportFormat === format}
|
||
onChange={() => setExportFormat(format)}
|
||
/>
|
||
<span className="format-icon">
|
||
{format === 'excel' ? '📗' : format === 'csv' ? '📄' : '📕'}
|
||
</span>
|
||
<span>{format.toUpperCase()}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label>Zeitraum</label>
|
||
<div className="date-inputs">
|
||
<input
|
||
type="date"
|
||
value={exportDateRange.from}
|
||
onChange={(e) =>
|
||
setExportDateRange((prev) => ({ ...prev, from: e.target.value }))
|
||
}
|
||
/>
|
||
<input
|
||
type="date"
|
||
value={exportDateRange.to}
|
||
onChange={(e) =>
|
||
setExportDateRange((prev) => ({ ...prev, to: e.target.value }))
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="modal-footer">
|
||
<button className="btn" onClick={() => setShowExportModal(false)}>
|
||
Abbrechen
|
||
</button>
|
||
<button className="btn btn-primary" onClick={handleExport} disabled={exporting}>
|
||
{exporting ? '⏳ Exportiere...' : '📥 Exportieren'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// DESKTOP RENDER
|
||
return (
|
||
<div className="community-inbox desktop">
|
||
{/* Header */}
|
||
<div className="inbox-header">
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||
<h1>📬 Community Inbox</h1>
|
||
<div className="inbox-stats">
|
||
<span className="stat stat-new">
|
||
<strong>{stats.new}</strong> Neu
|
||
</span>
|
||
<span className="stat stat-urgent">
|
||
<strong>{stats.urgent}</strong> Dringend
|
||
</span>
|
||
<span className="stat stat-medical">
|
||
<strong>{stats.medical}</strong> Medizinisch
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="inbox-actions">
|
||
{/* Sync Status Badge */}
|
||
{syncStatus && (
|
||
<div className="sync-status">
|
||
<span className={`sync-indicator ${syncStatus.isRunning ? 'active' : ''}`} />
|
||
<span className="sync-text">
|
||
{syncStatus.isRunning
|
||
? 'Sync läuft...'
|
||
: syncStatus.lastRunAt
|
||
? `Letzter Sync: ${new Date(syncStatus.lastRunAt).toLocaleTimeString('de-DE')}`
|
||
: 'Noch nicht synchronisiert'}
|
||
</span>
|
||
</div>
|
||
)}
|
||
<a href="/admin/views/community/analytics" className="btn">
|
||
📈 Analytics
|
||
</a>
|
||
<button className="btn" onClick={() => setShowExportModal(true)}>
|
||
📊 Export
|
||
</button>
|
||
<button className="btn btn-primary" onClick={handleSync} disabled={syncing || syncStatus?.isRunning}>
|
||
{syncing || syncStatus?.isRunning ? '⏳ Syncing...' : '🔄 Sync'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Layout */}
|
||
<div className="inbox-layout">
|
||
{/* Sidebar Filters */}
|
||
<aside className="inbox-sidebar">
|
||
<div className="filter-section">
|
||
<h3>Status</h3>
|
||
<div className="filter-options">
|
||
{['new', 'in_review', 'waiting', 'replied', 'resolved', 'archived', 'spam'].map(
|
||
(status) => (
|
||
<label key={status} className="filter-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={filters.status.includes(status)}
|
||
onChange={(e) => {
|
||
setFilters((prev) => ({
|
||
...prev,
|
||
status: e.target.checked
|
||
? [...prev.status, status]
|
||
: prev.status.filter((s) => s !== status),
|
||
}))
|
||
}}
|
||
/>
|
||
<span
|
||
className="status-dot"
|
||
style={{ backgroundColor: getStatusBadge(status).color }}
|
||
/>
|
||
{getStatusBadge(status).label}
|
||
</label>
|
||
),
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="filter-section">
|
||
<h3>Priorität</h3>
|
||
<div className="filter-options">
|
||
{['urgent', 'high', 'normal', 'low'].map((priority) => (
|
||
<label key={priority} className="filter-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={filters.priority.includes(priority)}
|
||
onChange={(e) => {
|
||
setFilters((prev) => ({
|
||
...prev,
|
||
priority: e.target.checked
|
||
? [...prev.priority, priority]
|
||
: prev.priority.filter((p) => p !== priority),
|
||
}))
|
||
}}
|
||
/>
|
||
<span className="priority-dot" style={{ backgroundColor: getPriorityColor(priority) }} />
|
||
{priority}
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="filter-section">
|
||
<h3>Sentiment</h3>
|
||
<div className="filter-options">
|
||
{['positive', 'neutral', 'negative', 'mixed'].map((sentiment) => (
|
||
<label key={sentiment} className="filter-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={filters.sentiment.includes(sentiment)}
|
||
onChange={(e) => {
|
||
setFilters((prev) => ({
|
||
...prev,
|
||
sentiment: e.target.checked
|
||
? [...prev.sentiment, sentiment]
|
||
: prev.sentiment.filter((s) => s !== sentiment),
|
||
}))
|
||
}}
|
||
/>
|
||
{getSentimentEmoji(sentiment)} {sentiment}
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="filter-section">
|
||
<h3>Flags</h3>
|
||
<div className="filter-options">
|
||
<label className="filter-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={filters.flags.includes('medical')}
|
||
onChange={(e) => {
|
||
setFilters((prev) => ({
|
||
...prev,
|
||
flags: e.target.checked
|
||
? [...prev.flags, 'medical']
|
||
: prev.flags.filter((f) => f !== 'medical'),
|
||
}))
|
||
}}
|
||
/>
|
||
⚕️ Medizinische Frage
|
||
</label>
|
||
<label className="filter-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={filters.flags.includes('escalation')}
|
||
onChange={(e) => {
|
||
setFilters((prev) => ({
|
||
...prev,
|
||
flags: e.target.checked
|
||
? [...prev.flags, 'escalation']
|
||
: prev.flags.filter((f) => f !== 'escalation'),
|
||
}))
|
||
}}
|
||
/>
|
||
🚨 Eskalation
|
||
</label>
|
||
<label className="filter-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={filters.flags.includes('influencer')}
|
||
onChange={(e) => {
|
||
setFilters((prev) => ({
|
||
...prev,
|
||
flags: e.target.checked
|
||
? [...prev.flags, 'influencer']
|
||
: prev.flags.filter((f) => f !== 'influencer'),
|
||
}))
|
||
}}
|
||
/>
|
||
⭐ Influencer
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="filter-section">
|
||
<h3>Zugewiesen an</h3>
|
||
<select
|
||
value={filters.assignedTo}
|
||
onChange={(e) => setFilters((prev) => ({ ...prev, assignedTo: e.target.value }))}
|
||
className="filter-select"
|
||
>
|
||
<option value="">Alle</option>
|
||
<option value="unassigned">Nicht zugewiesen</option>
|
||
{users.map((user) => (
|
||
<option key={user.id} value={user.id}>
|
||
{user.name || user.email}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div className="filter-section">
|
||
<h3>Suche</h3>
|
||
<input
|
||
type="text"
|
||
placeholder="Nachricht oder Autor..."
|
||
value={filters.search}
|
||
onChange={(e) => setFilters((prev) => ({ ...prev, search: e.target.value }))}
|
||
className="filter-search"
|
||
/>
|
||
</div>
|
||
|
||
<button className="btn btn-reset" onClick={() => setFilters(DEFAULT_FILTERS)}>
|
||
Filter zurücksetzen
|
||
</button>
|
||
</aside>
|
||
|
||
{/* Interaction List */}
|
||
<div className="inbox-list">
|
||
{loading ? (
|
||
<div className="loading">
|
||
<div className="spinner" />
|
||
<p>Lade Interaktionen...</p>
|
||
</div>
|
||
) : interactions.length === 0 ? (
|
||
<div className="empty-state">
|
||
<p>Keine Interaktionen gefunden</p>
|
||
<button onClick={() => setFilters(DEFAULT_FILTERS)}>Filter zurücksetzen</button>
|
||
</div>
|
||
) : (
|
||
<ul className="interaction-list" ref={listRef}>
|
||
{interactions.map((interaction) => (
|
||
<li
|
||
key={interaction.id}
|
||
className={`interaction-item ${selectedInteraction?.id === interaction.id ? 'selected' : ''} ${interaction.status === 'new' ? 'unread' : ''}`}
|
||
onClick={() => selectInteraction(interaction)}
|
||
>
|
||
<div className="interaction-header">
|
||
<div className="interaction-meta">
|
||
<span
|
||
className="priority-indicator"
|
||
style={{ backgroundColor: getPriorityColor(interaction.priority) }}
|
||
title={interaction.priority}
|
||
/>
|
||
<span className="platform-icon" title={interaction.platform?.name}>
|
||
{interaction.platform?.icon || '📱'}
|
||
</span>
|
||
<span className="author-name">
|
||
{interaction.author?.name || 'Unknown'}
|
||
{interaction.author?.isVerified && ' ✓'}
|
||
</span>
|
||
{interaction.flags?.isFromInfluencer && (
|
||
<span className="badge badge-influencer">⭐</span>
|
||
)}
|
||
</div>
|
||
<span className="interaction-time">{formatDate(interaction.publishedAt)}</span>
|
||
</div>
|
||
|
||
<div className="interaction-content">
|
||
<p className="message-preview">
|
||
{interaction.message?.substring(0, 120)}
|
||
{interaction.message?.length > 120 && '...'}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="interaction-footer">
|
||
<div className="interaction-badges">
|
||
{interaction.analysis?.sentiment && (
|
||
<span className="badge badge-sentiment">
|
||
{getSentimentEmoji(interaction.analysis.sentiment)}
|
||
</span>
|
||
)}
|
||
{interaction.flags?.isMedicalQuestion && (
|
||
<span className="badge badge-medical">⚕️</span>
|
||
)}
|
||
{interaction.flags?.requiresEscalation && (
|
||
<span className="badge badge-escalation">🚨</span>
|
||
)}
|
||
<span
|
||
className="badge badge-status"
|
||
style={{ backgroundColor: getStatusBadge(interaction.status).color }}
|
||
>
|
||
{getStatusBadge(interaction.status).label}
|
||
</span>
|
||
</div>
|
||
{interaction.linkedContent && (
|
||
<span className="linked-content" title={interaction.linkedContent.title}>
|
||
📺
|
||
</span>
|
||
)}
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
|
||
{/* Detail Panel */}
|
||
<div className="inbox-detail">
|
||
{selectedInteraction ? (
|
||
<>
|
||
{/* Author Info */}
|
||
<div className="detail-author">
|
||
{selectedInteraction.author?.avatarUrl && (
|
||
<img
|
||
src={selectedInteraction.author.avatarUrl}
|
||
alt=""
|
||
className="author-avatar"
|
||
/>
|
||
)}
|
||
<div className="author-details">
|
||
<h3>
|
||
{selectedInteraction.author?.name}
|
||
{selectedInteraction.author?.isVerified && (
|
||
<span className="verified-badge">✓</span>
|
||
)}
|
||
</h3>
|
||
<p>
|
||
{selectedInteraction.author?.handle}
|
||
{selectedInteraction.author?.subscriberCount > 0 && (
|
||
<span className="subscriber-count">
|
||
• {selectedInteraction.author.subscriberCount.toLocaleString()} Follower
|
||
</span>
|
||
)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Quick Actions */}
|
||
<div className="detail-actions">
|
||
<select
|
||
value={selectedInteraction.status}
|
||
onChange={(e) => updateStatus(selectedInteraction.id, e.target.value)}
|
||
>
|
||
<option value="new">🆕 Neu</option>
|
||
<option value="in_review">👁️ In Review</option>
|
||
<option value="waiting">⏳ Warten auf Info</option>
|
||
<option value="replied">✅ Beantwortet</option>
|
||
<option value="resolved">✔️ Erledigt</option>
|
||
<option value="archived">📦 Archiviert</option>
|
||
<option value="spam">🚫 Spam</option>
|
||
</select>
|
||
<select
|
||
value={String(selectedInteraction.assignedTo?.id || '')}
|
||
onChange={(e) => assignTo(selectedInteraction.id, e.target.value)}
|
||
>
|
||
<option value="">Nicht zugewiesen</option>
|
||
{users.map((user) => (
|
||
<option key={user.id} value={user.id}>
|
||
{user.name || user.email}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{/* Alerts */}
|
||
{(selectedInteraction.flags?.isMedicalQuestion ||
|
||
selectedInteraction.flags?.requiresEscalation) && (
|
||
<div className="detail-alerts">
|
||
{selectedInteraction.flags.isMedicalQuestion && (
|
||
<div className="alert alert-medical">
|
||
⚕️ Medizinische Frage – Auf Hotline verweisen!
|
||
</div>
|
||
)}
|
||
{selectedInteraction.flags.requiresEscalation && (
|
||
<div className="alert alert-escalation">🚨 Eskalation erforderlich</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Linked Content */}
|
||
{selectedInteraction.linkedContent && (
|
||
<a
|
||
href={`https://youtube.com/watch?v=${selectedInteraction.linkedContent.youtubeVideoId}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="linked-video"
|
||
>
|
||
📺 {selectedInteraction.linkedContent.title}
|
||
</a>
|
||
)}
|
||
|
||
{/* Message */}
|
||
<div className="detail-message">
|
||
<p>{selectedInteraction.message}</p>
|
||
<div className="message-meta">
|
||
<span>{formatDate(selectedInteraction.publishedAt)}</span>
|
||
<span>❤️ {selectedInteraction.engagement?.likes || 0}</span>
|
||
<span>💬 {selectedInteraction.engagement?.replies || 0}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* AI Analysis */}
|
||
{selectedInteraction.analysis && (
|
||
<div className="detail-analysis">
|
||
<h4>🤖 KI-Analyse</h4>
|
||
<div className="analysis-grid">
|
||
<div className="analysis-item">
|
||
<span className="label">Sentiment</span>
|
||
<span className="value">
|
||
{getSentimentEmoji(selectedInteraction.analysis.sentiment)}{' '}
|
||
{selectedInteraction.analysis.sentiment}
|
||
</span>
|
||
</div>
|
||
<div className="analysis-item">
|
||
<span className="label">Score</span>
|
||
<span className="value">{selectedInteraction.analysis.sentimentScore}</span>
|
||
</div>
|
||
<div className="analysis-item">
|
||
<span className="label">Konfidenz</span>
|
||
<span className="value">
|
||
{Math.round(selectedInteraction.analysis.confidence * 100)}%
|
||
</span>
|
||
</div>
|
||
</div>
|
||
{selectedInteraction.analysis.topics?.length > 0 && (
|
||
<div className="analysis-topics">
|
||
<span className="label">Themen:</span>
|
||
<div className="topics-list">
|
||
{selectedInteraction.analysis.topics.map((t, i) => (
|
||
<span key={i} className="topic-tag">
|
||
{t.topic}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Previous Response */}
|
||
{selectedInteraction.response?.text && (
|
||
<div className="detail-response">
|
||
<h4>✅ Unsere Antwort</h4>
|
||
<p>{selectedInteraction.response.text}</p>
|
||
<small>
|
||
Gesendet am {formatDate(selectedInteraction.response.sentAt)} von{' '}
|
||
{selectedInteraction.response.sentBy?.email}
|
||
</small>
|
||
</div>
|
||
)}
|
||
|
||
{/* Reply Section */}
|
||
{!['resolved', 'archived', 'spam'].includes(selectedInteraction.status) &&
|
||
!selectedInteraction.response?.text && (
|
||
<div className="detail-reply">
|
||
<div className="reply-toolbar">
|
||
<select
|
||
onChange={(e) => {
|
||
const template = templates.find((t) => t.id === Number(e.target.value))
|
||
if (template) applyTemplate(template)
|
||
}}
|
||
defaultValue=""
|
||
>
|
||
<option value="">📝 Template auswählen...</option>
|
||
{templates.map((template) => (
|
||
<option key={template.id} value={template.id}>
|
||
{template.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<div className="ai-buttons">
|
||
<button
|
||
className="btn btn-ai"
|
||
onClick={() => generateAIReply(false)}
|
||
disabled={isGeneratingReply}
|
||
>
|
||
{isGeneratingReply ? '...' : '🤖 KI-Antwort'}
|
||
</button>
|
||
<button
|
||
className="btn btn-ai-variants"
|
||
onClick={() => generateAIReply(true)}
|
||
disabled={isGeneratingReply}
|
||
title="3 Varianten generieren"
|
||
>
|
||
{isGeneratingReply ? '...' : '🤖 3 Varianten'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* AI Generated Replies */}
|
||
{generatedReplies.length > 0 && (
|
||
<div className="ai-reply-suggestions">
|
||
<h5>🤖 KI-Antwortvorschläge</h5>
|
||
<div className="suggestions-grid">
|
||
{generatedReplies.map((reply, index) => (
|
||
<div key={index} className="suggestion-card">
|
||
{reply.tone && (
|
||
<span className="suggestion-tone">{reply.tone}</span>
|
||
)}
|
||
<p className="suggestion-text">{reply.text}</p>
|
||
{reply.warnings?.length > 0 && (
|
||
<div className="suggestion-warnings">
|
||
{reply.warnings.map((w, i) => (
|
||
<span key={i} className="warning-tag">⚠️ {w}</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
<button
|
||
className="btn btn-use-suggestion"
|
||
onClick={() => applyGeneratedReply(reply)}
|
||
>
|
||
Übernehmen
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<button
|
||
className="btn btn-clear-suggestions"
|
||
onClick={() => setGeneratedReplies([])}
|
||
>
|
||
Vorschläge ausblenden
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<textarea
|
||
value={replyText}
|
||
onChange={(e) => setReplyText(e.target.value)}
|
||
placeholder="Antwort schreiben..."
|
||
rows={4}
|
||
className="reply-textarea"
|
||
/>
|
||
|
||
<div className="reply-actions">
|
||
<span className="char-count">{replyText.length} Zeichen</span>
|
||
<button
|
||
className="btn btn-primary"
|
||
onClick={sendReply}
|
||
disabled={!replyText.trim() || sendingReply}
|
||
>
|
||
{sendingReply ? 'Sende...' : '📤 Antworten'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
) : (
|
||
<div className="no-selection">
|
||
<p>Wähle eine Interaktion aus der Liste</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Export Modal */}
|
||
{showExportModal && (
|
||
<div className="modal-overlay" onClick={() => setShowExportModal(false)}>
|
||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||
<div className="modal-header">
|
||
<h2>📊 Report exportieren</h2>
|
||
<button className="modal-close" onClick={() => setShowExportModal(false)}>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
<div className="modal-body">
|
||
<div className="form-group">
|
||
<label>Format</label>
|
||
<div className="format-options">
|
||
{(['excel', 'csv', 'pdf'] as const).map((format) => (
|
||
<label
|
||
key={format}
|
||
className={`format-option ${exportFormat === format ? 'selected' : ''}`}
|
||
>
|
||
<input
|
||
type="radio"
|
||
name="format"
|
||
value={format}
|
||
checked={exportFormat === format}
|
||
onChange={() => setExportFormat(format)}
|
||
/>
|
||
<span className="format-icon">
|
||
{format === 'excel' ? '📗' : format === 'csv' ? '📄' : '📕'}
|
||
</span>
|
||
<span>{format === 'excel' ? 'Excel (.xlsx)' : format.toUpperCase()}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label>Zeitraum</label>
|
||
<div className="date-inputs">
|
||
<div className="date-input">
|
||
<span>Von</span>
|
||
<input
|
||
type="date"
|
||
value={exportDateRange.from}
|
||
onChange={(e) =>
|
||
setExportDateRange((prev) => ({ ...prev, from: e.target.value }))
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="date-input">
|
||
<span>Bis</span>
|
||
<input
|
||
type="date"
|
||
value={exportDateRange.to}
|
||
onChange={(e) =>
|
||
setExportDateRange((prev) => ({ ...prev, to: e.target.value }))
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label>Optionen</label>
|
||
<div className="export-info">
|
||
<p>Der Export enthält:</p>
|
||
<ul>
|
||
<li>Alle Interaktionen im gewählten Zeitraum</li>
|
||
<li>Aktuelle Filter werden berücksichtigt</li>
|
||
<li>Autor-Informationen, Sentiment, Status</li>
|
||
<li>Response-Daten (falls vorhanden)</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="modal-footer">
|
||
<button className="btn" onClick={() => setShowExportModal(false)}>
|
||
Abbrechen
|
||
</button>
|
||
<button className="btn btn-primary" onClick={handleExport} disabled={exporting}>
|
||
{exporting ? '⏳ Exportiere...' : '📥 Exportieren'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|