mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 20:54:11 +00:00
- Add .claude/ configuration (agents, commands, hooks, get-shit-done workflows) - Add prompts/ directory with development planning documents - Add scripts/setup-tenants/ with tenant configuration - Add docs/screenshots/ - Remove obsolete phase2.2-corrections-report.md - Update pnpm-lock.yaml - Update detect-secrets.sh to ignore setup.sh (env var usage, not secrets) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1734 lines
49 KiB
Markdown
1734 lines
49 KiB
Markdown
# 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: < 4h</div>
|
||
</div>
|
||
|
||
{/* Resolution Rate */}
|
||
<div className="response-metrics__item">
|
||
<div className="response-metrics__label">Resolution Rate</div>
|
||
<div className="response-metrics__value">{data.resolutionRate.toFixed(0)}%</div>
|
||
<div className="response-metrics__bar">
|
||
<div
|
||
className="response-metrics__bar-fill"
|
||
style={{
|
||
width: `${data.resolutionRate}%`,
|
||
backgroundColor: getProgressColor(data.resolutionRate, 90)
|
||
}}
|
||
/>
|
||
</div>
|
||
<div className="response-metrics__target">Ziel: > 90%</div>
|
||
</div>
|
||
|
||
{/* Escalation Rate */}
|
||
<div className="response-metrics__item">
|
||
<div className="response-metrics__label">Eskalationsrate</div>
|
||
<div className="response-metrics__value">{data.escalationRate.toFixed(1)}%</div>
|
||
<div className="response-metrics__bar">
|
||
<div
|
||
className="response-metrics__bar-fill"
|
||
style={{
|
||
width: `${Math.min(data.escalationRate * 10, 100)}%`,
|
||
backgroundColor: getProgressColor(data.escalationRate, 5, true)
|
||
}}
|
||
/>
|
||
</div>
|
||
<div className="response-metrics__target">Ziel: < 5%</div>
|
||
</div>
|
||
|
||
{/* Template Usage */}
|
||
<div className="response-metrics__item">
|
||
<div className="response-metrics__label">Template-Nutzung</div>
|
||
<div className="response-metrics__value">{data.templateUsageRate.toFixed(0)}%</div>
|
||
<div className="response-metrics__bar">
|
||
<div
|
||
className="response-metrics__bar-fill"
|
||
style={{
|
||
width: `${data.templateUsageRate}%`,
|
||
backgroundColor: getProgressColor(data.templateUsageRate, 60)
|
||
}}
|
||
/>
|
||
</div>
|
||
<div className="response-metrics__target">Ziel: > 60%</div>
|
||
</div>
|
||
|
||
{/* Priority Breakdown */}
|
||
<div className="response-metrics__priority-section">
|
||
<h4>Nach Priorität</h4>
|
||
<div className="response-metrics__priority-grid">
|
||
{Object.entries(data.byPriority).map(([priority, stats]) => (
|
||
<div key={priority} className={`response-metrics__priority response-metrics__priority--${priority}`}>
|
||
<span className="response-metrics__priority-label">
|
||
{priority.charAt(0).toUpperCase() + priority.slice(1)}
|
||
</span>
|
||
<span className="response-metrics__priority-count">{stats.count}</span>
|
||
<span className="response-metrics__priority-time">{formatTime(stats.avgResponseTime)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
### ChannelComparison.tsx
|
||
|
||
```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.
|