fix(Community): convert custom views to client components

- Fix Server Components render error by using 'use client' directive
- Remove DefaultTemplate which requires server-side props
- Add Community Analytics Dashboard view with charts
- Add Analytics API endpoints (overview, sentiment, response metrics, etc.)
- Add implementation report for design AI

The custom views now work correctly within Payload's RootLayout context.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2026-01-16 11:05:39 +00:00
parent 74b251edea
commit 22592bf759
17 changed files with 3349 additions and 8 deletions

View file

@ -0,0 +1,615 @@
# Community Management Phase 1 - Implementation Report
**Datum:** 15. Januar 2026
**Version:** 1.0
**Status:** ✅ Abgeschlossen
---
## Executive Summary
Community Management Phase 1 ist ein umfassendes System zur Verwaltung von YouTube-Kommentaren und Social-Media-Interaktionen innerhalb des Payload CMS Admin-Panels. Das System ermöglicht effizientes Community-Management durch automatisierte Regeln, Sentiment-Analyse und eine zentrale Inbox-Ansicht.
---
## 1. Systemarchitektur
### 1.1 Komponenten-Übersicht
```
┌─────────────────────────────────────────────────────────────────────┐
│ PAYLOAD CMS ADMIN │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Community │ │ Community │ │ Community │ │
│ │ Inbox View │ │ Rules │ │ Templates │ │
│ │ (Custom View) │ │ (Collection) │ │ (Collection) │ │
│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │ │
│ └────────────────────┼────────────────────┘ │
│ │ │
│ ┌───────────▼───────────┐ │
│ │ Rules Engine │ │
│ │ (Auto-Processing) │ │
│ └───────────┬───────────┘ │
│ │ │
└────────────────────────────────┼────────────────────────────────────┘
┌────────────────────────────────┼────────────────────────────────────┐
│ DATENBANK (PostgreSQL) │
├────────────────────────────────┼────────────────────────────────────┤
│ │ │
│ ┌─────────────────┐ ┌────────▼────────┐ ┌─────────────────┐ │
│ │ social_platforms│ │ community_ │ │ community_ │ │
│ │ │ │ interactions │ │ templates │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ social_accounts │ │ community_rules │ │ youtube_channels│ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ EXTERNE SERVICES │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ YouTube API │ │ OpenAI API │ │ Export Service │ │
│ │ (Comments) │ │ (Sentiment) │ │ (PDF/Excel) │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
### 1.2 Datenfluss
```
YouTube Kommentar
┌───────────────────┐
│ Cron Job │ ← Alle 15 Minuten
│ (syncAllComments)│
└────────┬──────────┘
┌───────────────────┐
│ YouTube API │
│ (Comments.list) │
└────────┬──────────┘
┌───────────────────┐
│ Datenbank │
│ (Interaction) │
└────────┬──────────┘
┌───────────────────┐
│ Rules Engine │ ← Automatische Verarbeitung
│ (processNew) │
└────────┬──────────┘
├─► Priorität setzen
├─► Zuweisen an User
├─► Flags setzen (Medical, Spam, etc.)
├─► Template vorschlagen
└─► Notification senden
┌───────────────────┐
│ Community Inbox │ ← Admin-Ansicht
│ (React View) │
└───────────────────┘
```
---
## 2. Collections (Datenmodelle)
### 2.1 Community Interactions
**Slug:** `community-interactions`
**Beschreibung:** Zentrale Sammlung aller Social-Media-Interaktionen
| Feldgruppe | Felder | Beschreibung |
|------------|--------|--------------|
| **Quelle** | platform, socialAccount, linkedContent | Woher kommt die Interaction |
| **Typ & ID** | type, externalId, parentInteraction | Art und Identifikation |
| **Autor** | authorName, authorHandle, authorAvatarUrl, authorIsVerified, authorIsSubscriber, authorSubscriberCount | Informationen zum Kommentator |
| **Inhalt** | message, messageHtml, publishedAt, attachments | Der eigentliche Kommentar |
| **Analyse** | sentiment, sentimentScore, confidence, language, topics, suggestedTemplate, suggestedReply | AI-Analyse |
| **Flags** | isMedicalQuestion, requiresEscalation, isSpam, isFromInfluencer | Automatische Markierungen |
| **Workflow** | status, priority, assignedTo, responseDeadline | Bearbeitungsstatus |
| **Antwort** | responseText, usedTemplate, sentAt, sentBy, externalReplyId | Gesendete Antwort |
| **Engagement** | likes, replies, isHearted, isPinned | YouTube-Metriken |
**Status-Workflow:**
```
new → in_review → waiting → answered → closed
escalated
```
**Prioritäten:**
- `urgent` - Sofort bearbeiten (rot)
- `high` - Heute bearbeiten (orange)
- `normal` - Standard (grau)
- `low` - Wenn Zeit ist (grün)
### 2.2 Community Rules
**Slug:** `community-rules`
**Beschreibung:** Automatisierungsregeln für Interactions
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| name | Text | Name der Regel |
| priority | Number | Ausführungsreihenfolge (niedrig = zuerst) |
| isActive | Checkbox | Regel aktiv? |
| channel | Relationship | Optional: Nur für diesen Kanal |
| platforms | Relationship[] | Optional: Nur für diese Plattformen |
**Trigger-Typen:**
| Trigger | Beschreibung | Zusätzliche Felder |
|---------|--------------|-------------------|
| `keyword` | Keyword-Match | keywords[] mit matchType (contains/exact/regex) |
| `sentiment` | Sentiment-Wert | sentimentValues[] (positive/negative/neutral/question) |
| `question_detected` | Frage erkannt | - |
| `medical_detected` | Medizinischer Inhalt | - |
| `influencer` | Influencer-Kommentar | influencerMinFollowers (default: 10000) |
| `all_new` | Alle neuen Kommentare | - |
| `contains_link` | Enthält URL | - |
| `contains_email` | Enthält E-Mail | - |
**Aktionen:**
| Aktion | Wert-Parameter | Beschreibung |
|--------|---------------|--------------|
| `set_priority` | urgent/high/normal/low | Priorität setzen |
| `assign_to` | targetUser | Zuweisen an Benutzer |
| `set_flag` | medical/spam/escalate | Flag setzen |
| `suggest_template` | targetTemplate | Antwort-Vorlage vorschlagen |
| `send_notification` | targetUser | Benachrichtigung senden |
| `flag_medical` | - | Als medizinische Frage markieren |
| `escalate` | - | Eskalieren |
| `mark_spam` | - | Als Spam markieren |
| `set_deadline` | Stunden | Deadline setzen |
### 2.3 Community Templates
**Slug:** `community-templates`
**Beschreibung:** Wiederverwendbare Antwort-Vorlagen
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| name | Text (lokalisiert) | Template-Name |
| category | Select | Kategorie (greeting, faq, support, etc.) |
| template | Textarea (lokalisiert) | Template-Text mit Variablen |
| variables | Array | Definierte Variablen |
| channel | Relationship | Optional: Kanal-spezifisch |
| platforms | Relationship[] | Plattform-Filter |
| autoSuggest.keywords | Array | Keywords für Auto-Suggest |
| requiresReview | Checkbox | Vor dem Senden prüfen? |
**Variablen-Syntax:** `{{variableName}}`
**Beispiel-Template:**
```
Hallo {{authorName}}! 👋
Vielen Dank für deine Frage zu {{topic}}.
{{customResponse}}
Liebe Grüße,
{{channelName}} Team
```
### 2.4 Social Platforms
**Slug:** `social-platforms`
**Beschreibung:** Konfiguration der Social-Media-Plattformen
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| name | Text | Plattform-Name |
| slug | Text | Eindeutiger Slug |
| icon | Text | Emoji oder Icon |
| color | Text | Hex-Farbcode |
| apiConfig | Group | API-Konfiguration |
| rateLimits | Group | API Rate Limits |
| interactionTypes | Array | Unterstützte Interaction-Typen |
**Vorkonfigurierte Plattform:**
- YouTube (slug: `youtube`, icon: 📺, color: #FF0000)
### 2.5 Social Accounts
**Slug:** `social-accounts`
**Beschreibung:** Verbundene Social-Media-Accounts
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| platform | Relationship | Zugehörige Plattform |
| linkedChannel | Relationship | Verknüpfter YouTube-Kanal |
| displayName | Text | Anzeigename |
| accountHandle | Text | @handle |
| externalId | Text | Externe ID |
| credentials | Group | OAuth-Tokens (verschlüsselt) |
| syncSettings | Group | Sync-Konfiguration |
---
## 3. Admin Views
### 3.1 Community Inbox
**Route:** `/admin/views/community/inbox`
**Komponente:** `CommunityInbox.tsx`
#### Layout (Desktop)
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Community Inbox [Sync] [Export] │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Stats: 📊 127 Total │ 🆕 23 Neu │ 🔴 5 Urgent │ 🏥 3 Medical │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Filters: [Status ▼] [Priority ▼] [Platform ▼] [🔍 Search...] │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────┬──────────────────────────────────────┐ │
│ │ │ │ │
│ │ 📋 Interaction List │ 📄 Detail View │ │
│ │ │ │ │
│ │ ┌────────────────────┐ │ ┌────────────────────────────────┐ │ │
│ │ │ 🔴 @user1 │ │ │ 👤 @selectedUser │ │ │
│ │ │ "Tolles Video!" │◄─┼──│ ───────────────────────────── │ │ │
│ │ │ 2h ago • YouTube │ │ │ "Der vollständige Kommentar │ │ │
│ │ └────────────────────┘ │ │ wird hier angezeigt..." │ │ │
│ │ │ │ │ │ │
│ │ ┌────────────────────┐ │ │ ───────────────────────────── │ │ │
│ │ │ 🟡 @user2 │ │ │ │ │ │
│ │ │ "Frage zu..." │ │ │ 📊 Analyse │ │ │
│ │ │ 5h ago • YouTube │ │ │ Sentiment: 😊 Positiv (0.85) │ │ │
│ │ └────────────────────┘ │ │ Topics: #frage #produkt │ │ │
│ │ │ │ │ │ │
│ │ ┌────────────────────┐ │ │ ───────────────────────────── │ │ │
│ │ │ 🟢 @user3 │ │ │ │ │ │
│ │ │ "Danke für..." │ │ │ 💬 Antwort │ │ │
│ │ │ 1d ago • YouTube │ │ │ ┌────────────────────────────┐ │ │ │
│ │ └────────────────────┘ │ │ │ Template: [Auswählen ▼] │ │ │ │
│ │ │ │ │ │ │ │ │
│ │ [Load More...] │ │ │ [Antwort-Text hier...] │ │ │ │
│ │ │ │ │ │ │ │ │
│ │ │ │ └────────────────────────────┘ │ │ │
│ │ │ │ │ │ │
│ │ │ │ [💾 Speichern] [📤 Senden] │ │ │
│ │ │ │ │ │ │
│ └──────────────────────────┴──────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
#### Layout (Mobile)
```
┌─────────────────────────┐
│ Community Inbox [≡] │
├─────────────────────────┤
│ │
│ [Liste] [Detail] [⚙️] │
│ │
│ ┌───────────────────┐ │
│ │ 🔴 @user1 │ │
│ │ "Tolles Video!" │ │
│ │ 2h ago │ │
│ └───────────────────┘ │
│ │
│ ┌───────────────────┐ │
│ │ 🟡 @user2 │ │
│ │ "Frage zu..." │ │
│ │ 5h ago │ │
│ └───────────────────┘ │
│ │
│ ┌───────────────────┐ │
│ │ 🟢 @user3 │ │
│ │ "Danke für..." │ │
│ │ 1d ago │ │
│ └───────────────────┘ │
│ │
└─────────────────────────┘
```
#### Farbkodierung
| Element | Farbe | Bedeutung |
|---------|-------|-----------|
| Priorität Urgent | `#dc2626` (Rot) | Sofort bearbeiten |
| Priorität High | `#f59e0b` (Orange) | Heute bearbeiten |
| Priorität Normal | `#6b7280` (Grau) | Standard |
| Priorität Low | `#10b981` (Grün) | Niedrig |
| Status New | `#3b82f6` (Blau) | Neu eingegangen |
| Status Answered | `#10b981` (Grün) | Beantwortet |
| Medical Flag | `#dc2626` (Rot) + 🏥 | Medizinische Frage |
| Influencer Flag | `#8b5cf6` (Lila) + ⭐ | Influencer |
#### Interaktionen
1. **Klick auf Interaction:** Öffnet Detail-Ansicht
2. **Filter anwenden:** Liste wird gefiltert
3. **Template auswählen:** Text wird in Antwort-Feld eingefügt
4. **Senden:** Antwort wird via YouTube API gepostet
5. **Sync:** Manuelle Synchronisation aller Kommentare
6. **Export:** Modal mit Format-Auswahl (PDF/Excel/CSV)
---
## 4. API Endpoints
### 4.1 Community APIs
| Endpoint | Methode | Beschreibung |
|----------|---------|--------------|
| `/api/community/stats` | GET | Statistiken (total, new, urgent, medical) |
| `/api/community/export` | POST | Export als PDF/Excel/CSV |
| `/api/community/reply` | POST | Antwort senden (via YouTube API) |
| `/api/community/sync-comments` | POST | Manuelle Synchronisation |
### 4.2 YouTube OAuth APIs
| Endpoint | Methode | Beschreibung |
|----------|---------|--------------|
| `/api/youtube/auth` | GET | OAuth-Flow starten |
| `/api/youtube/callback` | GET | OAuth-Callback |
| `/api/youtube/refresh-token` | POST | Token erneuern |
### 4.3 Collection APIs (Standard Payload)
| Endpoint | Beschreibung |
|----------|--------------|
| `/api/community-interactions` | CRUD für Interactions |
| `/api/community-rules` | CRUD für Rules |
| `/api/community-templates` | CRUD für Templates |
| `/api/social-platforms` | CRUD für Platforms |
| `/api/social-accounts` | CRUD für Accounts |
---
## 5. Rules Engine
### 5.1 Funktionsweise
```typescript
class RulesEngine {
// Verarbeitet neue Interactions
async processNewInteraction(interaction: Interaction): Promise<void>
// Lädt aktive Regeln (sortiert nach Priorität)
private async loadActiveRules(): Promise<Rule[]>
// Prüft ob Trigger matcht
private matchesTrigger(interaction: Interaction, rule: Rule): boolean
// Führt Aktionen aus
private executeActions(interaction: Interaction, actions: Action[]): Promise<void>
}
```
### 5.2 Beispiel-Regeln
**Regel 1: Medizinische Fragen**
```json
{
"name": "Medizinische Fragen - Priorität hoch",
"priority": 10,
"trigger": {
"type": "keyword",
"keywords": [
{ "keyword": "arzt", "matchType": "contains" },
{ "keyword": "schmerz", "matchType": "contains" },
{ "keyword": "medikament", "matchType": "contains" }
]
},
"actions": [
{ "action": "set_priority", "value": "urgent" },
{ "action": "flag_medical" },
{ "action": "send_notification", "targetUser": "manager" }
]
}
```
**Regel 2: Influencer-Kommentare**
```json
{
"name": "Influencer - Priorität urgent",
"priority": 5,
"trigger": {
"type": "influencer",
"influencerMinFollowers": 10000
},
"actions": [
{ "action": "set_priority", "value": "urgent" },
{ "action": "assign_to", "targetUser": "creator" }
]
}
```
**Regel 3: Spam-Erkennung**
```json
{
"name": "Spam-Erkennung",
"priority": 1,
"trigger": {
"type": "keyword",
"keywords": [
{ "keyword": "free.*money", "matchType": "regex" },
{ "keyword": "click.*link", "matchType": "regex" }
]
},
"actions": [
{ "action": "mark_spam" },
{ "action": "set_priority", "value": "low" }
]
}
```
---
## 6. Scheduled Jobs
### 6.1 Comment Sync Cron
**Schedule:** `*/15 * * * *` (alle 15 Minuten)
**Job:** `syncAllComments`
```
1. Lade alle aktiven Social Accounts mit gültigen Tokens
2. Für jeden Account:
a. Prüfe Rate Limits
b. Hole neue Kommentare via YouTube API
c. Speichere als Community Interactions
d. Verarbeite mit Rules Engine
3. Logge Ergebnis
```
### 6.2 Token Refresh
**Trigger:** Vor Ablauf des Access Tokens
**Aktion:** Refresh Token verwenden für neuen Access Token
---
## 7. Berechtigungen
### 7.1 Rollen-Hierarchie
```
Super Admin
├── YouTube Manager ──► Voller Zugriff auf YouTube & Community
├── Community Manager ──► Voller Zugriff auf Community
├── Community Moderator ──► Lesen & Antworten
└── Community Viewer ──► Nur Lesen
```
### 7.2 Access Control Matrix
| Aktion | Viewer | Moderator | Manager | Super Admin |
|--------|--------|-----------|---------|-------------|
| Interactions lesen | ✅ | ✅ | ✅ | ✅ |
| Interactions bearbeiten | ❌ | ✅ | ✅ | ✅ |
| Antworten senden | ❌ | ✅ | ✅ | ✅ |
| Rules verwalten | ❌ | ❌ | ✅ | ✅ |
| Templates verwalten | ❌ | ❌ | ✅ | ✅ |
| Export | ❌ | ✅ | ✅ | ✅ |
| OAuth verbinden | ❌ | ❌ | ✅ | ✅ |
---
## 8. Technische Details
### 8.1 Datenbank-Schema
**Haupttabellen:**
- `community_interactions` - Haupttabelle
- `community_interactions_attachments` - Anhänge (Array)
- `community_interactions_analysis_topics` - Topics (Array)
- `community_rules` - Regeln
- `community_rules_trigger_keywords` - Keywords (Array)
- `community_rules_trigger_sentiment_values` - Sentiment-Werte (hasMany Select)
- `community_rules_actions` - Aktionen (Array)
- `community_rules_rels` - Relationships
- `community_templates` - Templates
- `community_templates_locales` - Lokalisierte Felder
- `community_templates_variables` - Variablen (Array)
- `social_platforms` - Plattformen
- `social_accounts` - Accounts
### 8.2 Payload CMS Version
- **Payload:** 3.69.0
- **Next.js:** 15.5.9
- **React:** 19.2.3
- **Datenbank:** PostgreSQL 17.6
### 8.3 Abhängigkeiten
```json
{
"googleapis": "^140.0.0",
"exceljs": "^4.4.0"
}
```
---
## 9. Zukünftige Erweiterungen (Phase 2+)
### Geplant:
- [ ] Instagram Integration
- [ ] TikTok Integration
- [ ] AI-basierte Sentiment-Analyse (OpenAI)
- [ ] Auto-Reply für einfache Fragen
- [ ] Team-Zuweisungsregeln
- [ ] Analytics Dashboard
- [ ] Bulk-Aktionen
- [ ] Keyboard Shortcuts
- [ ] Notification Center
---
## 10. Dateistruktur
```
src/
├── collections/
│ ├── CommunityInteractions.ts
│ ├── CommunityRules.ts
│ ├── CommunityTemplates.ts
│ ├── SocialPlatforms.ts
│ └── SocialAccounts.ts
├── lib/
│ ├── communityAccess.ts # Access Control
│ ├── services/
│ │ └── RulesEngine.ts # Automatisierung
│ └── integrations/
│ └── youtube/
│ └── oauth.ts # YouTube OAuth
├── jobs/
│ ├── scheduler.ts # Cron-Jobs
│ └── syncAllComments.ts # Comment Sync
└── app/(payload)/
├── admin/views/community/
│ └── inbox/
│ ├── page.tsx # Page Route
│ ├── CommunityInbox.tsx # React Component
│ └── inbox.scss # Styles
└── api/
├── community/
│ ├── export/route.ts
│ ├── stats/route.ts
│ └── reply/route.ts
└── youtube/
├── auth/route.ts
├── callback/route.ts
└── refresh-token/route.ts
```
---
**Erstellt von:** Claude Opus 4.5
**Für:** Design AI / Dokumentation
**Projekt:** Payload CMS Multi-Tenant

