- 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>
49 KiB
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
{
id: number
platform: Relation<SocialPlatforms>
socialAccount: Relation<SocialAccounts>
linkedContent?: Relation<YouTubeContent> // youtube-content Collection
type: 'comment' | 'reply' | 'dm' | 'mention' | 'review' | 'question'
externalId: string // Unique, indexed
parentInteraction?: Relation<CommunityInteractions> // 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<CommunityTemplates>
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<Users>
responseDeadline?: Date
response?: {
text: string
usedTemplate?: Relation<CommunityTemplates>
sentAt: Date
sentBy: Relation<Users>
externalReplyId?: string
}
engagement: {
likes: number
replies: number
isHearted: boolean // Creator Heart
isPinned: boolean
}
internalNotes?: string
createdAt: Date
updatedAt: Date
}
Collection: SocialAccounts
{
id: number
platform: Relation<SocialPlatforms>
linkedChannel?: Relation<YouTubeChannels>
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)
// 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
// 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
// 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
// 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
// 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
// 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
// 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)
// 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 (
<DefaultTemplate>
<Gutter>
<AnalyticsDashboard />
</Gutter>
</DefaultTemplate>
)
}
AnalyticsDashboard.tsx (Hauptkomponente)
// 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<Period>('30d')
const [channel, setChannel] = useState<string>('all')
const [channels, setChannels] = useState<Channel[]>([])
// 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<Period, string> = {
'7d': '7 Tage',
'30d': '30 Tage',
'90d': '90 Tage'
}
return (
<div className="analytics-dashboard">
{/* Header */}
<div className="analytics-dashboard__header">
<div className="analytics-dashboard__title">
<h1>Community Analytics</h1>
<p className="analytics-dashboard__subtitle">
Performance-Übersicht aller Kanäle
</p>
</div>
<div className="analytics-dashboard__filters">
{/* Period Selector */}
<div className="analytics-dashboard__filter">
<label htmlFor="period-select">Zeitraum:</label>
<select
id="period-select"
value={period}
onChange={(e) => setPeriod(e.target.value as Period)}
className="analytics-dashboard__select"
>
{Object.entries(periodLabels).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>
{/* Channel Filter */}
<div className="analytics-dashboard__filter">
<label htmlFor="channel-select">Kanal:</label>
<select
id="channel-select"
value={channel}
onChange={(e) => setChannel(e.target.value)}
className="analytics-dashboard__select"
>
<option value="all">Alle Kanäle</option>
{channels.map((ch) => (
<option key={ch.id} value={ch.id}>{ch.name}</option>
))}
</select>
</div>
</div>
</div>
{/* KPI Cards Row */}
<div className="analytics-dashboard__kpi-grid">
<KPICards period={period} channel={channel} />
</div>
{/* Charts Grid - Row 1 */}
<div className="analytics-dashboard__charts-grid">
<div className="analytics-dashboard__card analytics-dashboard__card--wide">
<h3>Sentiment-Trend</h3>
<SentimentTrendChart period={period} channel={channel} />
</div>
<div className="analytics-dashboard__card">
<h3>Response-Metriken</h3>
<ResponseMetrics period={period} channel={channel} />
</div>
</div>
{/* Charts Grid - Row 2 */}
<div className="analytics-dashboard__charts-grid">
<div className="analytics-dashboard__card">
<h3>Kanal-Vergleich</h3>
<ChannelComparison period={period} />
</div>
<div className="analytics-dashboard__card">
<h3>Themen-Wolke</h3>
<TopicCloud period={period} channel={channel} />
</div>
</div>
{/* Top Content */}
<div className="analytics-dashboard__card">
<h3>Top Content nach Engagement</h3>
<TopContent period={period} channel={channel} />
</div>
</div>
)
}
KPICards.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<KPICardsProps> = ({ period, channel }) => {
const [data, setData] = useState<OverviewData | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(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) => (
<div key={i} className="kpi-card kpi-card--loading">
<div className="kpi-card__skeleton" />
</div>
))}
</>
)
}
if (error || !data) {
return <div className="kpi-card kpi-card--error">Fehler beim Laden der Daten</div>
}
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 */}
<div className="kpi-card">
<div className="kpi-card__icon kpi-card__icon--blue">💬</div>
<div className="kpi-card__value">{data.newInteractions}</div>
<div className="kpi-card__label">Neue Interaktionen</div>
<div className={`kpi-card__trend ${getTrendClass(data.comparison.totalInteractions)}`}>
{getTrendIcon(data.comparison.totalInteractions)} {Math.abs(data.comparison.totalInteractions)}% vs. Vorperiode
</div>
</div>
{/* Response Rate */}
<div className="kpi-card">
<div className="kpi-card__icon kpi-card__icon--green">✅</div>
<div className="kpi-card__value">{data.responseRate.toFixed(0)}%</div>
<div className="kpi-card__label">Response Rate</div>
<div className={`kpi-card__trend ${getTrendClass(data.comparison.responseRate)}`}>
{getTrendIcon(data.comparison.responseRate)} {Math.abs(data.comparison.responseRate).toFixed(1)}%
</div>
</div>
{/* Ø Antwortzeit */}
<div className="kpi-card">
<div className="kpi-card__icon kpi-card__icon--purple">⏱️</div>
<div className="kpi-card__value">{formatTime(data.avgResponseTimeHours)}</div>
<div className="kpi-card__label">Ø Antwortzeit</div>
<div className="kpi-card__trend kpi-card__trend--neutral">
Median
</div>
</div>
{/* Sentiment Score */}
<div className="kpi-card">
<div className="kpi-card__icon kpi-card__icon--yellow">
{getSentimentEmoji(data.avgSentimentScore)}
</div>
<div className="kpi-card__value">
{data.avgSentimentScore > 0 ? '+' : ''}{data.avgSentimentScore.toFixed(2)}
</div>
<div className="kpi-card__label">Sentiment Score</div>
<div className={`kpi-card__trend ${getTrendClass(data.comparison.avgSentimentScore)}`}>
{getTrendIcon(data.comparison.avgSentimentScore)} {Math.abs(data.comparison.avgSentimentScore).toFixed(2)}
</div>
</div>
{/* Medical Flags */}
<div className="kpi-card">
<div className={`kpi-card__icon ${data.medicalQuestions > 0 ? 'kpi-card__icon--red' : 'kpi-card__icon--gray'}`}>
⚕️
</div>
<div className="kpi-card__value">{data.medicalQuestions}</div>
<div className="kpi-card__label">Med. Anfragen</div>
{data.escalations > 0 && (
<div className="kpi-card__trend kpi-card__trend--down">
⚠️ {data.escalations} Eskalationen
</div>
)}
</div>
</>
)
}
SentimentTrendChart.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<SentimentTrendProps> = ({ period, channel }) => {
const [data, setData] = useState<TrendDataPoint[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(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 <div className="chart-loading">Lade Daten...</div>
}
if (error) {
return <div className="chart-error">Fehler: {error}</div>
}
if (data.length === 0) {
return <div className="chart-empty">Keine Daten für den gewählten Zeitraum</div>
}
const formatDate = (dateStr: string): string => {
const date = new Date(dateStr)
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })
}
return (
<div className="sentiment-trend-chart">
<ResponsiveContainer width="100%" height={300}>
<ComposedChart data={data} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#E5E7EB" />
<XAxis
dataKey="date"
tickFormatter={formatDate}
stroke="#9CA3AF"
fontSize={12}
/>
<YAxis
yAxisId="left"
stroke="#9CA3AF"
fontSize={12}
/>
<YAxis
yAxisId="right"
orientation="right"
domain={[-1, 1]}
stroke={COLORS.avgScore}
fontSize={12}
/>
<Tooltip />
<Legend />
<Area
yAxisId="left"
type="monotone"
dataKey="positive"
name="Positiv"
stackId="1"
fill={COLORS.positive}
stroke={COLORS.positive}
fillOpacity={0.6}
/>
<Area
yAxisId="left"
type="monotone"
dataKey="neutral"
name="Neutral"
stackId="1"
fill={COLORS.neutral}
stroke={COLORS.neutral}
fillOpacity={0.6}
/>
<Area
yAxisId="left"
type="monotone"
dataKey="negative"
name="Negativ"
stackId="1"
fill={COLORS.negative}
stroke={COLORS.negative}
fillOpacity={0.6}
/>
<Area
yAxisId="left"
type="monotone"
dataKey="question"
name="Frage"
stackId="1"
fill={COLORS.question}
stroke={COLORS.question}
fillOpacity={0.6}
/>
<Line
yAxisId="right"
type="monotone"
dataKey="avgScore"
name="Ø Score"
stroke={COLORS.avgScore}
strokeWidth={2}
dot={false}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
)
}
ResponseMetrics.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<ResponseMetricsProps> = ({ period, channel }) => {
const [data, setData] = useState<MetricsData | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(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 <div className="metrics-loading">Lade Metriken...</div>
}
if (error || !data) {
return <div className="metrics-error">Fehler beim Laden</div>
}
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 (
<div className="response-metrics">
{/* Response Time */}
<div className="response-metrics__item">
<div className="response-metrics__label">Erste Antwort (Median)</div>
<div className="response-metrics__value">
{formatTime(data.firstResponseTime.median)}
</div>
<div className="response-metrics__bar">
<div
className="response-metrics__bar-fill"
style={{
width: `${Math.min((4 / data.firstResponseTime.median) * 100, 100)}%`,
backgroundColor: getProgressColor(data.firstResponseTime.median, 4, true)
}}
/>
</div>
<div className="response-metrics__target">Ziel: < 4h</div>
</div>
{/* Resolution Rate */}
<div className="response-metrics__item">
<div className="response-metrics__label">Resolution Rate</div>
<div className="response-metrics__value">{data.resolutionRate.toFixed(0)}%</div>
<div className="response-metrics__bar">
<div
className="response-metrics__bar-fill"
style={{
width: `${data.resolutionRate}%`,
backgroundColor: getProgressColor(data.resolutionRate, 90)
}}
/>
</div>
<div className="response-metrics__target">Ziel: > 90%</div>
</div>
{/* Escalation Rate */}
<div className="response-metrics__item">
<div className="response-metrics__label">Eskalationsrate</div>
<div className="response-metrics__value">{data.escalationRate.toFixed(1)}%</div>
<div className="response-metrics__bar">
<div
className="response-metrics__bar-fill"
style={{
width: `${Math.min(data.escalationRate * 10, 100)}%`,
backgroundColor: getProgressColor(data.escalationRate, 5, true)
}}
/>
</div>
<div className="response-metrics__target">Ziel: < 5%</div>
</div>
{/* Template Usage */}
<div className="response-metrics__item">
<div className="response-metrics__label">Template-Nutzung</div>
<div className="response-metrics__value">{data.templateUsageRate.toFixed(0)}%</div>
<div className="response-metrics__bar">
<div
className="response-metrics__bar-fill"
style={{
width: `${data.templateUsageRate}%`,
backgroundColor: getProgressColor(data.templateUsageRate, 60)
}}
/>
</div>
<div className="response-metrics__target">Ziel: > 60%</div>
</div>
{/* Priority Breakdown */}
<div className="response-metrics__priority-section">
<h4>Nach Priorität</h4>
<div className="response-metrics__priority-grid">
{Object.entries(data.byPriority).map(([priority, stats]) => (
<div key={priority} className={`response-metrics__priority response-metrics__priority--${priority}`}>
<span className="response-metrics__priority-label">
{priority.charAt(0).toUpperCase() + priority.slice(1)}
</span>
<span className="response-metrics__priority-count">{stats.count}</span>
<span className="response-metrics__priority-time">{formatTime(stats.avgResponseTime)}</span>
</div>
))}
</div>
</div>
</div>
)
}
ChannelComparison.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<ChannelComparisonProps> = ({ period }) => {
const [channels, setChannels] = useState<ChannelData[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(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 <div className="channel-comparison-loading">Lade Vergleich...</div>
}
if (error) {
return <div className="channel-comparison-error">Fehler: {error}</div>
}
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 (
<div className="channel-comparison">
<table className="channel-comparison__table">
<thead>
<tr>
<th>Kanal</th>
<th>Interaktionen</th>
<th>Sentiment</th>
<th>Response Rate</th>
<th>Ø Antwortzeit</th>
<th>Top Themen</th>
</tr>
</thead>
<tbody>
{channels.map((channel) => (
<tr key={channel.id}>
<td>
<div className="channel-comparison__channel">
<span className="channel-comparison__platform">{channel.platform}</span>
<strong>{channel.name}</strong>
</div>
</td>
<td>{channel.metrics.totalInteractions}</td>
<td>
<span className="channel-comparison__sentiment">
{getSentimentEmoji(channel.metrics.avgSentiment)}
{channel.metrics.avgSentiment > 0 ? '+' : ''}
{channel.metrics.avgSentiment.toFixed(2)}
</span>
</td>
<td>
<span className={`channel-comparison__rate ${channel.metrics.responseRate >= 90 ? 'channel-comparison__rate--good' : ''}`}>
{channel.metrics.responseRate.toFixed(0)}%
</span>
</td>
<td>{formatTime(channel.metrics.avgResponseTimeHours)}</td>
<td>
<div className="channel-comparison__topics">
{channel.metrics.topTopics.slice(0, 3).map((topic, i) => (
<span key={i} className="channel-comparison__topic-tag">{topic}</span>
))}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
TopContent.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<TopContentProps> = ({ period, channel }) => {
const [content, setContent] = useState<ContentData[]>([])
const [sortBy, setSortBy] = useState<SortBy>('comments')
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(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 (
<div className="top-content">
<div className="top-content__header">
<div className="top-content__sort">
<label>Sortieren nach:</label>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortBy)}
>
{sortOptions.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
</div>
{isLoading && <div className="top-content__loading">Lade Content...</div>}
{error && <div className="top-content__error">Fehler: {error}</div>}
{!isLoading && !error && (
<div className="top-content__list">
{content.map((item, index) => (
<div key={item.contentId} className="top-content__item">
<span className="top-content__rank">{index + 1}</span>
<div className="top-content__thumbnail">
{item.thumbnailUrl ? (
<img src={item.thumbnailUrl} alt={item.title} />
) : (
<div className="top-content__thumbnail-placeholder">🎬</div>
)}
</div>
<div className="top-content__info">
<h4 className="top-content__title">{item.title}</h4>
<span className="top-content__channel">{item.channelName}</span>
<div className="top-content__topics">
{item.topTopics.slice(0, 3).map((topic, i) => (
<span key={i} className="top-content__topic">{topic}</span>
))}
</div>
</div>
<div className="top-content__stats">
<div className="top-content__stat">
<span className="top-content__stat-value">{item.commentCount}</span>
<span className="top-content__stat-label">Interaktionen</span>
</div>
<div className="top-content__stat">
<span className="top-content__stat-value">
{getSentimentEmoji(item.avgSentiment)} {item.avgSentiment.toFixed(2)}
</span>
<span className="top-content__stat-label">Sentiment</span>
</div>
{item.medicalQuestions > 0 && (
<div className="top-content__stat top-content__stat--medical">
<span className="top-content__stat-value">⚕️ {item.medicalQuestions}</span>
<span className="top-content__stat-label">Med. Fragen</span>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
)
}
TopicCloud.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<TopicCloudProps> = ({ period, channel }) => {
const [topics, setTopics] = useState<TopicData[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(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 <div className="topic-cloud-loading">Lade Themen...</div>
}
if (error) {
return <div className="topic-cloud-error">Fehler: {error}</div>
}
if (topics.length === 0) {
return <div className="topic-cloud-empty">Keine Themen gefunden</div>
}
// 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 (
<div className="topic-cloud">
<div className="topic-cloud__container">
{topics.map((topic, index) => (
<span
key={index}
className="topic-cloud__tag"
style={{
fontSize: `${getSize(topic.count)}rem`,
color: getColor(topic.avgSentiment),
opacity: 0.6 + (getSize(topic.count) / 2 * 0.4)
}}
title={`${topic.count} Erwähnungen | Sentiment: ${topic.avgSentiment.toFixed(2)}`}
>
{topic.topic}
</span>
))}
</div>
<div className="topic-cloud__legend">
<span className="topic-cloud__legend-item">
<span style={{ color: '#10B981' }}>●</span> Positiv
</span>
<span className="topic-cloud__legend-item">
<span style={{ color: '#6B7280' }}>●</span> Neutral
</span>
<span className="topic-cloud__legend-item">
<span style={{ color: '#EF4444' }}>●</span> Negativ
</span>
</div>
</div>
)
}
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
// 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<string, unknown> = {
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<string, unknown> = {
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
pnpm add recharts date-fns
Hinweis: Diese Dependencies sind NICHT installiert und müssen vor der Implementierung hinzugefügt werden.
7. Implementierungs-Reihenfolge
- Dependencies installieren -
pnpm add recharts date-fns - Verzeichnisstruktur anlegen -
/admin/views/community/analytics/ - API Endpoints (alle 6 implementieren)
- page.tsx mit Haupt-Dashboard-Komponente
- KPICards Komponente
- SentimentTrendChart mit Recharts
- ResponseMetrics Komponente
- ChannelComparison Tabelle
- TopContent Liste
- TopicCloud Tag-Wolke
- SCSS Styling mit BEM-Konvention
- 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
-
Keine externen API-Calls – alle Daten kommen aus der PostgreSQL-Datenbank
-
Payload Local API verwenden für Datenbankabfragen:
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, }) -
date-fns für Datumsberechnungen verwenden (subDays, differenceInHours, etc.)
-
Caching – API-Responses können für 5 Minuten gecacht werden:
return NextResponse.json(data, { headers: { 'Cache-Control': 'private, max-age=300', }, }) -
Performance: Bei großen Datenmengen (>10k Interactions) ggf. direkte SQL-Aggregation über Drizzle ORM verwenden
-
Authentifizierung: Alle API-Endpoints müssen User-Authentifizierung prüfen wie in
/api/community/stats -
Sentiment-Werte beachten:
sentimentist ein String mit 6 möglichen WertensentimentScoreist numerisch (-1 bis +1)confidenceist 0-100 (Prozent), nicht 0-1!
-
Status-Werte beachten: 7 verschiedene Status-Werte, nicht 6
10. Deliverables
Nach Abschluss solltest du haben:
- ✅ Alle 6 API-Endpoints funktionsfähig
- ✅ Dashboard-View im Admin-Panel unter
/admin/community/analytics - ✅ Alle 6 Komponenten implementiert
- ✅ SCSS vollständig mit BEM-Konvention
- ✅ Mobile-responsiv getestet (3 Breakpoints)
- ✅ Keine TypeScript-Fehler
- ✅ Keine Console-Errors
- ✅ 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.