# Community Management Phase 2.1 – Analytics Dashboard ## Projekt-Kontext Du arbeitest am Community Management System für Complex Care Solutions (CCS). Phase 1 ist abgeschlossen und umfasst: - Community Inbox View (Desktop + Mobile) unter `/admin/community/inbox` - YouTube OAuth + Auto-Sync - Rules Engine für automatische Aktionen - Export-Funktion (PDF/Excel/CSV) - Claude AI Sentiment-Analyse - Bestehende Stats-API unter `/api/community/stats` **Deine Aufgabe:** Implementiere das Analytics Dashboard als neue Admin View. --- ## Tech Stack | Technologie | Version | |-------------|---------| | Payload CMS | 3.69.0 | | Next.js | 15.x (App Router) | | React | 19.x | | TypeScript | 5.x | | Datenbank | PostgreSQL 17 | | Charts | Recharts 2.x | | Datum | date-fns 4.x | | Styling | SCSS (BEM-Konvention) | --- ## Bestehende Datenbankstruktur ### Collection: CommunityInteractions ```typescript { id: number platform: Relation socialAccount: Relation linkedContent?: Relation // youtube-content Collection type: 'comment' | 'reply' | 'dm' | 'mention' | 'review' | 'question' externalId: string // Unique, indexed parentInteraction?: Relation // für Threads author: { name: string handle: string profileUrl: string avatarUrl: string isVerified: boolean isSubscriber: boolean isMember: boolean // Channel Member subscriberCount: number } message: string messageHtml?: string // Original HTML falls vorhanden attachments: Array<{ type: 'image' | 'video' | 'link' | 'sticker', url: string }> publishedAt: Date analysis: { sentiment: 'positive' | 'neutral' | 'negative' | 'question' | 'gratitude' | 'frustration' sentimentScore: number // -1.0 bis +1.0 confidence: number // 0 bis 100 (Prozent!) topics: Array<{ topic: string }> language: string suggestedTemplate?: Relation suggestedReply?: string analyzedAt?: Date } flags: { isMedicalQuestion: boolean // Erfordert ärztliche Review requiresEscalation: boolean isSpam: boolean isFromInfluencer: boolean // >10k Follower } status: 'new' | 'in_review' | 'waiting' | 'replied' | 'resolved' | 'archived' | 'spam' priority: 'urgent' | 'high' | 'normal' | 'low' assignedTo?: Relation responseDeadline?: Date response?: { text: string usedTemplate?: Relation sentAt: Date sentBy: Relation externalReplyId?: string } engagement: { likes: number replies: number isHearted: boolean // Creator Heart isPinned: boolean } internalNotes?: string createdAt: Date updatedAt: Date } ``` ### Collection: SocialAccounts ```typescript { id: number platform: Relation linkedChannel?: Relation displayName: string // z.B. "BlogWoman YouTube" accountHandle?: string // z.B. "@blogwoman" externalId?: string // YouTube Channel ID, etc. accountUrl?: string isActive: boolean stats: { followers: number totalPosts: number lastSyncedAt?: Date } syncSettings: { autoSyncEnabled: boolean syncIntervalMinutes: number syncComments: boolean syncDMs: boolean } } ``` --- ## Bestehende API-Endpoints ### GET /api/community/stats (EXISTIERT BEREITS) ```typescript // Response: { new: number // Neue Interaktionen urgent: number // Dringende (priority=urgent, nicht resolved/archived/spam) waiting: number // Status = waiting today: number // Heute eingegangen sentiment: { positive: number neutral: number negative: number } } ``` > **Hinweis:** Diese API kann als Basis für die Overview-Daten verwendet oder erweitert werden. --- ## Implementierungs-Anforderungen ### 1. Dateistruktur erstellen ``` src/ ├── app/(payload)/ │ ├── admin/views/community/ │ │ └── analytics/ │ │ ├── page.tsx │ │ ├── AnalyticsDashboard.tsx │ │ ├── components/ │ │ │ ├── KPICards.tsx │ │ │ ├── SentimentTrendChart.tsx │ │ │ ├── ResponseMetrics.tsx │ │ │ ├── ChannelComparison.tsx │ │ │ ├── TopContent.tsx │ │ │ └── TopicCloud.tsx │ │ └── analytics.scss │ └── api/community/analytics/ │ ├── overview/route.ts │ ├── sentiment-trend/route.ts │ ├── response-metrics/route.ts │ ├── channel-comparison/route.ts │ ├── top-content/route.ts │ └── topic-cloud/route.ts └── lib/ └── analytics/ └── calculateMetrics.ts ``` ### 2. API Endpoints implementieren #### GET /api/community/analytics/overview ```typescript // Query Parameter: // - period: '7d' | '30d' | '90d' (default: '30d') // - channel: 'all' | string (socialAccount.id) // Response: interface OverviewResponse { period: string totalInteractions: number newInteractions: number responseRate: number // 0-100 avgResponseTimeHours: number avgSentimentScore: number // -1 bis +1 medicalQuestions: number escalations: number // Sentiment-Verteilung (alle 6 Typen!) sentimentDistribution: { positive: number neutral: number negative: number question: number gratitude: number frustration: number } comparison: { totalInteractions: number // Änderung vs. Vorperiode in % responseRate: number avgSentimentScore: number } } ``` #### GET /api/community/analytics/sentiment-trend ```typescript // Query Parameter: // - period: '7d' | '30d' | '90d' // - channel: 'all' | string // - granularity: 'day' | 'week' (default: basierend auf period) // Response: interface SentimentTrendResponse { data: Array<{ date: string // ISO Date positive: number neutral: number negative: number question: number gratitude: number frustration: number avgScore: number total: number }> } ``` #### GET /api/community/analytics/response-metrics ```typescript // Query Parameter: // - period: '7d' | '30d' | '90d' // - channel: 'all' | string // Response: interface ResponseMetricsResponse { firstResponseTime: { median: number // Stunden p90: number trend: number // vs. Vorperiode } resolutionRate: number // 0-100 (status=resolved / total) escalationRate: number // flags.requiresEscalation / total templateUsageRate: number // response.usedTemplate vorhanden / responded byPriority: { urgent: { count: number, avgResponseTime: number } high: { count: number, avgResponseTime: number } normal: { count: number, avgResponseTime: number } low: { count: number, avgResponseTime: number } } byStatus: { new: number in_review: number waiting: number replied: number resolved: number archived: number spam: number } } ``` #### GET /api/community/analytics/channel-comparison ```typescript // Query Parameter: // - period: '7d' | '30d' | '90d' // Response: interface ChannelComparisonResponse { channels: Array<{ id: number name: string // displayName platform: string // platform.name metrics: { totalInteractions: number avgSentiment: number responseRate: number avgResponseTimeHours: number topTopics: string[] } }> } ``` #### GET /api/community/analytics/top-content ```typescript // Query Parameter: // - period: '7d' | '30d' | '90d' // - limit: number (default: 10) // - sortBy: 'comments' | 'sentiment' | 'medical' (default: 'comments') // - channel: 'all' | string // Response: interface TopContentResponse { content: Array<{ contentId: number // YouTubeContent.id videoId: string // YouTube Video ID title: string channelName: string thumbnailUrl: string commentCount: number avgSentiment: number medicalQuestions: number topTopics: string[] publishedAt: string }> } ``` #### GET /api/community/analytics/topic-cloud ```typescript // Query Parameter: // - period: '7d' | '30d' | '90d' // - channel: 'all' | string // - limit: number (default: 30) // Response: interface TopicCloudResponse { topics: Array<{ topic: string count: number avgSentiment: number channels: string[] }> } ``` --- ## 3. React Komponenten ### page.tsx (View Registration via App Router) ```tsx // src/app/(payload)/admin/views/community/analytics/page.tsx import React from 'react' import { DefaultTemplate } from '@payloadcms/next/templates' import { Gutter } from '@payloadcms/ui' import { AnalyticsDashboard } from './AnalyticsDashboard' import './analytics.scss' export const metadata = { title: 'Community Analytics', description: 'Performance insights across all channels', } export default function CommunityAnalyticsPage() { return ( ) } ``` ### AnalyticsDashboard.tsx (Hauptkomponente) ```tsx // src/app/(payload)/admin/views/community/analytics/AnalyticsDashboard.tsx 'use client' import React, { useState, useEffect } from 'react' import { KPICards } from './components/KPICards' import { SentimentTrendChart } from './components/SentimentTrendChart' import { ResponseMetrics } from './components/ResponseMetrics' import { ChannelComparison } from './components/ChannelComparison' import { TopContent } from './components/TopContent' import { TopicCloud } from './components/TopicCloud' type Period = '7d' | '30d' | '90d' interface Channel { id: string name: string } export const AnalyticsDashboard: React.FC = () => { const [period, setPeriod] = useState('30d') const [channel, setChannel] = useState('all') const [channels, setChannels] = useState([]) // Fetch available channels on mount useEffect(() => { const fetchChannels = async () => { try { const response = await fetch('/api/social-accounts?where[isActive][equals]=true&limit=100') if (response.ok) { const data = await response.json() setChannels(data.docs.map((acc: { id: number; displayName: string }) => ({ id: String(acc.id), name: acc.displayName }))) } } catch (err) { console.error('Failed to fetch channels:', err) } } fetchChannels() }, []) const periodLabels: Record = { '7d': '7 Tage', '30d': '30 Tage', '90d': '90 Tage' } return (
{/* Header */}