View file

@ -0,0 +1,142 @@
'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">
<div className="analytics-dashboard__nav">
<a href="/admin/views/community/inbox" className="analytics-dashboard__back-link">
Inbox
</a>
</div>
<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>
)
}

View file

@ -0,0 +1,685 @@
// Community Analytics Dashboard Styles
// =====================================
// Mobile-first responsive design für das Analytics Dashboard
.analytics-dashboard {
// Header Section
&__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
@media (max-width: 768px) {
flex-direction: column;
}
}
&__title {
h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 600;
color: var(--theme-text);
}
}
&__nav {
margin-bottom: 0.5rem;
}
&__back-link {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--theme-elevation-600);
text-decoration: none;
transition: color 0.2s ease;
&:hover {
color: var(--theme-text);
}
}
&__subtitle {
margin: 0.25rem 0 0;
font-size: 0.875rem;
color: var(--theme-elevation-800);
}
&__filters {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
&__filter {
display: flex;
align-items: center;
gap: 0.5rem;
label {
font-size: 0.875rem;
color: var(--theme-elevation-800);
white-space: nowrap;
}
}
&__select {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
border: 1px solid var(--theme-elevation-150);
border-radius: 6px;
background: var(--theme-elevation-0);
color: var(--theme-text);
cursor: pointer;
&:hover {
border-color: var(--theme-elevation-250);
}
&:focus {
outline: none;
border-color: var(--theme-elevation-400);
}
}
// KPI Cards Grid
&__kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
@media (max-width: 480px) {
grid-template-columns: repeat(2, 1fr);
}
}
// Charts Grid
&__charts-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 1.5rem;
margin-bottom: 1.5rem;
@media (max-width: 1024px) {
grid-template-columns: 1fr;
}
}
// Card Base
&__card {
background: var(--theme-elevation-50);
border-radius: 8px;
padding: 1.25rem;
h3 {
margin: 0 0 1rem;
font-size: 1rem;
font-weight: 600;
color: var(--theme-text);
}
&--wide {
grid-column: span 1;
}
}
}
// KPI Card Component
.kpi-card {
background: var(--theme-elevation-50);
border-radius: 8px;
padding: 1.25rem;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
&__icon {
font-size: 1.5rem;
margin-bottom: 0.5rem;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--theme-elevation-100);
&--blue { background: rgba(59, 130, 246, 0.15); }
&--green { background: rgba(16, 185, 129, 0.15); }
&--purple { background: rgba(139, 92, 246, 0.15); }
&--yellow { background: rgba(245, 158, 11, 0.15); }
&--red { background: rgba(239, 68, 68, 0.15); }
&--gray { background: var(--theme-elevation-100); }
}
&__value {
font-size: 1.75rem;
font-weight: 700;
color: var(--theme-text);
line-height: 1.2;
}
&__label {
font-size: 0.75rem;
color: var(--theme-elevation-800);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 0.25rem;
margin-bottom: 0.5rem;
}
&__trend {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
&--up {
color: #10B981;
background: rgba(16, 185, 129, 0.1);
}
&--down {
color: #EF4444;
background: rgba(239, 68, 68, 0.1);
}
&--neutral {
color: var(--theme-elevation-600);
background: var(--theme-elevation-100);
}
}
&--loading {
min-height: 140px;
justify-content: center;
}
&__skeleton {
width: 60%;
height: 24px;
background: linear-gradient(90deg,
var(--theme-elevation-100) 25%,
var(--theme-elevation-150) 50%,
var(--theme-elevation-100) 75%
);
background-size: 200% 100%;
animation: skeleton-loading 1.5s infinite;
border-radius: 4px;
}
&--error {
background: rgba(239, 68, 68, 0.1);
color: #EF4444;
padding: 2rem;
}
}
@keyframes skeleton-loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
// Chart States
.chart-loading,
.chart-error,
.chart-empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
color: var(--theme-elevation-600);
font-size: 0.875rem;
}
.chart-error {
color: #EF4444;
}
// Sentiment Trend Chart
.sentiment-trend-chart {
min-height: 300px;
.recharts-legend-wrapper {
font-size: 0.75rem;
}
}
// Response Metrics
.response-metrics {
display: flex;
flex-direction: column;
gap: 1rem;
&__item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
&__label {
font-size: 0.75rem;
color: var(--theme-elevation-800);
font-weight: 500;
}
&__value {
font-size: 1.25rem;
font-weight: 700;
color: var(--theme-text);
}
&__bar {
height: 6px;
background: var(--theme-elevation-150);
border-radius: 3px;
overflow: hidden;
}
&__bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.5s ease;
}
&__target {
font-size: 0.625rem;
color: var(--theme-elevation-600);
}
&__priority-section {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--theme-elevation-150);
h4 {
margin: 0 0 0.75rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--theme-text);
}
}
&__priority-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
&__priority {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
&--urgent {
background: rgba(239, 68, 68, 0.1);
.response-metrics__priority-label { color: #EF4444; }
}
&--high {
background: rgba(245, 158, 11, 0.1);
.response-metrics__priority-label { color: #F59E0B; }
}
&--normal {
background: rgba(59, 130, 246, 0.1);
.response-metrics__priority-label { color: #3B82F6; }
}
&--low {
background: var(--theme-elevation-100);
.response-metrics__priority-label { color: var(--theme-elevation-600); }
}
}
&__priority-label {
font-weight: 600;
}
&__priority-count {
font-weight: 700;
color: var(--theme-text);
}
&__priority-time {
color: var(--theme-elevation-600);
}
}
.metrics-loading,
.metrics-error {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
color: var(--theme-elevation-600);
font-size: 0.875rem;
}
// Channel Comparison Table
.channel-comparison {
overflow-x: auto;
&__table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
th, td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--theme-elevation-150);
}
th {
font-weight: 600;
color: var(--theme-elevation-800);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
td {
color: var(--theme-text);
}
tr:hover td {
background: var(--theme-elevation-100);
}
}
&__channel {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
&__platform {
font-size: 0.625rem;
color: var(--theme-elevation-600);
text-transform: uppercase;
letter-spacing: 0.5px;
}
&__sentiment {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
&__rate {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-weight: 600;
background: var(--theme-elevation-100);
&--good {
background: rgba(16, 185, 129, 0.15);
color: #10B981;
}
}
&__topics {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
&__topic-tag {
display: inline-block;
padding: 0.125rem 0.375rem;
font-size: 0.625rem;
background: var(--theme-elevation-150);
border-radius: 3px;
color: var(--theme-elevation-700);
}
&-loading,
&-error,
&-empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 150px;
color: var(--theme-elevation-600);
font-size: 0.875rem;
}
}
// Top Content List
.top-content {
&__header {
display: flex;
justify-content: flex-end;
margin-bottom: 1rem;
}
&__sort {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
label {
color: var(--theme-elevation-600);
}
select {
padding: 0.375rem 0.5rem;
font-size: 0.875rem;
border: 1px solid var(--theme-elevation-150);
border-radius: 4px;
background: var(--theme-elevation-0);
color: var(--theme-text);
}
}
&__loading,
&__error,
&__empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 150px;
color: var(--theme-elevation-600);
font-size: 0.875rem;
}
&__list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
&__item {
display: grid;
grid-template-columns: 32px 80px 1fr auto;
gap: 1rem;
align-items: center;
padding: 0.75rem;
background: var(--theme-elevation-0);
border-radius: 6px;
border: 1px solid var(--theme-elevation-100);
@media (max-width: 768px) {
grid-template-columns: 32px 60px 1fr;
grid-template-rows: auto auto;
.top-content__stats {
grid-column: 1 / -1;
justify-content: flex-start;
}
}
}
&__rank {
font-size: 1.25rem;
font-weight: 700;
color: var(--theme-elevation-400);
text-align: center;
}
&__thumbnail {
width: 80px;
height: 45px;
border-radius: 4px;
overflow: hidden;
background: var(--theme-elevation-100);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
@media (max-width: 768px) {
width: 60px;
height: 34px;
}
}
&__thumbnail-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
}
&__info {
min-width: 0;
}
&__title {
margin: 0 0 0.25rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--theme-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__channel {
font-size: 0.75rem;
color: var(--theme-elevation-600);
}
&__topics {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.375rem;
}
&__topic {
display: inline-block;
padding: 0.125rem 0.375rem;
font-size: 0.625rem;
background: var(--theme-elevation-100);
border-radius: 3px;
color: var(--theme-elevation-700);
}
&__stats {
display: flex;
gap: 1.5rem;
justify-content: flex-end;
}
&__stat {
text-align: center;
&--medical {
.top-content__stat-value {
color: #EF4444;
}
}
}
&__stat-value {
display: block;
font-size: 1rem;
font-weight: 700;
color: var(--theme-text);
}
&__stat-label {
display: block;
font-size: 0.625rem;
color: var(--theme-elevation-600);
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
// Topic Cloud
.topic-cloud {
&__container {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
align-items: center;
min-height: 200px;
padding: 1rem;
}
&__tag {
display: inline-block;
padding: 0.25rem 0.5rem;
cursor: default;
transition: transform 0.2s ease;
border-radius: 4px;
&:hover {
transform: scale(1.1);
background: var(--theme-elevation-100);
}
}
&__legend {
display: flex;
justify-content: center;
gap: 1.5rem;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--theme-elevation-150);
}
&__legend-item {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--theme-elevation-600);
}
&-loading,
&-error,
&-empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
color: var(--theme-elevation-600);
font-size: 0.875rem;
}
}

