mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 22:04:10 +00:00
- Add .claude/ configuration (agents, commands, hooks, get-shit-done workflows) - Add prompts/ directory with development planning documents - Add scripts/setup-tenants/ with tenant configuration - Add docs/screenshots/ - Remove obsolete phase2.2-corrections-report.md - Update pnpm-lock.yaml - Update detect-secrets.sh to ignore setup.sh (env var usage, not secrets) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
119 KiB
119 KiB
Phase 1 Completion – Integration Prompt (Extended)
Übersicht
Dieser Prompt vervollständigt Phase 1 des Community Management Systems mit:
- Community Inbox View – Custom Admin View für tägliches Community Management
- Mobile-Optimierte Inbox – Vollständig responsive für Smartphone-Nutzung
- YouTube OAuth Flow – Authentifizierung + Token Management
- Auto-Sync Cron Job – Automatischer Kommentar-Import
- Rules Engine – Automatische Regel-Ausführung bei neuen Interaktionen
- Export-Funktion – PDF/Excel Reports für Analysen
Geschätzter Aufwand: 7-9 Tage
Teil 1: Community Inbox View (Mobile-Optimiert)
1.1 View Registration
// src/app/(payload)/admin/views/community/inbox/page.tsx
import React from 'react'
import { DefaultTemplate } from '@payloadcms/next/templates'
import { Gutter } from '@payloadcms/ui'
import { CommunityInbox } from './CommunityInbox'
import './inbox.scss'
export const metadata = {
title: 'Community Inbox',
description: 'Manage community interactions across all platforms',
}
export default function CommunityInboxPage() {
return (
<DefaultTemplate>
<Gutter>
<CommunityInbox />
</Gutter>
</DefaultTemplate>
)
}
1.2 Main Component (mit Mobile Support)
// src/app/(payload)/admin/views/community/inbox/CommunityInbox.tsx
'use client'
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useConfig } from '@payloadcms/ui'
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 = () => {
const { config } = useConfig()
// 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')
const [showMobileFilters, setShowMobileFilters] = useState(false)
// Export state
const [showExportModal, setShowExportModal] = useState(false)
const [exportFormat, setExportFormat] = useState<'pdf' | 'excel' | 'csv'>('excel')
const [exportDateRange, setExportDateRange] = useState({ from: '', to: '' })
// Refs
const listRef = useRef<HTMLUListElement>(null)
const detailRef = useRef<HTMLDivElement>(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])
// Sync comments
const handleSync = async () => {
setSyncing(true)
try {
const response = await fetch('/api/community/sync-comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ analyzeWithAI: true }),
})
if (response.ok) {
const result = await response.json()
if (isMobile) {
alert(`✅ ${result.created} neue, ${result.updated} aktualisiert`)
} else {
alert(`Sync complete: ${result.created} new, ${result.updated} updated`)
}
loadInteractions()
} else {
throw new 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)
}
// Include current filters
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')
}
// Download file
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()
if (isMobile) {
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 () => {
if (!selectedInteraction) return
try {
const response = await fetch('/api/community/generate-reply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
interactionId: selectedInteraction.id,
}),
})
if (response.ok) {
const data = await response.json()
setReplyText(data.reply)
}
} catch (error) {
console.error('AI generation error:', error)
}
}
// Select interaction (mobile-aware)
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) => {
// Status-Werte gemäß CommunityInteractions.ts
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' })
}
// Active filters count
const activeFiltersCount = [
filters.status.length !== 3 ? 1 : 0, // Default is 3
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">
<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>
{mobileView === 'filters' && (
<button
className="btn-text"
onClick={() => {
setFilters(DEFAULT_FILTERS)
setMobileView('list')
}}
>
Reset
</button>
)}
</>
)}
</div>
{/* Mobile Stats Bar */}
{mobileView === 'list' && (
<div className="mobile-stats">
<span className="stat">{stats.new} <small>neu</small></span>
<span className="stat urgent">{stats.urgent} <small>urgent</small></span>
<span className="stat medical">{stats.medical} <small>med.</small></span>
</div>
)}
{/* Mobile Search */}
{mobileView === 'list' && (
<div className="mobile-search">
<input
type="search"
placeholder="🔍 Suchen..."
value={filters.search}
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
/>
</div>
)}
{/* Mobile Filters View */}
{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' : ''}`}
style={{
borderColor: filters.priority.includes(priority) ? getPriorityColor(priority) : undefined,
backgroundColor: filters.priority.includes(priority) ? getPriorityColor(priority) + '20' : undefined,
}}
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']
}))
}}
>
⚕️ Medical
</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>
<div className="filter-section">
<h3>Zugewiesen an</h3>
<select
value={filters.assignedTo}
onChange={(e) => setFilters(prev => ({ ...prev, assignedTo: e.target.value }))}
className="mobile-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>
<button
className="btn btn-primary btn-full"
onClick={() => setMobileView('list')}
>
Filter anwenden ({interactions.length} Ergebnisse)
</button>
</div>
)}
{/* Mobile List View */}
{mobileView === 'list' && (
<div className="mobile-list">
{loading ? (
<div className="loading">
<div className="spinner" />
</div>
) : interactions.length === 0 ? (
<div className="empty-state">
<p>Keine Interaktionen</p>
<button onClick={() => setFilters(DEFAULT_FILTERS)}>
Filter zurücksetzen
</button>
</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>
)}
{interaction.flags?.isFromInfluencer && (
<span className="badge">⭐</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">
{/* Author Card */}
<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>
{/* Quick Actions */}
<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={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>
{/* Flags Alert */}
{(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>
)}
{/* Context */}
{selectedInteraction.linkedContent && (
<a
href={`https://youtube.com/watch?v=${selectedInteraction.linkedContent.youtubeVideoId}`}
target="_blank"
rel="noopener noreferrer"
className="context-link"
>
📺 {selectedInteraction.linkedContent.title}
</a>
)}
{/* Message */}
<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>
{/* AI Analysis */}
{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>
)}
{/* Previous Response */}
{selectedInteraction.response?.text && (
<div className="response-card">
<h4>✅ Unsere Antwort</h4>
<p>{selectedInteraction.response.text}</p>
<small>
{formatDate(selectedInteraction.response.sentAt)}
</small>
</div>
)}
{/* Reply Section */}
{!['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}
>
🤖 KI
</button>
</div>
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="Antwort schreiben..."
rows={4}
/>
<button
className="btn btn-primary btn-full"
onClick={sendReply}
disabled={!replyText.trim() || sendingReply}
>
{sendingReply ? '⏳ Sende...' : '📤 Antworten'}
</button>
</div>
)}
</div>
)}
{/* Export Modal */}
{showExportModal && (
<div className="mobile-modal-overlay" onClick={() => setShowExportModal(false)}>
<div className="mobile-modal" onClick={(e) => e.stopPropagation()}>
<h3>📊 Report exportieren</h3>
<div className="modal-field">
<label>Format</label>
<div className="format-buttons">
<button
className={exportFormat === 'excel' ? 'active' : ''}
onClick={() => setExportFormat('excel')}
>
📗 Excel
</button>
<button
className={exportFormat === 'csv' ? 'active' : ''}
onClick={() => setExportFormat('csv')}
>
📄 CSV
</button>
<button
className={exportFormat === 'pdf' ? 'active' : ''}
onClick={() => setExportFormat('pdf')}
>
📕 PDF
</button>
</div>
</div>
<div className="modal-field">
<label>Zeitraum</label>
<input
type="date"
value={exportDateRange.from}
onChange={(e) => setExportDateRange(prev => ({ ...prev, from: e.target.value }))}
placeholder="Von"
/>
<input
type="date"
value={exportDateRange.to}
onChange={(e) => setExportDateRange(prev => ({ ...prev, to: e.target.value }))}
placeholder="Bis"
/>
</div>
<div className="modal-actions">
<button onClick={() => setShowExportModal(false)}>
Abbrechen
</button>
<button
className="btn-primary"
onClick={handleExport}
disabled={exporting}
>
{exporting ? '⏳' : '📥'} Exportieren
</button>
</div>
</div>
</div>
)}
</div>
)
}
// ============================================
// DESKTOP RENDER
// ============================================
return (
<div className="community-inbox desktop">
{/* Header */}
<div className="inbox-header">
<div className="inbox-title">
<h1>📬 Community Inbox</h1>
<div className="inbox-stats">
<span className="stat">
<strong>{stats.total}</strong> Gesamt
</span>
<span className="stat stat-new">
<strong>{stats.new}</strong> Neu
</span>
<span className="stat stat-urgent">
<strong>{stats.urgent}</strong> Urgent
</span>
<span className="stat stat-medical">
<strong>{stats.medical}</strong> Medical
</span>
</div>
</div>
<div className="inbox-actions">
<button
className="btn btn-export"
onClick={() => setShowExportModal(true)}
>
📊 Export
</button>
<button
className="btn btn-sync"
onClick={handleSync}
disabled={syncing}
>
{syncing ? '🔄 Syncing...' : '🔄 Sync Comments'}
</button>
</div>
</div>
<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.charAt(0).toUpperCase() + priority.slice(1)}
</label>
))}
</div>
</div>
<div className="filter-section">
<h3>Plattform</h3>
<div className="filter-options">
{platforms.map(platform => (
<label key={platform.id} className="filter-checkbox">
<input
type="checkbox"
checked={filters.platform.includes(String(platform.id))}
onChange={(e) => {
setFilters(prev => ({
...prev,
platform: e.target.checked
? [...prev.platform, String(platform.id)]
: prev.platform.filter(p => p !== String(platform.id))
}))
}}
/>
{platform.icon} {platform.name}
</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>Zeitraum</h3>
<div className="date-range">
<input
type="date"
value={filters.dateFrom}
onChange={(e) => setFilters(prev => ({ ...prev, dateFrom: e.target.value }))}
placeholder="Von"
/>
<input
type="date"
value={filters.dateTo}
onChange={(e) => setFilters(prev => ({ ...prev, dateTo: e.target.value }))}
placeholder="Bis"
/>
</div>
</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="video-reference">
📺 {interaction.linkedContent.title?.substring(0, 30)}...
</span>
)}
</div>
</li>
))}
</ul>
)}
</div>
{/* Detail Panel */}
<div className="inbox-detail" ref={detailRef}>
{selectedInteraction ? (
<>
<div className="detail-header">
<div className="author-info">
{selectedInteraction.author?.avatarUrl && (
<img
src={selectedInteraction.author.avatarUrl}
alt={selectedInteraction.author.name}
className="author-avatar"
/>
)}
<div className="author-details">
<h3>
{selectedInteraction.author?.name}
{selectedInteraction.author?.isVerified && (
<span className="verified-badge">✓</span>
)}
</h3>
<p className="author-handle">
{selectedInteraction.author?.handle}
{selectedInteraction.author?.isSubscriber && ' • Subscriber'}
{selectedInteraction.author?.subscriberCount > 0 && (
` • ${selectedInteraction.author.subscriberCount.toLocaleString()} Follower`
)}
</p>
</div>
</div>
<div className="detail-actions">
<select
value={selectedInteraction.status}
onChange={(e) => updateStatus(selectedInteraction.id, e.target.value)}
className="status-select"
>
<option value="new">🆕 Neu</option>
<option value="read">👁️ Gelesen</option>
<option value="in_progress">⏳ In Arbeit</option>
<option value="awaiting">⏸️ Wartend</option>
<option value="responded">✅ Beantwortet</option>
<option value="closed">📦 Geschlossen</option>
</select>
<select
value={selectedInteraction.assignedTo?.id || ''}
onChange={(e) => assignTo(selectedInteraction.id, e.target.value)}
className="assign-select"
>
<option value="">Nicht zugewiesen</option>
{users.map(user => (
<option key={user.id} value={user.id}>
{user.name || user.email}
</option>
))}
</select>
</div>
</div>
{/* Flags Alert */}
{(selectedInteraction.flags?.isMedicalQuestion ||
selectedInteraction.flags?.requiresEscalation) && (
<div className="flags-alert">
{selectedInteraction.flags.isMedicalQuestion && (
<div className="alert alert-medical">
⚕️ <strong>Medizinische Frage</strong> – Keine medizinischen Ratschläge geben, auf Hotline verweisen!
</div>
)}
{selectedInteraction.flags.requiresEscalation && (
<div className="alert alert-escalation">
🚨 <strong>Eskalation erforderlich</strong> – Priorisierte Bearbeitung notwendig
</div>
)}
</div>
)}
{/* Context */}
{selectedInteraction.linkedContent && (
<div className="context-info">
<h4>📺 Kontext</h4>
<p>
<a
href={`https://youtube.com/watch?v=${selectedInteraction.linkedContent.youtubeVideoId}`}
target="_blank"
rel="noopener noreferrer"
>
{selectedInteraction.linkedContent.title}
</a>
</p>
</div>
)}
{/* Message */}
<div className="message-full">
<h4>Nachricht</h4>
<div className="message-content">
{selectedInteraction.message}
</div>
<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="analysis-section">
<h4>🤖 KI-Analyse</h4>
<div className="analysis-grid">
<div className="analysis-item">
<label>Sentiment</label>
<span>
{getSentimentEmoji(selectedInteraction.analysis.sentiment)}
{' '}{selectedInteraction.analysis.sentiment}
{selectedInteraction.analysis.confidence && (
<small> ({selectedInteraction.analysis.confidence}%)</small>
)}
</span>
</div>
{selectedInteraction.analysis.topics?.length > 0 && (
<div className="analysis-item">
<label>Themen</label>
<div className="topic-tags">
{selectedInteraction.analysis.topics.map((t, i) => (
<span key={i} className="topic-tag">{t.topic}</span>
))}
</div>
</div>
)}
</div>
{selectedInteraction.analysis.suggestedReply && (
<div className="suggested-reply">
<label>Vorgeschlagene Antwort</label>
<p>{selectedInteraction.analysis.suggestedReply}</p>
<button
className="btn btn-small"
onClick={() => setReplyText(selectedInteraction.analysis.suggestedReply)}
>
Übernehmen
</button>
</div>
)}
</div>
)}
{/* Previous Response */}
{selectedInteraction.response?.text && (
<div className="previous-response">
<h4>✅ Unsere Antwort</h4>
<div className="response-content">
{selectedInteraction.response.text}
</div>
<div className="response-meta">
Gesendet {formatDate(selectedInteraction.response.sentAt)}
{selectedInteraction.response.sentBy && (
<> von {selectedInteraction.response.sentBy.email}</>
)}
</div>
</div>
)}
{/* Reply Section */}
{!['resolved', 'archived', 'spam'].includes(selectedInteraction.status) && !selectedInteraction.response?.text && (
<div className="reply-section">
<h4>Antworten</h4>
{/* Template Picker */}
<div className="template-picker">
<label>Template:</label>
<select
onChange={(e) => {
const template = templates.find(t => t.id === Number(e.target.value))
if (template) applyTemplate(template)
}}
defaultValue=""
>
<option value="">Template wählen...</option>
{templates.map(template => (
<option key={template.id} value={template.id}>
{template.name}
</option>
))}
</select>
<button
className="btn btn-ai"
onClick={generateAIReply}
title="KI-Antwort generieren"
>
🤖 KI-Antwort
</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">
<label className={`format-option ${exportFormat === 'excel' ? 'selected' : ''}`}>
<input
type="radio"
name="format"
value="excel"
checked={exportFormat === 'excel'}
onChange={() => setExportFormat('excel')}
/>
<span className="format-icon">📗</span>
<span>Excel (.xlsx)</span>
</label>
<label className={`format-option ${exportFormat === 'csv' ? 'selected' : ''}`}>
<input
type="radio"
name="format"
value="csv"
checked={exportFormat === 'csv'}
onChange={() => setExportFormat('csv')}
/>
<span className="format-icon">📄</span>
<span>CSV</span>
</label>
<label className={`format-option ${exportFormat === 'pdf' ? 'selected' : ''}`}>
<input
type="radio"
name="format"
value="pdf"
checked={exportFormat === 'pdf'}
onChange={() => setExportFormat('pdf')}
/>
<span className="format-icon">📕</span>
<span>PDF</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>
)
}
1.3 Styles (Mobile + Desktop)
// src/app/(payload)/admin/views/community/inbox/inbox.scss
.community-inbox {
--color-urgent: #dc2626;
--color-high: #f97316;
--color-normal: #3b82f6;
--color-low: #22c55e;
--color-medical: #8b5cf6;
--color-border: #e5e7eb;
--color-bg-hover: #f9fafb;
--color-bg-selected: #eff6ff;
min-height: calc(100vh - 120px);
}
// ============================================
// DESKTOP STYLES
// ============================================
.community-inbox.desktop {
// Header
.inbox-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 0;
border-bottom: 1px solid var(--color-border);
margin-bottom: 1rem;
h1 {
font-size: 1.5rem;
margin: 0;
}
.inbox-stats {
display: flex;
gap: 1.5rem;
margin-left: 2rem;
.stat {
font-size: 0.875rem;
color: #6b7280;
strong {
font-size: 1.125rem;
margin-right: 0.25rem;
}
&.stat-new strong { color: var(--color-normal); }
&.stat-urgent strong { color: var(--color-urgent); }
&.stat-medical strong { color: var(--color-medical); }
}
}
}
.inbox-actions {
display: flex;
gap: 0.5rem;
}
// Layout
.inbox-layout {
display: grid;
grid-template-columns: 240px 1fr 400px;
gap: 1rem;
height: calc(100vh - 200px);
@media (max-width: 1400px) {
grid-template-columns: 200px 1fr 350px;
}
@media (max-width: 1200px) {
grid-template-columns: 1fr 350px;
.inbox-sidebar {
display: none;
}
}
}
// Sidebar
.inbox-sidebar {
background: #fff;
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 1rem;
overflow-y: auto;
.filter-section {
margin-bottom: 1.5rem;
h3 {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #6b7280;
margin-bottom: 0.5rem;
}
}
.filter-options {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.filter-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0;
font-size: 0.875rem;
cursor: pointer;
input {
cursor: pointer;
}
.status-dot,
.priority-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
}
.filter-select,
.filter-search {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: 4px;
font-size: 0.875rem;
}
.date-range {
display: flex;
flex-direction: column;
gap: 0.5rem;
input {
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: 4px;
font-size: 0.875rem;
}
}
.btn-reset {
width: 100%;
margin-top: 1rem;
}
}
// List
.inbox-list {
background: #fff;
border: 1px solid var(--color-border);
border-radius: 8px;
overflow-y: auto;
}
.interaction-list {
list-style: none;
margin: 0;
padding: 0;
}
.interaction-item {
padding: 1rem;
border-bottom: 1px solid var(--color-border);
cursor: pointer;
transition: background 0.15s;
&:hover {
background: var(--color-bg-hover);
}
&.selected {
background: var(--color-bg-selected);
border-left: 3px solid var(--color-normal);
}
&.unread {
background: #fefce8;
.author-name {
font-weight: 600;
}
}
}
.interaction-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.interaction-meta {
display: flex;
align-items: center;
gap: 0.5rem;
.priority-indicator {
width: 4px;
height: 16px;
border-radius: 2px;
}
.platform-icon {
font-size: 1rem;
}
.author-name {
font-size: 0.875rem;
}
}
.interaction-time {
font-size: 0.75rem;
color: #9ca3af;
}
.interaction-content {
.message-preview {
font-size: 0.875rem;
color: #4b5563;
margin: 0;
line-height: 1.4;
}
}
.interaction-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.5rem;
}
.interaction-badges {
display: flex;
gap: 0.25rem;
}
.badge {
display: inline-flex;
align-items: center;
padding: 0.125rem 0.375rem;
font-size: 0.625rem;
border-radius: 4px;
&.badge-status {
color: #fff;
}
&.badge-medical {
background: #f3e8ff;
color: var(--color-medical);
}
&.badge-escalation {
background: #fef2f2;
color: var(--color-urgent);
}
&.badge-influencer {
background: #fef3c7;
color: #d97706;
}
}
.video-reference {
font-size: 0.75rem;
color: #9ca3af;
}
// Detail Panel
.inbox-detail {
background: #fff;
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 1.5rem;
overflow-y: auto;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--color-border);
}
.author-info {
display: flex;
gap: 1rem;
.author-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
}
h3 {
margin: 0;
font-size: 1rem;
.verified-badge {
color: var(--color-normal);
margin-left: 0.25rem;
}
}
.author-handle {
font-size: 0.875rem;
color: #6b7280;
margin: 0;
}
}
.detail-actions {
display: flex;
gap: 0.5rem;
select {
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: 4px;
font-size: 0.875rem;
}
}
.flags-alert {
margin-bottom: 1rem;
.alert {
padding: 0.75rem 1rem;
border-radius: 6px;
font-size: 0.875rem;
margin-bottom: 0.5rem;
&.alert-medical {
background: #f3e8ff;
color: #6b21a8;
border-left: 4px solid var(--color-medical);
}
&.alert-escalation {
background: #fef2f2;
color: #991b1b;
border-left: 4px solid var(--color-urgent);
}
}
}
.context-info {
background: #f9fafb;
padding: 0.75rem 1rem;
border-radius: 6px;
margin-bottom: 1rem;
h4 {
font-size: 0.75rem;
color: #6b7280;
margin: 0 0 0.25rem;
}
a {
color: var(--color-normal);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
.message-full {
margin-bottom: 1.5rem;
h4 {
font-size: 0.75rem;
text-transform: uppercase;
color: #6b7280;
margin-bottom: 0.5rem;
}
.message-content {
background: #f9fafb;
padding: 1rem;
border-radius: 6px;
font-size: 0.9375rem;
line-height: 1.6;
white-space: pre-wrap;
}
.message-meta {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
font-size: 0.75rem;
color: #9ca3af;
}
}
.analysis-section {
background: #f0f9ff;
padding: 1rem;
border-radius: 6px;
margin-bottom: 1.5rem;
h4 {
font-size: 0.875rem;
margin: 0 0 0.75rem;
}
.analysis-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.analysis-item {
label {
display: block;
font-size: 0.75rem;
color: #6b7280;
margin-bottom: 0.25rem;
}
}
.topic-tags {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.topic-tag {
background: #dbeafe;
color: #1e40af;
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
}
.suggested-reply {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #bfdbfe;
label {
display: block;
font-size: 0.75rem;
color: #6b7280;
margin-bottom: 0.25rem;
}
p {
font-size: 0.875rem;
margin: 0 0 0.5rem;
}
}
}
.previous-response {
background: #f0fdf4;
padding: 1rem;
border-radius: 6px;
margin-bottom: 1.5rem;
h4 {
font-size: 0.875rem;
color: #166534;
margin: 0 0 0.5rem;
}
.response-content {
font-size: 0.875rem;
line-height: 1.5;
}
.response-meta {
margin-top: 0.5rem;
font-size: 0.75rem;
color: #6b7280;
}
}
.reply-section {
h4 {
font-size: 0.875rem;
margin: 0 0 0.75rem;
}
.template-picker {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
label {
font-size: 0.875rem;
color: #6b7280;
}
select {
flex: 1;
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: 4px;
}
}
.reply-textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--color-border);
border-radius: 6px;
font-size: 0.9375rem;
resize: vertical;
min-height: 100px;
&:focus {
outline: none;
border-color: var(--color-normal);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
}
.reply-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.75rem;
.char-count {
font-size: 0.75rem;
color: #9ca3af;
}
}
}
.no-selection {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #9ca3af;
}
}
// ============================================
// MOBILE STYLES
// ============================================
.community-inbox.mobile {
padding: 0;
background: #f9fafb;
min-height: 100vh;
// Mobile Header
.mobile-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: #fff;
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
z-index: 100;
h1 {
font-size: 1.25rem;
margin: 0;
}
.mobile-header-actions {
display: flex;
gap: 0.5rem;
}
.btn-icon {
position: relative;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: #f3f4f6;
border-radius: 8px;
font-size: 1.125rem;
cursor: pointer;
&:disabled {
opacity: 0.5;
}
.filter-badge {
position: absolute;
top: -4px;
right: -4px;
background: var(--color-urgent);
color: #fff;
font-size: 0.625rem;
width: 16px;
height: 16px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
}
.btn-back {
background: none;
border: none;
font-size: 1rem;
color: var(--color-normal);
cursor: pointer;
padding: 0.5rem;
}
.mobile-title {
font-weight: 600;
}
.btn-text {
background: none;
border: none;
color: var(--color-normal);
font-size: 0.875rem;
cursor: pointer;
}
}
// Mobile Stats
.mobile-stats {
display: flex;
justify-content: space-around;
padding: 0.75rem;
background: #fff;
border-bottom: 1px solid var(--color-border);
.stat {
text-align: center;
font-size: 1.125rem;
font-weight: 600;
small {
display: block;
font-size: 0.625rem;
font-weight: 400;
color: #6b7280;
text-transform: uppercase;
}
&.urgent { color: var(--color-urgent); }
&.medical { color: var(--color-medical); }
}
}
// Mobile Search
.mobile-search {
padding: 0.75rem;
background: #fff;
input {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid var(--color-border);
border-radius: 8px;
font-size: 1rem;
background: #f9fafb;
&:focus {
outline: none;
border-color: var(--color-normal);
background: #fff;
}
}
}
// Mobile Filters
.mobile-filters {
padding: 1rem;
background: #fff;
min-height: calc(100vh - 60px);
.filter-section {
margin-bottom: 1.5rem;
h3 {
font-size: 0.75rem;
text-transform: uppercase;
color: #6b7280;
margin-bottom: 0.75rem;
}
}
.filter-chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
.chip {
padding: 0.5rem 1rem;
border: 1px solid var(--color-border);
border-radius: 20px;
background: #fff;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.15s;
&.active {
background: var(--color-normal);
color: #fff;
border-color: var(--color-normal);
}
}
}
.mobile-select {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--color-border);
border-radius: 8px;
font-size: 1rem;
background: #fff;
}
.btn-full {
width: 100%;
margin-top: 1rem;
}
}
// Mobile List
.mobile-list {
min-height: calc(100vh - 200px);
ul {
list-style: none;
margin: 0;
padding: 0;
}
.mobile-item {
background: #fff;
margin-bottom: 1px;
&.unread {
background: #fffbeb;
}
.item-row {
display: flex;
align-items: stretch;
padding: 1rem;
gap: 0.75rem;
}
.priority-bar {
width: 4px;
border-radius: 2px;
flex-shrink: 0;
}
.item-content {
flex: 1;
min-width: 0;
}
.item-header {
display: flex;
justify-content: space-between;
margin-bottom: 0.25rem;
.author {
font-weight: 500;
font-size: 0.9375rem;
}
.time {
font-size: 0.75rem;
color: #9ca3af;
}
}
.message {
font-size: 0.875rem;
color: #4b5563;
margin: 0 0 0.5rem;
line-height: 1.4;
}
.item-badges {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
.badge {
padding: 0.125rem 0.375rem;
font-size: 0.625rem;
border-radius: 4px;
&.status {
color: #fff;
}
&.medical {
background: #f3e8ff;
}
&.urgent {
background: #fef2f2;
}
}
}
.chevron {
display: flex;
align-items: center;
color: #9ca3af;
font-size: 1.25rem;
}
}
}
// Mobile Detail
.mobile-detail {
padding: 1rem;
background: #f9fafb;
min-height: calc(100vh - 60px);
.author-card {
display: flex;
gap: 1rem;
padding: 1rem;
background: #fff;
border-radius: 12px;
margin-bottom: 1rem;
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
}
.author-info {
h3 {
margin: 0;
font-size: 1rem;
}
p {
margin: 0;
font-size: 0.75rem;
color: #6b7280;
}
}
}
.quick-actions {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
select {
flex: 1;
padding: 0.75rem;
border: 1px solid var(--color-border);
border-radius: 8px;
font-size: 0.875rem;
background: #fff;
}
}
.mobile-alerts {
margin-bottom: 1rem;
.alert {
padding: 0.75rem 1rem;
border-radius: 8px;
font-size: 0.875rem;
margin-bottom: 0.5rem;
&.medical {
background: #f3e8ff;
color: #6b21a8;
}
&.urgent {
background: #fef2f2;
color: #991b1b;
}
}
}
.context-link {
display: block;
padding: 0.75rem 1rem;
background: #f0f9ff;
border-radius: 8px;
color: var(--color-normal);
text-decoration: none;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.message-card {
background: #fff;
padding: 1rem;
border-radius: 12px;
margin-bottom: 1rem;
p {
margin: 0 0 0.75rem;
font-size: 0.9375rem;
line-height: 1.5;
white-space: pre-wrap;
}
.message-meta {
display: flex;
gap: 1rem;
font-size: 0.75rem;
color: #9ca3af;
}
}
.analysis-card {
background: #f0f9ff;
padding: 1rem;
border-radius: 12px;
margin-bottom: 1rem;
h4 {
margin: 0 0 0.75rem;
font-size: 0.875rem;
}
.analysis-row {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
font-size: 0.875rem;
.topics {
display: flex;
gap: 0.25rem;
.topic {
background: #dbeafe;
color: #1e40af;
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
}
}
}
}
.response-card {
background: #f0fdf4;
padding: 1rem;
border-radius: 12px;
margin-bottom: 1rem;
h4 {
margin: 0 0 0.5rem;
font-size: 0.875rem;
color: #166534;
}
p {
margin: 0;
font-size: 0.875rem;
}
small {
color: #6b7280;
}
}
.reply-section {
background: #fff;
padding: 1rem;
border-radius: 12px;
.reply-header {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
select {
flex: 1;
padding: 0.75rem;
border: 1px solid var(--color-border);
border-radius: 8px;
font-size: 0.875rem;
}
.btn-ai {
padding: 0.75rem 1rem;
background: #faf5ff;
border: 1px solid #e9d5ff;
border-radius: 8px;
color: #7c3aed;
font-size: 0.875rem;
cursor: pointer;
}
}
textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--color-border);
border-radius: 8px;
font-size: 1rem;
resize: vertical;
min-height: 100px;
margin-bottom: 0.75rem;
&:focus {
outline: none;
border-color: var(--color-normal);
}
}
.btn-full {
width: 100%;
}
}
}
// Mobile Modal
.mobile-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-end;
z-index: 1000;
}
.mobile-modal {
background: #fff;
width: 100%;
border-radius: 16px 16px 0 0;
padding: 1.5rem;
max-height: 80vh;
overflow-y: auto;
h3 {
margin: 0 0 1.5rem;
text-align: center;
}
.modal-field {
margin-bottom: 1.5rem;
label {
display: block;
font-size: 0.75rem;
text-transform: uppercase;
color: #6b7280;
margin-bottom: 0.5rem;
}
input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--color-border);
border-radius: 8px;
font-size: 1rem;
margin-bottom: 0.5rem;
}
}
.format-buttons {
display: flex;
gap: 0.5rem;
button {
flex: 1;
padding: 1rem;
border: 1px solid var(--color-border);
border-radius: 8px;
background: #fff;
font-size: 0.875rem;
cursor: pointer;
&.active {
background: var(--color-normal);
color: #fff;
border-color: var(--color-normal);
}
}
}
.modal-actions {
display: flex;
gap: 0.75rem;
margin-top: 1.5rem;
button {
flex: 1;
padding: 1rem;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
&:first-child {
background: #f3f4f6;
border: none;
}
&.btn-primary {
background: var(--color-normal);
color: #fff;
border: none;
}
}
}
}
}
// ============================================
// SHARED STYLES
// ============================================
// Buttons
.btn {
padding: 0.5rem 1rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: #fff;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.15s;
&:hover:not(:disabled) {
background: #f9fafb;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&.btn-primary {
background: var(--color-normal);
color: #fff;
border-color: var(--color-normal);
&:hover:not(:disabled) {
background: #2563eb;
}
}
&.btn-sync {
background: #f0f9ff;
border-color: #bfdbfe;
color: #1e40af;
}
&.btn-export {
background: #f0fdf4;
border-color: #bbf7d0;
color: #166534;
}
&.btn-ai {
background: #faf5ff;
border-color: #e9d5ff;
color: #7c3aed;
}
&.btn-small {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
}
// Loading
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
color: #9ca3af;
.spinner {
width: 32px;
height: 32px;
border: 3px solid #e5e7eb;
border-top-color: var(--color-normal);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
color: #9ca3af;
button {
margin-top: 1rem;
}
}
// Desktop Modal
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: #fff;
border-radius: 12px;
width: 100%;
max-width: 500px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid var(--color-border);
h2 {
margin: 0;
font-size: 1.25rem;
}
.modal-close {
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
color: #6b7280;
&:hover {
color: #1f2937;
}
}
}
.modal-body {
padding: 1.5rem;
.form-group {
margin-bottom: 1.5rem;
> label {
display: block;
font-weight: 500;
margin-bottom: 0.75rem;
}
}
.format-options {
display: flex;
gap: 1rem;
.format-option {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1rem;
border: 2px solid var(--color-border);
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
input {
display: none;
}
.format-icon {
font-size: 2rem;
}
&.selected {
border-color: var(--color-normal);
background: #f0f9ff;
}
&:hover {
border-color: var(--color-normal);
}
}
}
.date-inputs {
display: flex;
gap: 1rem;
.date-input {
flex: 1;
span {
display: block;
font-size: 0.75rem;
color: #6b7280;
margin-bottom: 0.25rem;
}
input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--color-border);
border-radius: 6px;
font-size: 0.875rem;
}
}
}
.export-info {
background: #f9fafb;
padding: 1rem;
border-radius: 6px;
font-size: 0.875rem;
p {
margin: 0 0 0.5rem;
font-weight: 500;
}
ul {
margin: 0;
padding-left: 1.25rem;
color: #6b7280;
li {
margin-bottom: 0.25rem;
}
}
}
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid var(--color-border);
background: #f9fafb;
border-radius: 0 0 12px 12px;
}
}
Teil 2: Export API Endpoint
2.1 Export Route Handler
// src/app/(payload)/api/community/export/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import ExcelJS from 'exceljs'
import PDFDocument from 'pdfkit'
export async function GET(req: NextRequest) {
try {
const searchParams = req.nextUrl.searchParams
const format = searchParams.get('format') || 'excel'
const dateFrom = searchParams.get('dateFrom')
const dateTo = searchParams.get('dateTo')
const status = searchParams.get('status')?.split(',')
const platform = searchParams.get('platform')?.split(',')
const sentiment = searchParams.get('sentiment')?.split(',')
const payload = await getPayload({ config })
// Build query
const where: any = {}
if (dateFrom || dateTo) {
where.publishedAt = {}
if (dateFrom) where.publishedAt.greater_than_equal = dateFrom
if (dateTo) where.publishedAt.less_than_equal = dateTo
}
if (status?.length) {
where.status = { in: status }
}
if (platform?.length) {
where.platform = { in: platform }
}
if (sentiment?.length) {
where['analysis.sentiment'] = { in: sentiment }
}
// Fetch interactions
const interactions = await payload.find({
collection: 'community-interactions',
where,
sort: '-publishedAt',
limit: 1000,
depth: 2,
})
// Generate export based on format
switch (format) {
case 'excel':
return generateExcel(interactions.docs)
case 'csv':
return generateCSV(interactions.docs)
case 'pdf':
return generatePDF(interactions.docs)
default:
return NextResponse.json({ error: 'Invalid format' }, { status: 400 })
}
} catch (error: any) {
console.error('Export error:', error)
return NextResponse.json({ error: error.message }, { status: 500 })
}
}
// Excel Export
async function generateExcel(interactions: any[]): Promise<NextResponse> {
const workbook = new ExcelJS.Workbook()
workbook.creator = 'Community Hub'
workbook.created = new Date()
// Main Data Sheet
const dataSheet = workbook.addWorksheet('Interactions', {
views: [{ state: 'frozen', ySplit: 1 }],
})
dataSheet.columns = [
{ header: 'ID', key: 'id', width: 8 },
{ header: 'Datum', key: 'date', width: 12 },
{ header: 'Plattform', key: 'platform', width: 12 },
{ header: 'Kanal', key: 'channel', width: 20 },
{ header: 'Autor', key: 'author', width: 20 },
{ header: 'Follower', key: 'followers', width: 12 },
{ header: 'Nachricht', key: 'message', width: 50 },
{ header: 'Sentiment', key: 'sentiment', width: 12 },
{ header: 'Score', key: 'score', width: 8 },
{ header: 'Status', key: 'status', width: 12 },
{ header: 'Priorität', key: 'priority', width: 10 },
{ header: 'Medical', key: 'medical', width: 8 },
{ header: 'Eskalation', key: 'escalation', width: 10 },
{ header: 'Influencer', key: 'influencer', width: 10 },
{ header: 'Zugewiesen', key: 'assigned', width: 20 },
{ header: 'Beantwortet', key: 'repliedAt', width: 12 },
{ header: 'Antwort', key: 'response', width: 50 },
{ header: 'Likes', key: 'likes', width: 8 },
{ header: 'Replies', key: 'replies', width: 8 },
]
// Style header row
dataSheet.getRow(1).font = { bold: true }
dataSheet.getRow(1).fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FF3B82F6' },
}
dataSheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } }
// Add data rows
for (const interaction of interactions) {
dataSheet.addRow({
id: interaction.id,
date: new Date(interaction.publishedAt).toLocaleDateString('de-DE'),
platform: interaction.platform?.name || '',
channel: interaction.socialAccount?.displayName || '',
author: interaction.author?.name || '',
followers: interaction.author?.subscriberCount || 0,
message: interaction.message?.substring(0, 500) || '',
sentiment: interaction.analysis?.sentiment || '',
score: interaction.analysis?.sentimentScore || 0,
status: interaction.status,
priority: interaction.priority,
medical: interaction.flags?.isMedicalQuestion ? 'Ja' : '',
escalation: interaction.flags?.requiresEscalation ? 'Ja' : '',
influencer: interaction.flags?.isFromInfluencer ? 'Ja' : '',
assigned: interaction.assignedTo?.email || '',
repliedAt: interaction.response?.sentAt
? new Date(interaction.response.sentAt).toLocaleDateString('de-DE')
: '',
response: interaction.response?.text?.substring(0, 500) || '',
likes: interaction.engagement?.likes || 0,
replies: interaction.engagement?.replies || 0,
})
}
// Conditional formatting for priority
dataSheet.eachRow((row, rowNumber) => {
if (rowNumber > 1) {
const priorityCell = row.getCell('priority')
if (priorityCell.value === 'urgent') {
row.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFFEF2F2' },
}
}
}
})
// Summary Sheet
const summarySheet = workbook.addWorksheet('Zusammenfassung')
const sentimentCounts = {
positive: interactions.filter(i => i.analysis?.sentiment === 'positive').length,
neutral: interactions.filter(i => i.analysis?.sentiment === 'neutral').length,
negative: interactions.filter(i => i.analysis?.sentiment === 'negative').length,
mixed: interactions.filter(i => i.analysis?.sentiment === 'mixed').length,
}
// Status-Werte gemäß CommunityInteractions.ts
const statusCounts = {
new: interactions.filter(i => i.status === 'new').length,
in_review: interactions.filter(i => i.status === 'in_review').length,
waiting: interactions.filter(i => i.status === 'waiting').length,
replied: interactions.filter(i => i.status === 'replied').length,
resolved: interactions.filter(i => i.status === 'resolved').length,
archived: interactions.filter(i => i.status === 'archived').length,
spam: interactions.filter(i => i.status === 'spam').length,
}
summarySheet.addRow(['Community Report'])
summarySheet.addRow(['Erstellt am', new Date().toLocaleString('de-DE')])
summarySheet.addRow(['Zeitraum', interactions.length > 0
? `${new Date(interactions[interactions.length - 1].publishedAt).toLocaleDateString('de-DE')} - ${new Date(interactions[0].publishedAt).toLocaleDateString('de-DE')}`
: 'N/A'
])
summarySheet.addRow([])
summarySheet.addRow(['Gesamt Interaktionen', interactions.length])
summarySheet.addRow([])
summarySheet.addRow(['Sentiment Verteilung'])
summarySheet.addRow(['Positiv', sentimentCounts.positive])
summarySheet.addRow(['Neutral', sentimentCounts.neutral])
summarySheet.addRow(['Negativ', sentimentCounts.negative])
summarySheet.addRow(['Gemischt', sentimentCounts.mixed])
summarySheet.addRow([])
summarySheet.addRow(['Status Verteilung'])
summarySheet.addRow(['Neu', statusCounts.new])
summarySheet.addRow(['In Review', statusCounts.in_review])
summarySheet.addRow(['Warten auf Info', statusCounts.waiting])
summarySheet.addRow(['Beantwortet', statusCounts.replied])
summarySheet.addRow(['Erledigt', statusCounts.resolved])
summarySheet.addRow(['Archiviert', statusCounts.archived])
summarySheet.addRow(['Spam', statusCounts.spam])
summarySheet.addRow([])
summarySheet.addRow(['Flags'])
summarySheet.addRow(['Medizinische Fragen', interactions.filter(i => i.flags?.isMedicalQuestion).length])
summarySheet.addRow(['Eskalationen', interactions.filter(i => i.flags?.requiresEscalation).length])
summarySheet.addRow(['Influencer', interactions.filter(i => i.flags?.isFromInfluencer).length])
// Style summary
summarySheet.getRow(1).font = { bold: true, size: 16 }
summarySheet.getColumn(1).width = 25
summarySheet.getColumn(2).width = 20
// Generate buffer
const buffer = await workbook.xlsx.writeBuffer()
return new NextResponse(buffer, {
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': `attachment; filename="community-report-${new Date().toISOString().split('T')[0]}.xlsx"`,
},
})
}
// CSV Export
async function generateCSV(interactions: any[]): Promise<NextResponse> {
const headers = [
'ID',
'Datum',
'Plattform',
'Kanal',
'Autor',
'Follower',
'Nachricht',
'Sentiment',
'Score',
'Status',
'Priorität',
'Medical',
'Eskalation',
'Influencer',
'Zugewiesen',
'Beantwortet',
'Antwort',
'Likes',
'Replies',
]
const rows = interactions.map(interaction => [
interaction.id,
new Date(interaction.publishedAt).toISOString(),
interaction.platform?.name || '',
interaction.socialAccount?.displayName || '',
interaction.author?.name || '',
interaction.author?.subscriberCount || 0,
`"${(interaction.message || '').replace(/"/g, '""').substring(0, 500)}"`,
interaction.analysis?.sentiment || '',
interaction.analysis?.sentimentScore || 0,
interaction.status,
interaction.priority,
interaction.flags?.isMedicalQuestion ? 'Ja' : '',
interaction.flags?.requiresEscalation ? 'Ja' : '',
interaction.flags?.isFromInfluencer ? 'Ja' : '',
interaction.assignedTo?.email || '',
interaction.response?.sentAt || '',
`"${(interaction.response?.text || '').replace(/"/g, '""').substring(0, 500)}"`,
interaction.engagement?.likes || 0,
interaction.engagement?.replies || 0,
])
const csv = [headers.join(';'), ...rows.map(row => row.join(';'))].join('\n')
// Add BOM for Excel compatibility with German characters
const bom = '\uFEFF'
return new NextResponse(bom + csv, {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="community-report-${new Date().toISOString().split('T')[0]}.csv"`,
},
})
}
// PDF Export
async function generatePDF(interactions: any[]): Promise<NextResponse> {
return new Promise((resolve, reject) => {
try {
const doc = new PDFDocument({
margin: 50,
size: 'A4',
})
const chunks: Buffer[] = []
doc.on('data', (chunk) => chunks.push(chunk))
doc.on('end', () => {
const buffer = Buffer.concat(chunks)
resolve(new NextResponse(buffer, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="community-report-${new Date().toISOString().split('T')[0]}.pdf"`,
},
}))
})
// Title
doc.fontSize(24).text('Community Report', { align: 'center' })
doc.moveDown()
doc.fontSize(12).text(`Erstellt am: ${new Date().toLocaleString('de-DE')}`, { align: 'center' })
doc.moveDown(2)
// Summary Stats
const sentimentCounts = {
positive: interactions.filter(i => i.analysis?.sentiment === 'positive').length,
negative: interactions.filter(i => i.analysis?.sentiment === 'negative').length,
neutral: interactions.filter(i => i.analysis?.sentiment === 'neutral').length,
}
// Status-Werte gemäß CommunityInteractions.ts
const statusCounts = {
new: interactions.filter(i => i.status === 'new').length,
replied: interactions.filter(i => i.status === 'replied').length,
resolved: interactions.filter(i => i.status === 'resolved').length,
}
doc.fontSize(16).text('Übersicht', { underline: true })
doc.moveDown()
doc.fontSize(12)
doc.text(`Gesamt Interaktionen: ${interactions.length}`)
doc.text(`Davon neu: ${statusCounts.new}`)
doc.text(`Davon beantwortet: ${statusCounts.replied}`)
doc.text(`Davon erledigt: ${statusCounts.resolved}`)
doc.moveDown()
doc.text(`Positives Sentiment: ${sentimentCounts.positive}`)
doc.text(`Neutrales Sentiment: ${sentimentCounts.neutral}`)
doc.text(`Negatives Sentiment: ${sentimentCounts.negative}`)
doc.moveDown()
// Flags
const medicalCount = interactions.filter(i => i.flags?.isMedicalQuestion).length
const escalationCount = interactions.filter(i => i.flags?.requiresEscalation).length
const influencerCount = interactions.filter(i => i.flags?.isFromInfluencer).length
doc.text(`Medizinische Fragen: ${medicalCount}`)
doc.text(`Eskalationen: ${escalationCount}`)
doc.text(`Von Influencern: ${influencerCount}`)
doc.moveDown(2)
// Recent interactions
doc.fontSize(16).text('Letzte 20 Interaktionen', { underline: true })
doc.moveDown()
const recentInteractions = interactions.slice(0, 20)
for (const interaction of recentInteractions) {
// Check if we need a new page
if (doc.y > 700) {
doc.addPage()
}
doc.fontSize(10)
.fillColor('#6b7280')
.text(
`${new Date(interaction.publishedAt).toLocaleDateString('de-DE')} | ${interaction.platform?.name || 'Unknown'} | ${interaction.author?.name || 'Unknown'}`,
{ continued: false }
)
doc.fontSize(11)
.fillColor('#000000')
.text(
interaction.message?.substring(0, 200) + (interaction.message?.length > 200 ? '...' : ''),
{ continued: false }
)
const badges = []
if (interaction.analysis?.sentiment) badges.push(interaction.analysis.sentiment)
if (interaction.flags?.isMedicalQuestion) badges.push('Medical')
if (interaction.flags?.requiresEscalation) badges.push('Eskalation')
doc.fontSize(9)
.fillColor('#3b82f6')
.text(`Status: ${interaction.status} | ${badges.join(' | ')}`)
doc.moveDown()
}
// Footer
const pageCount = doc.bufferedPageRange().count
for (let i = 0; i < pageCount; i++) {
doc.switchToPage(i)
doc.fontSize(8)
.fillColor('#9ca3af')
.text(
`Seite ${i + 1} von ${pageCount} | Community Hub Report`,
50,
doc.page.height - 50,
{ align: 'center' }
)
}
doc.end()
} catch (error) {
reject(error)
}
})
}
2.2 Package Dependencies
{
"dependencies": {
"exceljs": "^4.4.0",
"pdfkit": "^0.15.0"
},
"devDependencies": {
"@types/pdfkit": "^0.13.4"
}
}
Teil 3: YouTube OAuth Flow
// src/lib/integrations/youtube/oauth.ts
import { google } from 'googleapis'
const SCOPES = [
'https://www.googleapis.com/auth/youtube.readonly',
'https://www.googleapis.com/auth/youtube.force-ssl',
'https://www.googleapis.com/auth/youtube',
]
export function getOAuth2Client() {
return new google.auth.OAuth2(
process.env.YOUTUBE_CLIENT_ID,
process.env.YOUTUBE_CLIENT_SECRET,
process.env.YOUTUBE_REDIRECT_URI || `${process.env.NEXT_PUBLIC_SERVER_URL}/api/youtube/callback`
)
}
export function getAuthUrl(state?: string) {
const oauth2Client = getOAuth2Client()
return oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: SCOPES,
prompt: 'consent',
state: state,
})
}
export async function getTokensFromCode(code: string) {
const oauth2Client = getOAuth2Client()
const { tokens } = await oauth2Client.getToken(code)
return tokens
}
export async function refreshAccessToken(refreshToken: string) {
const oauth2Client = getOAuth2Client()
oauth2Client.setCredentials({ refresh_token: refreshToken })
const { credentials } = await oauth2Client.refreshAccessToken()
return credentials
}
// src/app/(payload)/api/youtube/auth/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getAuthUrl } from '@/lib/integrations/youtube/oauth'
export async function GET(req: NextRequest) {
const searchParams = req.nextUrl.searchParams
const socialAccountId = searchParams.get('socialAccountId')
if (!socialAccountId) {
return NextResponse.json(
{ error: 'socialAccountId query parameter required' },
{ status: 400 }
)
}
const state = Buffer.from(JSON.stringify({ socialAccountId })).toString('base64')
const authUrl = getAuthUrl(state)
return NextResponse.redirect(authUrl)
}
// src/app/(payload)/api/youtube/callback/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import { getTokensFromCode } from '@/lib/integrations/youtube/oauth'
import { google } from 'googleapis'
export async function GET(req: NextRequest) {
const searchParams = req.nextUrl.searchParams
const code = searchParams.get('code')
const state = searchParams.get('state')
const error = searchParams.get('error')
if (error) {
console.error('OAuth error:', error)
return NextResponse.redirect(
`${process.env.NEXT_PUBLIC_SERVER_URL}/admin/collections/social-accounts?error=${error}`
)
}
if (!code || !state) {
return NextResponse.redirect(
`${process.env.NEXT_PUBLIC_SERVER_URL}/admin/collections/social-accounts?error=missing_params`
)
}
try {
const { socialAccountId } = JSON.parse(Buffer.from(state, 'base64').toString())
const tokens = await getTokensFromCode(code)
const oauth2Client = new google.auth.OAuth2()
oauth2Client.setCredentials(tokens)
const youtube = google.youtube({ version: 'v3', auth: oauth2Client })
const channelResponse = await youtube.channels.list({
part: ['snippet', 'statistics'],
mine: true,
})
const channel = channelResponse.data.items?.[0]
const payload = await getPayload({ config })
await payload.update({
collection: 'social-accounts',
id: socialAccountId,
data: {
externalId: channel?.id,
accountHandle: channel?.snippet?.customUrl || `@${channel?.snippet?.title}`,
credentials: {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
tokenExpiresAt: tokens.expiry_date
? new Date(tokens.expiry_date).toISOString()
: null,
},
stats: {
followers: parseInt(channel?.statistics?.subscriberCount || '0'),
totalPosts: parseInt(channel?.statistics?.videoCount || '0'),
lastSyncedAt: new Date().toISOString(),
},
},
})
return NextResponse.redirect(
`${process.env.NEXT_PUBLIC_SERVER_URL}/admin/collections/social-accounts/${socialAccountId}?success=connected`
)
} catch (err: any) {
console.error('OAuth callback error:', err)
return NextResponse.redirect(
`${process.env.NEXT_PUBLIC_SERVER_URL}/admin/collections/social-accounts?error=${encodeURIComponent(err.message)}`
)
}
}
// src/app/(payload)/api/youtube/refresh-token/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import { refreshAccessToken } from '@/lib/integrations/youtube/oauth'
export async function POST(req: NextRequest) {
try {
const { socialAccountId } = await req.json()
const payload = await getPayload({ config })
const account = await payload.findByID({
collection: 'social-accounts',
id: socialAccountId,
})
if (!account?.credentials?.refreshToken) {
return NextResponse.json(
{ error: 'No refresh token available' },
{ status: 400 }
)
}
const credentials = await refreshAccessToken(account.credentials.refreshToken)
await payload.update({
collection: 'social-accounts',
id: socialAccountId,
data: {
credentials: {
accessToken: credentials.access_token,
refreshToken: credentials.refresh_token || account.credentials.refreshToken,
tokenExpiresAt: credentials.expiry_date
? new Date(credentials.expiry_date).toISOString()
: null,
},
},
})
return NextResponse.json({ success: true })
} catch (error: any) {
console.error('Token refresh error:', error)
return NextResponse.json({ error: error.message }, { status: 500 })
}
}
Teil 4: Auto-Sync Cron Job
// src/jobs/syncAllComments.ts
import { getPayload } from 'payload'
import config from '@payload-config'
import { CommentsSyncService } from '@/lib/integrations/youtube/CommentsSyncService'
import { refreshAccessToken } from '@/lib/integrations/youtube/oauth'
async function main() {
console.log('🔄 Starting scheduled comment sync...')
console.log(` Time: ${new Date().toISOString()}`)
const payload = await getPayload({ config })
const syncService = new CommentsSyncService(payload)
const accounts = await payload.find({
collection: 'social-accounts',
where: {
and: [
{ isActive: { equals: true } },
{ 'syncSettings.autoSyncEnabled': { equals: true } },
{ 'credentials.accessToken': { exists: true } },
],
},
limit: 100,
})
console.log(`📊 Found ${accounts.docs.length} accounts to sync`)
let totalNew = 0
let totalUpdated = 0
let errors: string[] = []
for (const account of accounts.docs) {
try {
console.log(`\n📺 Syncing: ${account.displayName}`)
const expiresAt = account.credentials?.tokenExpiresAt
if (expiresAt && new Date(expiresAt) < new Date()) {
console.log(' ⏰ Token expired, refreshing...')
try {
const newCredentials = await refreshAccessToken(account.credentials.refreshToken)
await payload.update({
collection: 'social-accounts',
id: account.id,
data: {
credentials: {
accessToken: newCredentials.access_token,
refreshToken: newCredentials.refresh_token || account.credentials.refreshToken,
tokenExpiresAt: newCredentials.expiry_date
? new Date(newCredentials.expiry_date).toISOString()
: null,
},
},
})
console.log(' ✅ Token refreshed')
} catch (refreshError) {
console.error(' ❌ Token refresh failed:', refreshError)
errors.push(`${account.displayName}: Token refresh failed`)
continue
}
}
const result = await syncService.syncCommentsForAccount(account.id, {
maxComments: 100,
analyzeWithAI: true,
})
totalNew += result.created || 0
totalUpdated += result.updated || 0
console.log(` ✅ New: ${result.created || 0}, Updated: ${result.updated || 0}`)
if (result.errors?.length > 0) {
errors.push(...result.errors.map((e: string) => `${account.displayName}: ${e}`))
}
} catch (accountError: any) {
console.error(` ❌ Failed: ${accountError.message}`)
errors.push(`${account.displayName}: ${accountError.message}`)
}
}
console.log('\n' + '='.repeat(50))
console.log('📊 SYNC SUMMARY')
console.log('='.repeat(50))
console.log(` New comments: ${totalNew}`)
console.log(` Updated: ${totalUpdated}`)
console.log(` Errors: ${errors.length}`)
if (errors.length > 0) {
console.log('\n⚠️ Errors:')
errors.forEach(e => console.log(` - ${e}`))
}
console.log('\n🏁 Sync complete!')
}
main()
.then(() => process.exit(0))
.catch((err) => {
console.error('Fatal error:', err)
process.exit(1)
})
Teil 5: Rules Engine
// src/lib/services/RulesEngine.ts
import type { Payload } from 'payload'
interface RuleAction {
action: string
value?: string
targetUser?: number | { id: number }
targetTemplate?: number | { id: number }
}
interface Rule {
id: number
name: string
priority: number
isActive: boolean
platforms?: any[]
channel?: any
trigger: {
type: string
keywords?: { keyword: string; matchType: string }[]
sentimentValues?: string[]
influencerMinFollowers?: number
}
actions: RuleAction[]
}
export class RulesEngine {
private payload: Payload
constructor(payload: Payload) {
this.payload = payload
}
async evaluateRules(interactionId: number): Promise<{
appliedRules: string[]
changes: Record<string, any>
}> {
const appliedRules: string[] = []
const changes: Record<string, any> = {}
const interaction = await this.payload.findByID({
collection: 'community-interactions',
id: interactionId,
depth: 2,
})
if (!interaction) {
throw new Error('Interaction not found')
}
const rules = await this.payload.find({
collection: 'community-rules',
where: {
isActive: { equals: true },
},
sort: 'priority',
limit: 100,
})
for (const rule of rules.docs as Rule[]) {
if (rule.platforms?.length > 0) {
const platformIds = rule.platforms.map((p: any) =>
typeof p === 'object' ? p.id : p
)
const interactionPlatformId = typeof interaction.platform === 'object'
? interaction.platform.id
: interaction.platform
if (!platformIds.includes(interactionPlatformId)) {
continue
}
}
const triggered = await this.checkTrigger(rule, interaction)
if (triggered) {
const ruleChanges = await this.applyActions(rule, interaction)
Object.assign(changes, ruleChanges)
appliedRules.push(rule.name)
await this.payload.update({
collection: 'community-rules',
id: rule.id,
data: {
stats: {
timesTriggered: (rule as any).stats?.timesTriggered + 1 || 1,
lastTriggeredAt: new Date().toISOString(),
},
},
})
}
}
if (Object.keys(changes).length > 0) {
await this.payload.update({
collection: 'community-interactions',
id: interactionId,
data: changes,
})
}
return { appliedRules, changes }
}
private async checkTrigger(rule: Rule, interaction: any): Promise<boolean> {
const { trigger } = rule
const message = interaction.message?.toLowerCase() || ''
switch (trigger.type) {
case 'keyword':
if (!trigger.keywords?.length) return false
return trigger.keywords.some(({ keyword, matchType }) => {
const kw = keyword.toLowerCase()
switch (matchType) {
case 'exact':
return message === kw
case 'regex':
try {
return new RegExp(keyword, 'i').test(interaction.message)
} catch {
return false
}
case 'contains':
default:
return message.includes(kw)
}
})
case 'sentiment':
if (!trigger.sentimentValues?.length) return false
return trigger.sentimentValues.includes(interaction.analysis?.sentiment)
case 'influencer':
const minFollowers = trigger.influencerMinFollowers || 10000
return (interaction.author?.subscriberCount || 0) >= minFollowers
case 'medical':
return interaction.flags?.isMedicalQuestion === true
case 'new_subscriber':
return interaction.author?.isSubscriber === true
default:
return false
}
}
private async applyActions(rule: Rule, interaction: any): Promise<Record<string, any>> {
const changes: Record<string, any> = {}
for (const action of rule.actions) {
switch (action.action) {
case 'set_priority':
if (action.value) {
changes.priority = action.value
}
break
case 'assign_to':
const userId = typeof action.targetUser === 'object'
? action.targetUser.id
: action.targetUser
if (userId) {
changes.assignedTo = userId
}
break
case 'apply_template':
const templateId = typeof action.targetTemplate === 'object'
? action.targetTemplate.id
: action.targetTemplate
if (templateId) {
changes['analysis.suggestedTemplate'] = templateId
}
break
case 'flag':
if (action.value === 'medical') {
changes['flags.isMedicalQuestion'] = true
} else if (action.value === 'escalation') {
changes['flags.requiresEscalation'] = true
} else if (action.value === 'spam') {
changes['flags.isSpam'] = true
} else if (action.value === 'influencer') {
changes['flags.isFromInfluencer'] = true
}
break
case 'notify':
await this.sendNotification(action, interaction, rule)
break
}
}
return changes
}
private async sendNotification(
action: RuleAction,
interaction: any,
rule: Rule
): Promise<void> {
const userId = typeof action.targetUser === 'object'
? action.targetUser.id
: action.targetUser
if (!userId) return
try {
await this.payload.create({
collection: 'yt-notifications',
data: {
user: userId,
type: 'community_rule_triggered',
title: `🔔 Rule "${rule.name}" triggered`,
message: `New interaction from ${interaction.author?.name}: "${interaction.message?.substring(0, 100)}..."`,
relatedContent: interaction.linkedContent?.id,
priority: interaction.priority === 'urgent' ? 'urgent' : 'normal',
isRead: false,
},
})
} catch (error) {
console.error('Failed to create notification:', error)
}
}
}
Implementierungs-Checkliste
Community Inbox View (inkl. Mobile)
src/app/(payload)/admin/views/community/inbox/page.tsxsrc/app/(payload)/admin/views/community/inbox/CommunityInbox.tsxsrc/app/(payload)/admin/views/community/inbox/inbox.scsssrc/app/(payload)/api/community-interactions/stats/route.tssrc/app/(payload)/api/community/generate-reply/route.ts- Payload config: View registration
- Navigation link hinzufügen
- Mobile testing (verschiedene Devices)
Export Funktion
src/app/(payload)/api/community/export/route.ts- npm install exceljs pdfkit @types/pdfkit
- Excel export testen
- CSV export testen
- PDF export testen
YouTube OAuth
src/lib/integrations/youtube/oauth.tssrc/app/(payload)/api/youtube/auth/route.tssrc/app/(payload)/api/youtube/callback/route.tssrc/app/(payload)/api/youtube/refresh-token/route.ts- Environment variables setzen
- Google Cloud Console konfigurieren
Auto-Sync Cron
src/jobs/syncAllComments.ts- package.json script hinzufügen
- Cron oder Systemd Timer einrichten
Rules Engine
src/lib/services/RulesEngine.ts- Hook in CommunityInteractions hinzufügen
Mobile-spezifische Features
| Feature | Beschreibung |
|---|---|
| Touch-optimiert | Größere Touch-Targets, Swipe-Gesten möglich |
| Fullscreen Views | Liste/Detail/Filter als separate Screens |
| Quick Stats | Kompakte Stats-Bar im Header |
| Chip-basierte Filter | Einfach tippbare Filter-Chips |
| Bottom-Sheet Modal | Native-feeling Export-Modal |
| Optimierte Typografie | Größere Fonts, bessere Lesbarkeit |
| Sticky Header | Navigation immer sichtbar |
| Pull-to-Refresh | Könnte noch ergänzt werden |
Export-Formate
| Format | Inhalt | Verwendung |
|---|---|---|
| Excel | 2 Sheets (Daten + Summary), formatiert | Management Reports |
| CSV | Rohdaten, BOM für DE-Zeichen | Weiterverarbeitung |
| Übersicht + Top 20 Interaktionen | Präsentationen |
Geschätzter Aufwand (Updated)
| Teil | Tage |
|---|---|
| Community Inbox (Desktop) | 3 |
| Mobile Optimierung | 1.5 |
| Export Funktion | 1 |
| YouTube OAuth | 1 |
| Auto-Sync Cron | 0.5 |
| Rules Engine | 1.5 |
| Testing | 1 |
| Gesamt | 9-10 Tage |