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

49 KiB
Raw Permalink Blame History

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: &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

// 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

  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:

    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:

    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.