View file

@ -0,0 +1,126 @@
'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>
}
if (channels.length === 0) {
return <div className="channel-comparison-empty">Keine Kanäle gefunden</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</th>
<th>Ø Zeit</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>
)
}

View file

@ -0,0 +1,165 @@
'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>
</>
)
}

View file

@ -0,0 +1,177 @@
'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 / Math.max(data.firstResponseTime.median, 0.1)) * 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>
)
}

View file

@ -0,0 +1,165 @@
'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
contentStyle={{
backgroundColor: 'var(--theme-elevation-50)',
border: '1px solid var(--theme-elevation-150)',
borderRadius: '4px',
}}
/>
<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>
)
}

View file

@ -0,0 +1,139 @@
'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 && content.length === 0 && (
<div className="top-content__empty">Kein Content mit Interaktionen gefunden</div>
)}
{!isLoading && !error && content.length > 0 && (
<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>
)
}

View file

@ -0,0 +1,105 @@
'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>
)
}

View file

@ -0,0 +1,17 @@
'use client'
import React from 'react'
import { Gutter } from '@payloadcms/ui'
import { AnalyticsDashboard } from './AnalyticsDashboard'
import './analytics.scss'
export default function CommunityAnalyticsPage() {
return (
<div className="community-analytics-page">
<Gutter>
<AnalyticsDashboard />
</Gutter>
</div>
)
}

