mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 18:34:13 +00:00
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:
parent
74b251edea
commit
22592bf759
17 changed files with 3349 additions and 8 deletions
615
docs/reports/community-phase1-implementation.md
Normal file
615
docs/reports/community-phase1-implementation.md
Normal 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
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
685
src/app/(payload)/admin/views/community/analytics/analytics.scss
Normal file
685
src/app/(payload)/admin/views/community/analytics/analytics.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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: < 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
17
src/app/(payload)/admin/views/community/analytics/page.tsx
Normal file
17
src/app/(payload)/admin/views/community/analytics/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,21 +1,17 @@
|
|||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { DefaultTemplate } from '@payloadcms/next/templates'
|
||||
import { Gutter } from '@payloadcms/ui'
|
||||
import { CommunityInbox } from './CommunityInbox'
|
||||
|
||||
import './inbox.scss'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Community Inbox',
|
||||
description: 'Manage community interactions across all platforms',
|
||||
}
|
||||
|
||||
export default function CommunityInboxPage() {
|
||||
return (
|
||||
<DefaultTemplate>
|
||||
<div className="community-inbox-page">
|
||||
<Gutter>
|
||||
<CommunityInbox />
|
||||
</Gutter>
|
||||
</DefaultTemplate>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
209
src/app/(payload)/api/community/analytics/overview/route.ts
Normal file
209
src/app/(payload)/api/community/analytics/overview/route.ts
Normal 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'
|
||||
|
|
@ -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'
|
||||
|
|
@ -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'
|
||||
183
src/app/(payload)/api/community/analytics/top-content/route.ts
Normal file
183
src/app/(payload)/api/community/analytics/top-content/route.ts
Normal 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'
|
||||
135
src/app/(payload)/api/community/analytics/topic-cloud/route.ts
Normal file
135
src/app/(payload)/api/community/analytics/topic-cloud/route.ts
Normal 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'
|
||||
Loading…
Reference in a new issue