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>
4169 lines
119 KiB
Markdown
4169 lines
119 KiB
Markdown
# Phase 1 Completion – Integration Prompt (Extended)
|
||
|
||
## Übersicht
|
||
|
||
Dieser Prompt vervollständigt Phase 1 des Community Management Systems mit:
|
||
|
||
1. **Community Inbox View** – Custom Admin View für tägliches Community Management
|
||
2. **Mobile-Optimierte Inbox** – Vollständig responsive für Smartphone-Nutzung
|
||
3. **YouTube OAuth Flow** – Authentifizierung + Token Management
|
||
4. **Auto-Sync Cron Job** – Automatischer Kommentar-Import
|
||
5. **Rules Engine** – Automatische Regel-Ausführung bei neuen Interaktionen
|
||
6. **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
|
||
|
||
```typescript
|
||
// 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)
|
||
|
||
```tsx
|
||
// 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)
|
||
|
||
```scss
|
||
// 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
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```json
|
||
{
|
||
"dependencies": {
|
||
"exceljs": "^4.4.0",
|
||
"pdfkit": "^0.15.0"
|
||
},
|
||
"devDependencies": {
|
||
"@types/pdfkit": "^0.13.4"
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Teil 3: YouTube OAuth Flow
|
||
|
||
```typescript
|
||
// 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
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
// 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)
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
// 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)}`
|
||
)
|
||
}
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```typescript
|
||
// 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.tsx`
|
||
- [ ] `src/app/(payload)/admin/views/community/inbox/CommunityInbox.tsx`
|
||
- [ ] `src/app/(payload)/admin/views/community/inbox/inbox.scss`
|
||
- [ ] `src/app/(payload)/api/community-interactions/stats/route.ts`
|
||
- [ ] `src/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.ts`
|
||
- [ ] `src/app/(payload)/api/youtube/auth/route.ts`
|
||
- [ ] `src/app/(payload)/api/youtube/callback/route.ts`
|
||
- [ ] `src/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 |
|
||
| **PDF** | Ü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** |
|