View file

@ -1,21 +1,17 @@
'use client'
import React from 'react' import React from 'react'
import { DefaultTemplate } from '@payloadcms/next/templates'
import { Gutter } from '@payloadcms/ui' import { Gutter } from '@payloadcms/ui'
import { CommunityInbox } from './CommunityInbox' import { CommunityInbox } from './CommunityInbox'
import './inbox.scss' import './inbox.scss'
export const metadata = {
title: 'Community Inbox',
description: 'Manage community interactions across all platforms',
}
export default function CommunityInboxPage() { export default function CommunityInboxPage() {
return ( return (
<DefaultTemplate> <div className="community-inbox-page">
<Gutter> <Gutter>
<CommunityInbox /> <CommunityInbox />
</Gutter> </Gutter>
</DefaultTemplate> </div>
) )
} }

View file

@ -0,0 +1,159 @@
/**
* Community Analytics Channel Comparison API
*
* Vergleicht Performance über alle Kanäle.
*
* GET /api/community/analytics/channel-comparison
* Query: period (7d|30d|90d)
*/
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:ChannelComparison')
interface UserWithCommunityAccess {
id: number
isSuperAdmin?: boolean
is_super_admin?: boolean
roles?: string[]
}
function hasCommunityAccess(user: UserWithCommunityAccess): boolean {
if (user.isSuperAdmin || user.is_super_admin) return true
const communityRoles = ['community_manager', 'community_agent', 'admin']
return user.roles?.some((role) => communityRoles.includes(role)) ?? false
}
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const period = searchParams.get('period') || '30d'
const payload = await getPayload({ config })
// Auth check
const { user } = await payload.auth({ headers: request.headers })
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!hasCommunityAccess(user as UserWithCommunityAccess)) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Calculate date range
const now = new Date()
const days = period === '7d' ? 7 : period === '90d' ? 90 : 30
const periodStart = subDays(now, days)
// Fetch all active social accounts
const accounts = await payload.find({
collection: 'social-accounts',
where: { isActive: { equals: true } },
limit: 100,
depth: 1, // Include platform relation
})
// Fetch all interactions for the period
const interactions = await payload.find({
collection: 'community-interactions',
where: {
publishedAt: { greater_than_equal: periodStart.toISOString() },
},
limit: 10000,
})
// Calculate metrics per channel
const channels = accounts.docs.map((account) => {
const accountId = account.id
const channelInteractions = interactions.docs.filter(
(i) =>
(typeof i.socialAccount === 'object'
? i.socialAccount?.id
: i.socialAccount) === accountId,
)
const total = channelInteractions.length
const repliedCount = channelInteractions.filter((i) =>
['replied', 'resolved'].includes(i.status as string),
).length
const responseRate = total > 0 ? (repliedCount / total) * 100 : 0
// Average sentiment
const sentimentScores = channelInteractions
.filter((i) => i.analysis?.sentimentScore != null)
.map((i) => i.analysis!.sentimentScore as number)
const avgSentiment =
sentimentScores.length > 0
? sentimentScores.reduce((a, b) => a + b, 0) / sentimentScores.length
: 0
// Average response time
const responseTimes = channelInteractions
.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
// Top topics
const topicCounts: Record<string, number> = {}
channelInteractions.forEach((i) => {
if (i.analysis?.topics && Array.isArray(i.analysis.topics)) {
i.analysis.topics.forEach((t: { topic?: string }) => {
if (t.topic) {
topicCounts[t.topic] = (topicCounts[t.topic] || 0) + 1
}
})
}
})
const topTopics = Object.entries(topicCounts)
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([topic]) => topic)
// Get platform name
const platformName =
typeof account.platform === 'object'
? (account.platform as { name?: string })?.name || 'Unknown'
: 'Unknown'
return {
id: account.id,
name: account.displayName,
platform: platformName,
metrics: {
totalInteractions: total,
avgSentiment,
responseRate,
avgResponseTimeHours: avgResponseTime,
topTopics,
},
}
})
// Sort by total interactions
channels.sort((a, b) => b.metrics.totalInteractions - a.metrics.totalInteractions)
return NextResponse.json({ channels })
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error('Channel comparison error:', { error: errorMessage })
return NextResponse.json({ error: 'Failed to fetch channel comparison' }, { status: 500 })
}
}
export const dynamic = 'force-dynamic'