Community Analytics

Performance-Übersicht aller Kanäle

{/* Period Selector */}
{/* Channel Filter */}
{/* KPI Cards Row */}
{/* Charts Grid - Row 1 */}

Sentiment-Trend

Response-Metriken

{/* Charts Grid - Row 2 */}

Kanal-Vergleich

Themen-Wolke

{/* Top Content */}

Top Content nach Engagement

) } ``` ### KPICards.tsx ```tsx // src/app/(payload)/admin/views/community/analytics/components/KPICards.tsx 'use client' import React, { useState, useEffect } from 'react' interface KPICardsProps { period: string channel: string } interface OverviewData { totalInteractions: number newInteractions: number responseRate: number avgResponseTimeHours: number avgSentimentScore: number medicalQuestions: number escalations: number sentimentDistribution: { positive: number neutral: number negative: number question: number gratitude: number frustration: number } comparison: { totalInteractions: number responseRate: number avgSentimentScore: number } } export const KPICards: React.FC = ({ period, channel }) => { const [data, setData] = useState(null) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) useEffect(() => { const fetchData = async () => { setIsLoading(true) setError(null) try { const params = new URLSearchParams({ period, channel }) const response = await fetch(`/api/community/analytics/overview?${params}`) if (!response.ok) throw new Error('Failed to fetch overview data') const result = await response.json() setData(result) } catch (err) { setError(err instanceof Error ? err.message : 'Unknown error') } finally { setIsLoading(false) } } fetchData() }, [period, channel]) if (isLoading) { return ( <> {[...Array(5)].map((_, i) => (
))} ) } if (error || !data) { return
Fehler beim Laden der Daten
} const getTrendClass = (value: number): string => { if (value > 0) return 'kpi-card__trend--up' if (value < 0) return 'kpi-card__trend--down' return 'kpi-card__trend--neutral' } const getTrendIcon = (value: number): string => { if (value > 0) return '↑' if (value < 0) return '↓' return '→' } const getSentimentEmoji = (score: number): string => { if (score > 0.3) return '😊' if (score < -0.3) return '😟' return '😐' } const formatTime = (hours: number): string => { if (hours < 1) return `${Math.round(hours * 60)} Min` return `${hours.toFixed(1)}h` } return ( <> {/* Neue Interaktionen */}
💬
{data.newInteractions}
Neue Interaktionen
{getTrendIcon(data.comparison.totalInteractions)} {Math.abs(data.comparison.totalInteractions)}% vs. Vorperiode
{/* Response Rate */}
{data.responseRate.toFixed(0)}%
Response Rate
{getTrendIcon(data.comparison.responseRate)} {Math.abs(data.comparison.responseRate).toFixed(1)}%
{/* Ø Antwortzeit */}
⏱️
{formatTime(data.avgResponseTimeHours)}
Ø Antwortzeit
Median
{/* Sentiment Score */}
{getSentimentEmoji(data.avgSentimentScore)}
{data.avgSentimentScore > 0 ? '+' : ''}{data.avgSentimentScore.toFixed(2)}
Sentiment Score
{getTrendIcon(data.comparison.avgSentimentScore)} {Math.abs(data.comparison.avgSentimentScore).toFixed(2)}
{/* Medical Flags */}
0 ? 'kpi-card__icon--red' : 'kpi-card__icon--gray'}`}> ⚕️
{data.medicalQuestions}
Med. Anfragen
{data.escalations > 0 && (
⚠️ {data.escalations} Eskalationen
)}
) } ``` ### SentimentTrendChart.tsx ```tsx // src/app/(payload)/admin/views/community/analytics/components/SentimentTrendChart.tsx 'use client' import React, { useState, useEffect } from 'react' import { ComposedChart, Area, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts' interface SentimentTrendProps { period: string channel: string } interface TrendDataPoint { date: string positive: number neutral: number negative: number question: number gratitude: number frustration: number avgScore: number total: number } const COLORS = { positive: '#10B981', neutral: '#6B7280', negative: '#EF4444', question: '#3B82F6', gratitude: '#8B5CF6', frustration: '#F97316', avgScore: '#1F2937' } export const SentimentTrendChart: React.FC = ({ period, channel }) => { const [data, setData] = useState([]) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) useEffect(() => { const fetchData = async () => { setIsLoading(true) setError(null) try { const params = new URLSearchParams({ period, channel }) const response = await fetch(`/api/community/analytics/sentiment-trend?${params}`) if (!response.ok) throw new Error('Failed to fetch sentiment trend') const result = await response.json() setData(result.data) } catch (err) { setError(err instanceof Error ? err.message : 'Unknown error') } finally { setIsLoading(false) } } fetchData() }, [period, channel]) if (isLoading) { return
Lade Daten...
} if (error) { return
Fehler: {error}
} if (data.length === 0) { return
Keine Daten für den gewählten Zeitraum
} const formatDate = (dateStr: string): string => { const date = new Date(dateStr) return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }) } return (
) } ``` ### ResponseMetrics.tsx ```tsx // src/app/(payload)/admin/views/community/analytics/components/ResponseMetrics.tsx 'use client' import React, { useState, useEffect } from 'react' interface ResponseMetricsProps { period: string channel: string } interface MetricsData { firstResponseTime: { median: number p90: number trend: number } resolutionRate: number escalationRate: number templateUsageRate: number byPriority: { urgent: { count: number; avgResponseTime: number } high: { count: number; avgResponseTime: number } normal: { count: number; avgResponseTime: number } low: { count: number; avgResponseTime: number } } byStatus: { new: number in_review: number waiting: number replied: number resolved: number archived: number spam: number } } export const ResponseMetrics: React.FC = ({ period, channel }) => { const [data, setData] = useState(null) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) useEffect(() => { const fetchData = async () => { setIsLoading(true) setError(null) try { const params = new URLSearchParams({ period, channel }) const response = await fetch(`/api/community/analytics/response-metrics?${params}`) if (!response.ok) throw new Error('Failed to fetch response metrics') const result = await response.json() setData(result) } catch (err) { setError(err instanceof Error ? err.message : 'Unknown error') } finally { setIsLoading(false) } } fetchData() }, [period, channel]) if (isLoading) { return
Lade Metriken...
} if (error || !data) { return
Fehler beim Laden
} const formatTime = (hours: number): string => { if (hours < 1) return `${Math.round(hours * 60)}m` return `${hours.toFixed(1)}h` } const getProgressColor = (value: number, target: number, inverse: boolean = false): string => { const ratio = value / target if (inverse) { if (ratio <= 1) return '#10B981' if (ratio <= 1.5) return '#F59E0B' return '#EF4444' } if (ratio >= 1) return '#10B981' if (ratio >= 0.7) return '#F59E0B' return '#EF4444' } return (
{/* Response Time */}
Erste Antwort (Median)
{formatTime(data.firstResponseTime.median)}
Ziel: < 4h
{/* Resolution Rate */}
Resolution Rate
{data.resolutionRate.toFixed(0)}%
Ziel: > 90%
{/* Escalation Rate */}
Eskalationsrate
{data.escalationRate.toFixed(1)}%
Ziel: < 5%
{/* Template Usage */}
Template-Nutzung
{data.templateUsageRate.toFixed(0)}%
Ziel: > 60%
{/* Priority Breakdown */}

Nach Priorität

{Object.entries(data.byPriority).map(([priority, stats]) => (
{priority.charAt(0).toUpperCase() + priority.slice(1)} {stats.count} {formatTime(stats.avgResponseTime)}
))}
) } ``` ### ChannelComparison.tsx ```tsx // src/app/(payload)/admin/views/community/analytics/components/ChannelComparison.tsx 'use client' import React, { useState, useEffect } from 'react' interface ChannelComparisonProps { period: string } interface ChannelData { id: number name: string platform: string metrics: { totalInteractions: number avgSentiment: number responseRate: number avgResponseTimeHours: number topTopics: string[] } } export const ChannelComparison: React.FC = ({ period }) => { const [channels, setChannels] = useState([]) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) useEffect(() => { const fetchData = async () => { setIsLoading(true) setError(null) try { const params = new URLSearchParams({ period }) const response = await fetch(`/api/community/analytics/channel-comparison?${params}`) if (!response.ok) throw new Error('Failed to fetch channel comparison') const result = await response.json() setChannels(result.channels) } catch (err) { setError(err instanceof Error ? err.message : 'Unknown error') } finally { setIsLoading(false) } } fetchData() }, [period]) if (isLoading) { return
Lade Vergleich...
} if (error) { return
Fehler: {error}
} const getSentimentEmoji = (score: number): string => { if (score > 0.3) return '😊' if (score < -0.3) return '😟' return '😐' } const formatTime = (hours: number): string => { if (hours < 1) return `${Math.round(hours * 60)}m` return `${hours.toFixed(1)}h` } return (
{channels.map((channel) => ( ))}
Kanal Interaktionen Sentiment Response Rate Ø Antwortzeit Top Themen
{channel.platform} {channel.name}
{channel.metrics.totalInteractions} {getSentimentEmoji(channel.metrics.avgSentiment)} {channel.metrics.avgSentiment > 0 ? '+' : ''} {channel.metrics.avgSentiment.toFixed(2)} = 90 ? 'channel-comparison__rate--good' : ''}`}> {channel.metrics.responseRate.toFixed(0)}% {formatTime(channel.metrics.avgResponseTimeHours)}
{channel.metrics.topTopics.slice(0, 3).map((topic, i) => ( {topic} ))}
) } ``` ### TopContent.tsx ```tsx // src/app/(payload)/admin/views/community/analytics/components/TopContent.tsx 'use client' import React, { useState, useEffect } from 'react' interface TopContentProps { period: string channel: string } type SortBy = 'comments' | 'sentiment' | 'medical' interface ContentData { contentId: number videoId: string title: string channelName: string thumbnailUrl: string commentCount: number avgSentiment: number medicalQuestions: number topTopics: string[] publishedAt: string } export const TopContent: React.FC = ({ period, channel }) => { const [content, setContent] = useState([]) const [sortBy, setSortBy] = useState('comments') const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) useEffect(() => { const fetchData = async () => { setIsLoading(true) setError(null) try { const params = new URLSearchParams({ period, channel, sortBy, limit: '10' }) const response = await fetch(`/api/community/analytics/top-content?${params}`) if (!response.ok) throw new Error('Failed to fetch top content') const result = await response.json() setContent(result.content) } catch (err) { setError(err instanceof Error ? err.message : 'Unknown error') } finally { setIsLoading(false) } } fetchData() }, [period, channel, sortBy]) const getSentimentEmoji = (score: number): string => { if (score > 0.3) return '😊' if (score < -0.3) return '😟' return '😐' } const sortOptions: { value: SortBy; label: string }[] = [ { value: 'comments', label: 'Interaktionen' }, { value: 'sentiment', label: 'Sentiment' }, { value: 'medical', label: 'Med. Anfragen' } ] return (
{isLoading &&
Lade Content...
} {error &&
Fehler: {error}
} {!isLoading && !error && (
{content.map((item, index) => (
{index + 1}
{item.thumbnailUrl ? ( {item.title} ) : (
🎬
)}

{item.title}

{item.channelName}
{item.topTopics.slice(0, 3).map((topic, i) => ( {topic} ))}
{item.commentCount} Interaktionen
{getSentimentEmoji(item.avgSentiment)} {item.avgSentiment.toFixed(2)} Sentiment
{item.medicalQuestions > 0 && (
⚕️ {item.medicalQuestions} Med. Fragen
)}
))}
)}
) } ``` ### TopicCloud.tsx ```tsx // src/app/(payload)/admin/views/community/analytics/components/TopicCloud.tsx 'use client' import React, { useState, useEffect } from 'react' interface TopicCloudProps { period: string channel: string } interface TopicData { topic: string count: number avgSentiment: number channels: string[] } export const TopicCloud: React.FC = ({ period, channel }) => { const [topics, setTopics] = useState([]) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) useEffect(() => { const fetchData = async () => { setIsLoading(true) setError(null) try { const params = new URLSearchParams({ period, channel, limit: '30' }) const response = await fetch(`/api/community/analytics/topic-cloud?${params}`) if (!response.ok) throw new Error('Failed to fetch topics') const result = await response.json() setTopics(result.topics) } catch (err) { setError(err instanceof Error ? err.message : 'Unknown error') } finally { setIsLoading(false) } } fetchData() }, [period, channel]) if (isLoading) { return
Lade Themen...
} if (error) { return
Fehler: {error}
} if (topics.length === 0) { return
Keine Themen gefunden
} // Calculate min/max for sizing const maxCount = Math.max(...topics.map(t => t.count)) const minCount = Math.min(...topics.map(t => t.count)) const getSize = (count: number): number => { if (maxCount === minCount) return 1 const normalized = (count - minCount) / (maxCount - minCount) return 0.75 + (normalized * 1.25) // Scale from 0.75rem to 2rem } const getColor = (sentiment: number): string => { if (sentiment > 0.3) return '#10B981' // Green if (sentiment < -0.3) return '#EF4444' // Red return '#6B7280' // Gray } return (
{topics.map((topic, index) => ( {topic.topic} ))}
Positiv Neutral Negativ
) } ``` --- ## 4. SCSS Styling Kopiere das SCSS aus der bestehenden Inbox-Implementierung und passe es an: `src/app/(payload)/admin/views/community/inbox/inbox.scss` Die grundlegende Struktur bleibt gleich, verwende CSS-Variablen von Payload UI: - `var(--theme-elevation-X)` für Farben - Responsive Breakpoints: 480px, 768px, 1024px, 1200px --- ## 5. API Endpoint Beispiel-Implementierung ### overview/route.ts ```typescript // src/app/(payload)/api/community/analytics/overview/route.ts import { NextRequest, NextResponse } from 'next/server' import { getPayload } from 'payload' import config from '@payload-config' import { subDays, differenceInHours } from 'date-fns' import { createSafeLogger } from '@/lib/security' const logger = createSafeLogger('API:AnalyticsOverview') export async function GET(request: NextRequest) { try { const { searchParams } = new URL(request.url) const period = searchParams.get('period') || '30d' const channelId = searchParams.get('channel') || 'all' const payload = await getPayload({ config }) // Authentifizierung prüfen const { user } = await payload.auth({ headers: request.headers }) if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } // Calculate date ranges const now = new Date() let periodStart: Date let previousPeriodStart: Date let previousPeriodEnd: Date switch (period) { case '7d': periodStart = subDays(now, 7) previousPeriodStart = subDays(now, 14) previousPeriodEnd = subDays(now, 7) break case '90d': periodStart = subDays(now, 90) previousPeriodStart = subDays(now, 180) previousPeriodEnd = subDays(now, 90) break default: // 30d periodStart = subDays(now, 30) previousPeriodStart = subDays(now, 60) previousPeriodEnd = subDays(now, 30) } // Build where clause const baseWhere: Record = { publishedAt: { greater_than_equal: periodStart.toISOString(), }, } if (channelId !== 'all') { baseWhere.socialAccount = { equals: parseInt(channelId) } } // Fetch current period data const currentInteractions = await payload.find({ collection: 'community-interactions', where: baseWhere, limit: 10000, }) // Calculate metrics const docs = currentInteractions.docs const total = docs.length const newCount = docs.filter(i => i.status === 'new').length const repliedCount = docs.filter(i => ['replied', 'resolved'].includes(i.status as string) ).length const responseRate = total > 0 ? (repliedCount / total) * 100 : 0 // Calculate average response time const responseTimes = docs .filter(i => i.response?.sentAt && i.publishedAt) .map(i => differenceInHours( new Date(i.response!.sentAt as string), new Date(i.publishedAt as string) )) const avgResponseTime = responseTimes.length > 0 ? responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length : 0 // Calculate sentiment (use sentimentScore, normalize confidence from 0-100 to 0-1) const sentimentScores = docs .filter(i => i.analysis?.sentimentScore != null) .map(i => { const score = i.analysis!.sentimentScore as number const confidence = ((i.analysis!.confidence as number) || 100) / 100 return score * confidence }) const avgSentiment = sentimentScores.length > 0 ? sentimentScores.reduce((a, b) => a + b, 0) / sentimentScores.length : 0 // Sentiment distribution (all 6 types) const sentimentDistribution = { positive: docs.filter(i => i.analysis?.sentiment === 'positive').length, neutral: docs.filter(i => i.analysis?.sentiment === 'neutral').length, negative: docs.filter(i => i.analysis?.sentiment === 'negative').length, question: docs.filter(i => i.analysis?.sentiment === 'question').length, gratitude: docs.filter(i => i.analysis?.sentiment === 'gratitude').length, frustration: docs.filter(i => i.analysis?.sentiment === 'frustration').length, } // Count flags const medicalQuestions = docs.filter( i => i.flags?.isMedicalQuestion ).length const escalations = docs.filter( i => i.flags?.requiresEscalation ).length // Fetch previous period for comparison const previousWhere: Record = { publishedAt: { greater_than_equal: previousPeriodStart.toISOString(), less_than: previousPeriodEnd.toISOString(), }, } if (channelId !== 'all') { previousWhere.socialAccount = { equals: parseInt(channelId) } } const previousInteractions = await payload.find({ collection: 'community-interactions', where: previousWhere, limit: 10000, }) const prevDocs = previousInteractions.docs const prevTotal = prevDocs.length const prevRepliedCount = prevDocs.filter(i => ['replied', 'resolved'].includes(i.status as string) ).length const prevResponseRate = prevTotal > 0 ? (prevRepliedCount / prevTotal) * 100 : 0 const prevSentimentScores = prevDocs .filter(i => i.analysis?.sentimentScore != null) .map(i => { const score = i.analysis!.sentimentScore as number const confidence = ((i.analysis!.confidence as number) || 100) / 100 return score * confidence }) const prevAvgSentiment = prevSentimentScores.length > 0 ? prevSentimentScores.reduce((a, b) => a + b, 0) / prevSentimentScores.length : 0 // Calculate comparisons const totalChange = prevTotal > 0 ? ((total - prevTotal) / prevTotal) * 100 : 0 const responseRateChange = responseRate - prevResponseRate const sentimentChange = avgSentiment - prevAvgSentiment return NextResponse.json({ period, totalInteractions: total, newInteractions: newCount, responseRate, avgResponseTimeHours: avgResponseTime, avgSentimentScore: avgSentiment, medicalQuestions, escalations, sentimentDistribution, comparison: { totalInteractions: Math.round(totalChange), responseRate: responseRateChange, avgSentimentScore: sentimentChange, }, }) } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : 'Unknown error' logger.error('Analytics overview error:', { error: errorMessage }) return NextResponse.json( { error: 'Failed to fetch analytics' }, { status: 500 } ) } } export const dynamic = 'force-dynamic' ``` --- ## 6. Dependencies installieren ```bash pnpm add recharts date-fns ``` > **Hinweis:** Diese Dependencies sind NICHT installiert und müssen vor der Implementierung hinzugefügt werden. --- ## 7. Implementierungs-Reihenfolge 1. **Dependencies installieren** - `pnpm add recharts date-fns` 2. **Verzeichnisstruktur anlegen** - `/admin/views/community/analytics/` 3. **API Endpoints** (alle 6 implementieren) 4. **page.tsx** mit Haupt-Dashboard-Komponente 5. **KPICards** Komponente 6. **SentimentTrendChart** mit Recharts 7. **ResponseMetrics** Komponente 8. **ChannelComparison** Tabelle 9. **TopContent** Liste 10. **TopicCloud** Tag-Wolke 11. **SCSS Styling** mit BEM-Konvention 12. **Mobile Responsive** Testing --- ## 8. Testfälle Nach Implementierung verifizieren: - [ ] Dashboard lädt unter `/admin/community/analytics` - [ ] Period-Filter (7d/30d/90d) aktualisiert alle Komponenten - [ ] Channel-Filter funktioniert korrekt - [ ] KPI-Vergleiche zeigen korrekte Trends - [ ] Sentiment-Chart rendert alle 6 Sentiment-Typen - [ ] Response-Metriken zeigen alle 7 Status-Typen - [ ] Channel-Vergleich zeigt alle aktiven SocialAccounts - [ ] Top-Content-Sortierung funktioniert - [ ] Topic-Cloud skaliert korrekt - [ ] Mobile Layout funktioniert (< 768px) - [ ] Tablet Layout funktioniert (768px - 1024px) - [ ] Leere Daten werden graceful gehandelt - [ ] API-Fehler zeigen User-Feedback - [ ] Loading States erscheinen während Datenabruf --- ## 9. Hinweise 1. **Keine externen API-Calls** – alle Daten kommen aus der PostgreSQL-Datenbank 2. **Payload Local API verwenden** für Datenbankabfragen: ```typescript import { getPayload } from 'payload' import config from '@payload-config' const payload = await getPayload({ config }) const interactions = await payload.find({ collection: 'community-interactions', where: { ... }, limit: 10000, }) ``` 3. **date-fns** für Datumsberechnungen verwenden (subDays, differenceInHours, etc.) 4. **Caching** – API-Responses können für 5 Minuten gecacht werden: ```typescript return NextResponse.json(data, { headers: { 'Cache-Control': 'private, max-age=300', }, }) ``` 5. **Performance**: Bei großen Datenmengen (>10k Interactions) ggf. direkte SQL-Aggregation über Drizzle ORM verwenden 6. **Authentifizierung**: Alle API-Endpoints müssen User-Authentifizierung prüfen wie in `/api/community/stats` 7. **Sentiment-Werte beachten**: - `sentiment` ist ein String mit 6 möglichen Werten - `sentimentScore` ist numerisch (-1 bis +1) - `confidence` ist 0-100 (Prozent), nicht 0-1! 8. **Status-Werte beachten**: 7 verschiedene Status-Werte, nicht 6 --- ## 10. Deliverables Nach Abschluss solltest du haben: 1. ✅ Alle 6 API-Endpoints funktionsfähig 2. ✅ Dashboard-View im Admin-Panel unter `/admin/community/analytics` 3. ✅ Alle 6 Komponenten implementiert 4. ✅ SCSS vollständig mit BEM-Konvention 5. ✅ Mobile-responsiv getestet (3 Breakpoints) 6. ✅ Keine TypeScript-Fehler 7. ✅ Keine Console-Errors 8. ✅ Loading & Error States für alle Komponenten --- **Beginne mit den Dependencies und API-Endpoints und arbeite dich zur UI vor.** Bei Fragen oder Unklarheiten: Implementiere erst die Grundfunktion, Feinschliff kann danach erfolgen.