cms.c2sgmbh/prompts/phase2-analytics-dashboard-prompt.md
Martin Porwoll 77f70876f4 chore: add Claude Code config, prompts, and tenant setup scripts
- 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>
2026-01-18 10:18:05 +00:00

1734 lines
49 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<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
```typescript
{
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)
```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 (
<DefaultTemplate>
<Gutter>
<AnalyticsDashboard />
</Gutter>
</DefaultTemplate>
)
}
```
### 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<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
```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
```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
```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: &lt; 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: &gt; 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: &lt; 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: &gt; 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
```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
```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
```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
```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<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
```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.