View file

@ -0,0 +1,209 @@
/**
* Community Analytics Overview API
*
* Liefert KPI-Übersicht für das Analytics Dashboard.
*
* GET /api/community/analytics/overview
* Query: period (7d|30d|90d), channel (all|id)
*/
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')
interface UserWithCommunityAccess {
id: number
isSuperAdmin?: boolean
is_super_admin?: boolean
roles?: string[]
}
function hasCommunityAccess(user: UserWithCommunityAccess): boolean {
if (user.isSuperAdmin || user.is_super_admin) return true
const communityRoles = ['community_manager', 'community_agent', 'admin']
return user.roles?.some((role) => communityRoles.includes(role)) ?? false
}
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 })
}
if (!hasCommunityAccess(user as UserWithCommunityAccess)) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// 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 (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'

View file

@ -0,0 +1,197 @@
/**
* Community Analytics Response Metrics API
*
* Liefert Response-Zeit und Workflow-Metriken.
*
* GET /api/community/analytics/response-metrics
* Query: period (7d|30d|90d), channel (all|id)
*/
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:ResponseMetrics')
interface UserWithCommunityAccess {
id: number
isSuperAdmin?: boolean
is_super_admin?: boolean
roles?: string[]
}
function hasCommunityAccess(user: UserWithCommunityAccess): boolean {
if (user.isSuperAdmin || user.is_super_admin) return true
const communityRoles = ['community_manager', 'community_agent', 'admin']
return user.roles?.some((role) => communityRoles.includes(role)) ?? false
}
function median(arr: number[]): number {
if (arr.length === 0) return 0
const sorted = [...arr].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
}
function percentile(arr: number[], p: number): number {
if (arr.length === 0) return 0
const sorted = [...arr].sort((a, b) => a - b)
const index = Math.ceil((p / 100) * sorted.length) - 1
return sorted[Math.max(0, index)]
}
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 })
// Auth check
const { user } = await payload.auth({ headers: request.headers })
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!hasCommunityAccess(user as UserWithCommunityAccess)) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Calculate date ranges
const now = new Date()
const days = period === '7d' ? 7 : period === '90d' ? 90 : 30
const periodStart = subDays(now, days)
const prevPeriodStart = subDays(now, days * 2)
const prevPeriodEnd = periodStart
// 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 interactions = await payload.find({
collection: 'community-interactions',
where: baseWhere,
limit: 10000,
})
const docs = interactions.docs
const total = docs.length
// Calculate response times
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 medianTime = median(responseTimes)
const p90Time = percentile(responseTimes, 90)
// Fetch previous period for trend
const prevWhere: Record<string, unknown> = {
publishedAt: {
greater_than_equal: prevPeriodStart.toISOString(),
less_than: prevPeriodEnd.toISOString(),
},
}
if (channelId !== 'all') {
prevWhere.socialAccount = { equals: parseInt(channelId) }
}
const prevInteractions = await payload.find({
collection: 'community-interactions',
where: prevWhere,
limit: 10000,
})
const prevResponseTimes = prevInteractions.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 prevMedian = median(prevResponseTimes)
const trend = prevMedian > 0 ? ((medianTime - prevMedian) / prevMedian) * 100 : 0
// Resolution rate (resolved / total)
const resolvedCount = docs.filter((i) => i.status === 'resolved').length
const resolutionRate = total > 0 ? (resolvedCount / total) * 100 : 0
// Escalation rate
const escalatedCount = docs.filter((i) => i.flags?.requiresEscalation).length
const escalationRate = total > 0 ? (escalatedCount / total) * 100 : 0
// Template usage rate (among responded)
const respondedDocs = docs.filter((i) => i.response?.text)
const templateUsedCount = respondedDocs.filter((i) => i.response?.usedTemplate).length
const templateUsageRate =
respondedDocs.length > 0 ? (templateUsedCount / respondedDocs.length) * 100 : 0
// By priority
const priorities = ['urgent', 'high', 'normal', 'low'] as const
const byPriority: Record<string, { count: number; avgResponseTime: number }> = {}
for (const priority of priorities) {
const priorityDocs = docs.filter((i) => i.priority === priority)
const priorityTimes = priorityDocs
.filter((i) => i.response?.sentAt && i.publishedAt)
.map((i) =>
differenceInHours(
new Date(i.response!.sentAt as string),
new Date(i.publishedAt as string),
),
)
byPriority[priority] = {
count: priorityDocs.length,
avgResponseTime:
priorityTimes.length > 0
? priorityTimes.reduce((a, b) => a + b, 0) / priorityTimes.length
: 0,
}
}
// By status
const statuses = ['new', 'in_review', 'waiting', 'replied', 'resolved', 'archived', 'spam'] as const
const byStatus: Record<string, number> = {}
for (const status of statuses) {
byStatus[status] = docs.filter((i) => i.status === status).length
}
return NextResponse.json({
firstResponseTime: {
median: medianTime,
p90: p90Time,
trend,
},
resolutionRate,
escalationRate,
templateUsageRate,
byPriority,
byStatus,
})
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error('Response metrics error:', { error: errorMessage })
return NextResponse.json({ error: 'Failed to fetch response metrics' }, { status: 500 })
}
}
export const dynamic = 'force-dynamic'

