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