View file

@ -0,0 +1,126 @@
/**
* Community Analytics Sentiment Trend API
*
* Liefert Sentiment-Zeitreihe für Charts.
*
* GET /api/community/analytics/sentiment-trend
* Query: period (7d|30d|90d), channel (all|id), granularity (day|week)
*/
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import { subDays, format, eachDayOfInterval, eachWeekOfInterval } from 'date-fns'
import { createSafeLogger } from '@/lib/security'
const logger = createSafeLogger('API:SentimentTrend')
interface UserWithCommunityAccess {
id: number
isSuperAdmin?: boolean
is_super_admin?: boolean
roles?: string[]
}
function hasCommunityAccess(user: UserWithCommunityAccess): boolean {
if (user.isSuperAdmin || user.is_super_admin) return true
const communityRoles = ['community_manager', 'community_agent', 'admin']
return user.roles?.some((role) => communityRoles.includes(role)) ?? false
}
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'
let granularity = searchParams.get('granularity')
// Auto-determine granularity based on period
if (!granularity) {
granularity = period === '90d' ? 'week' : 'day'
}
const payload = await getPayload({ config })
// Auth check
const { user } = await payload.auth({ headers: request.headers })
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!hasCommunityAccess(user as UserWithCommunityAccess)) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Calculate date range
const now = new Date()
const days = period === '7d' ? 7 : period === '90d' ? 90 : 30
const periodStart = subDays(now, days)
// Build where clause
const baseWhere: Record<string, unknown> = {
publishedAt: {
greater_than_equal: periodStart.toISOString(),
},
}
if (channelId !== 'all') {
baseWhere.socialAccount = { equals: parseInt(channelId) }
}
// Fetch interactions
const interactions = await payload.find({
collection: 'community-interactions',
where: baseWhere,
limit: 10000,
})
// Generate date buckets
const intervals =
granularity === 'week'
? eachWeekOfInterval({ start: periodStart, end: now })
: eachDayOfInterval({ start: periodStart, end: now })
// Aggregate by date
const data = intervals.map((date) => {
const dateKey = format(date, 'yyyy-MM-dd')
const nextDate =
granularity === 'week' ? subDays(date, -7) : subDays(date, -1)
const dayInteractions = interactions.docs.filter((i) => {
const pubDate = new Date(i.publishedAt as string)
return pubDate >= date && pubDate < nextDate
})
const sentiments = {
positive: dayInteractions.filter((i) => i.analysis?.sentiment === 'positive').length,
neutral: dayInteractions.filter((i) => i.analysis?.sentiment === 'neutral').length,
negative: dayInteractions.filter((i) => i.analysis?.sentiment === 'negative').length,
question: dayInteractions.filter((i) => i.analysis?.sentiment === 'question').length,
gratitude: dayInteractions.filter((i) => i.analysis?.sentiment === 'gratitude').length,
frustration: dayInteractions.filter((i) => i.analysis?.sentiment === 'frustration').length,
}
const scores = dayInteractions
.filter((i) => i.analysis?.sentimentScore != null)
.map((i) => i.analysis!.sentimentScore as number)
const avgScore = scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : 0
return {
date: dateKey,
...sentiments,
avgScore,
total: dayInteractions.length,
}
})
return NextResponse.json({ data })
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error('Sentiment trend error:', { error: errorMessage })
return NextResponse.json({ error: 'Failed to fetch sentiment trend' }, { status: 500 })
}
}
export const dynamic = 'force-dynamic'

View file

@ -0,0 +1,183 @@
/**
* Community Analytics Top Content API
*
* Liefert Top-Videos/Content nach Engagement.
*
* GET /api/community/analytics/top-content
* Query: period (7d|30d|90d), channel (all|id), sortBy (comments|sentiment|medical), limit
*/
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import { subDays } from 'date-fns'
import { createSafeLogger } from '@/lib/security'
const logger = createSafeLogger('API:TopContent')
interface UserWithCommunityAccess {
id: number
isSuperAdmin?: boolean
is_super_admin?: boolean
roles?: string[]
}
function hasCommunityAccess(user: UserWithCommunityAccess): boolean {
if (user.isSuperAdmin || user.is_super_admin) return true
const communityRoles = ['community_manager', 'community_agent', 'admin']
return user.roles?.some((role) => communityRoles.includes(role)) ?? false
}
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 sortBy = searchParams.get('sortBy') || 'comments'
const limit = parseInt(searchParams.get('limit') || '10')
const payload = await getPayload({ config })
// Auth check
const { user } = await payload.auth({ headers: request.headers })
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!hasCommunityAccess(user as UserWithCommunityAccess)) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Calculate date range
const now = new Date()
const days = period === '7d' ? 7 : period === '90d' ? 90 : 30
const periodStart = subDays(now, days)
// Build where clause
const baseWhere: Record<string, unknown> = {
publishedAt: { greater_than_equal: periodStart.toISOString() },
linkedContent: { exists: true },
}
if (channelId !== 'all') {
baseWhere.socialAccount = { equals: parseInt(channelId) }
}
// Fetch interactions with linked content
const interactions = await payload.find({
collection: 'community-interactions',
where: baseWhere,
limit: 10000,
depth: 2, // Include linkedContent and socialAccount
})
// Group by content
const contentMap: Map<
number,
{
content: {
id: number
videoId?: string
title?: string
thumbnailUrl?: string
publishedAt?: string
}
channelName: string
interactions: typeof interactions.docs
}
> = new Map()
interactions.docs.forEach((i) => {
const linkedContent = i.linkedContent as {
id: number
videoId?: string
title?: string
thumbnailUrl?: string
publishedAt?: string
} | null
if (!linkedContent) return
const contentId = linkedContent.id
if (!contentMap.has(contentId)) {
const socialAccount = i.socialAccount as { displayName?: string } | null
contentMap.set(contentId, {
content: linkedContent,
channelName: socialAccount?.displayName || 'Unknown',
interactions: [],
})
}
contentMap.get(contentId)!.interactions.push(i)
})
// Calculate metrics per content
const contentMetrics = Array.from(contentMap.entries()).map(([contentId, data]) => {
const { content, channelName, interactions: contentInteractions } = data
const commentCount = contentInteractions.length
const sentimentScores = contentInteractions
.filter((i) => i.analysis?.sentimentScore != null)
.map((i) => i.analysis!.sentimentScore as number)
const avgSentiment =
sentimentScores.length > 0
? sentimentScores.reduce((a, b) => a + b, 0) / sentimentScores.length
: 0
const medicalQuestions = contentInteractions.filter(
(i) => i.flags?.isMedicalQuestion,
).length
// Top topics
const topicCounts: Record<string, number> = {}
contentInteractions.forEach((i) => {
if (i.analysis?.topics && Array.isArray(i.analysis.topics)) {
i.analysis.topics.forEach((t: { topic?: string }) => {
if (t.topic) {
topicCounts[t.topic] = (topicCounts[t.topic] || 0) + 1
}
})
}
})
const topTopics = Object.entries(topicCounts)
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([topic]) => topic)
return {
contentId,
videoId: content.videoId || '',
title: content.title || 'Untitled',
channelName,
thumbnailUrl: content.thumbnailUrl || '',
commentCount,
avgSentiment,
medicalQuestions,
topTopics,
publishedAt: content.publishedAt || '',
}
})
// Sort based on sortBy parameter
contentMetrics.sort((a, b) => {
switch (sortBy) {
case 'sentiment':
return b.avgSentiment - a.avgSentiment
case 'medical':
return b.medicalQuestions - a.medicalQuestions
default: // comments
return b.commentCount - a.commentCount
}
})
return NextResponse.json({ content: contentMetrics.slice(0, limit) })
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error('Top content error:', { error: errorMessage })
return NextResponse.json({ error: 'Failed to fetch top content' }, { status: 500 })
}
}
export const dynamic = 'force-dynamic'

View file

@ -0,0 +1,135 @@
/**
* Community Analytics Topic Cloud API
*
* Liefert aggregierte Themen für Tag-Cloud.
*
* GET /api/community/analytics/topic-cloud
* Query: period (7d|30d|90d), channel (all|id), limit
*/
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import { subDays } from 'date-fns'
import { createSafeLogger } from '@/lib/security'
const logger = createSafeLogger('API:TopicCloud')
interface UserWithCommunityAccess {
id: number
isSuperAdmin?: boolean
is_super_admin?: boolean
roles?: string[]
}
function hasCommunityAccess(user: UserWithCommunityAccess): boolean {
if (user.isSuperAdmin || user.is_super_admin) return true
const communityRoles = ['community_manager', 'community_agent', 'admin']
return user.roles?.some((role) => communityRoles.includes(role)) ?? false
}
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 limit = parseInt(searchParams.get('limit') || '30')
const payload = await getPayload({ config })
// Auth check
const { user } = await payload.auth({ headers: request.headers })
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!hasCommunityAccess(user as UserWithCommunityAccess)) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Calculate date range
const now = new Date()
const days = period === '7d' ? 7 : period === '90d' ? 90 : 30
const periodStart = subDays(now, days)
// Build where clause
const baseWhere: Record<string, unknown> = {
publishedAt: { greater_than_equal: periodStart.toISOString() },
}
if (channelId !== 'all') {
baseWhere.socialAccount = { equals: parseInt(channelId) }
}
// Fetch interactions
const interactions = await payload.find({
collection: 'community-interactions',
where: baseWhere,
limit: 10000,
depth: 1,
})
// Aggregate topics
const topicData: Map<
string,
{
count: number
sentimentScores: number[]
channels: Set<string>
}
> = new Map()
interactions.docs.forEach((i) => {
if (!i.analysis?.topics || !Array.isArray(i.analysis.topics)) return
const channelName =
typeof i.socialAccount === 'object'
? (i.socialAccount as { displayName?: string })?.displayName || 'Unknown'
: 'Unknown'
const sentimentScore = i.analysis?.sentimentScore as number | undefined
i.analysis.topics.forEach((t: { topic?: string }) => {
if (!t.topic) return
const topic = t.topic.toLowerCase().trim()
if (!topicData.has(topic)) {
topicData.set(topic, {
count: 0,
sentimentScores: [],
channels: new Set(),
})
}
const data = topicData.get(topic)!
data.count++
data.channels.add(channelName)
if (sentimentScore != null) {
data.sentimentScores.push(sentimentScore)
}
})
})
// Convert to array and calculate averages
const topics = Array.from(topicData.entries())
.map(([topic, data]) => ({
topic,
count: data.count,
avgSentiment:
data.sentimentScores.length > 0
? data.sentimentScores.reduce((a, b) => a + b, 0) / data.sentimentScores.length
: 0,
channels: Array.from(data.channels),
}))
.sort((a, b) => b.count - a.count)
.slice(0, limit)
return NextResponse.json({ topics })
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error('Topic cloud error:', { error: errorMessage })
return NextResponse.json({ error: 'Failed to fetch topic cloud' }, { status: 500 })
}
}
export const dynamic = 'force-dynamic'