From 22592bf75968888ad06de0ba1a7f0ac6bb7daab3 Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Fri, 16 Jan 2026 11:05:39 +0000 Subject: [PATCH] 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 --- .../community-phase1-implementation.md | 615 ++++++++++++++++ .../analytics/AnalyticsDashboard.tsx | 142 ++++ .../views/community/analytics/analytics.scss | 685 ++++++++++++++++++ .../components/ChannelComparison.tsx | 126 ++++ .../analytics/components/KPICards.tsx | 165 +++++ .../analytics/components/ResponseMetrics.tsx | 177 +++++ .../components/SentimentTrendChart.tsx | 165 +++++ .../analytics/components/TopContent.tsx | 139 ++++ .../analytics/components/TopicCloud.tsx | 105 +++ .../admin/views/community/analytics/page.tsx | 17 + .../admin/views/community/inbox/page.tsx | 12 +- .../analytics/channel-comparison/route.ts | 159 ++++ .../api/community/analytics/overview/route.ts | 209 ++++++ .../analytics/response-metrics/route.ts | 197 +++++ .../analytics/sentiment-trend/route.ts | 126 ++++ .../community/analytics/top-content/route.ts | 183 +++++ .../community/analytics/topic-cloud/route.ts | 135 ++++ 17 files changed, 3349 insertions(+), 8 deletions(-) create mode 100644 docs/reports/community-phase1-implementation.md create mode 100644 src/app/(payload)/admin/views/community/analytics/AnalyticsDashboard.tsx create mode 100644 src/app/(payload)/admin/views/community/analytics/analytics.scss create mode 100644 src/app/(payload)/admin/views/community/analytics/components/ChannelComparison.tsx create mode 100644 src/app/(payload)/admin/views/community/analytics/components/KPICards.tsx create mode 100644 src/app/(payload)/admin/views/community/analytics/components/ResponseMetrics.tsx create mode 100644 src/app/(payload)/admin/views/community/analytics/components/SentimentTrendChart.tsx create mode 100644 src/app/(payload)/admin/views/community/analytics/components/TopContent.tsx create mode 100644 src/app/(payload)/admin/views/community/analytics/components/TopicCloud.tsx create mode 100644 src/app/(payload)/admin/views/community/analytics/page.tsx create mode 100644 src/app/(payload)/api/community/analytics/channel-comparison/route.ts create mode 100644 src/app/(payload)/api/community/analytics/overview/route.ts create mode 100644 src/app/(payload)/api/community/analytics/response-metrics/route.ts create mode 100644 src/app/(payload)/api/community/analytics/sentiment-trend/route.ts create mode 100644 src/app/(payload)/api/community/analytics/top-content/route.ts create mode 100644 src/app/(payload)/api/community/analytics/topic-cloud/route.ts diff --git a/docs/reports/community-phase1-implementation.md b/docs/reports/community-phase1-implementation.md new file mode 100644 index 0000000..845bd72 --- /dev/null +++ b/docs/reports/community-phase1-implementation.md @@ -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 + + // Lädt aktive Regeln (sortiert nach Priorität) + private async loadActiveRules(): Promise + + // Prüft ob Trigger matcht + private matchesTrigger(interaction: Interaction, rule: Rule): boolean + + // Führt Aktionen aus + private executeActions(interaction: Interaction, actions: Action[]): Promise +} +``` + +### 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 diff --git a/src/app/(payload)/admin/views/community/analytics/AnalyticsDashboard.tsx b/src/app/(payload)/admin/views/community/analytics/AnalyticsDashboard.tsx new file mode 100644 index 0000000..8518881 --- /dev/null +++ b/src/app/(payload)/admin/views/community/analytics/AnalyticsDashboard.tsx @@ -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('30d') + const [channel, setChannel] = useState('all') + const [channels, setChannels] = useState([]) + + // 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 = { + '7d': '7 Tage', + '30d': '30 Tage', + '90d': '90 Tage', + } + + return ( +
+ {/* Header */} +
+
+ +

Community Analytics

+

Performance-Übersicht aller Kanäle

+
+ +
+ {/* Period Selector */} +
+ + +
+ + {/* Channel Filter */} +
+ + +
+
+
+ + {/* KPI Cards Row */} +
+ +
+ + {/* Charts Grid - Row 1 */} +
+
+

Sentiment-Trend

+ +
+ +
+

Response-Metriken

+ +
+
+ + {/* Charts Grid - Row 2 */} +
+
+

Kanal-Vergleich

+ +
+ +
+

Themen-Wolke

+ +
+
+ + {/* Top Content */} +
+

Top Content nach Engagement

+ +
+
+ ) +} diff --git a/src/app/(payload)/admin/views/community/analytics/analytics.scss b/src/app/(payload)/admin/views/community/analytics/analytics.scss new file mode 100644 index 0000000..26074b2 --- /dev/null +++ b/src/app/(payload)/admin/views/community/analytics/analytics.scss @@ -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; + } +} diff --git a/src/app/(payload)/admin/views/community/analytics/components/ChannelComparison.tsx b/src/app/(payload)/admin/views/community/analytics/components/ChannelComparison.tsx new file mode 100644 index 0000000..3e95dd2 --- /dev/null +++ b/src/app/(payload)/admin/views/community/analytics/components/ChannelComparison.tsx @@ -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 = ({ period }) => { + const [channels, setChannels] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(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
Lade Vergleich...
+ } + + if (error) { + return
Fehler: {error}
+ } + + if (channels.length === 0) { + return
Keine Kanäle gefunden
+ } + + 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 ( +
+ + + + + + + + + + + + + {channels.map((channel) => ( + + + + + + + + + ))} + +
KanalInteraktionenSentimentResponseØ ZeitTop Themen
+
+ {channel.platform} + {channel.name} +
+
{channel.metrics.totalInteractions} + + {getSentimentEmoji(channel.metrics.avgSentiment)} + {channel.metrics.avgSentiment > 0 ? '+' : ''} + {channel.metrics.avgSentiment.toFixed(2)} + + + = 90 ? 'channel-comparison__rate--good' : ''}`} + > + {channel.metrics.responseRate.toFixed(0)}% + + {formatTime(channel.metrics.avgResponseTimeHours)} +
+ {channel.metrics.topTopics.slice(0, 3).map((topic, i) => ( + + {topic} + + ))} +
+
+
+ ) +} diff --git a/src/app/(payload)/admin/views/community/analytics/components/KPICards.tsx b/src/app/(payload)/admin/views/community/analytics/components/KPICards.tsx new file mode 100644 index 0000000..b1d90fa --- /dev/null +++ b/src/app/(payload)/admin/views/community/analytics/components/KPICards.tsx @@ -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 = ({ period, channel }) => { + const [data, setData] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(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) => ( +
+
+
+ ))} + + ) + } + + if (error || !data) { + return
Fehler beim Laden der Daten
+ } + + 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 */} +
+
💬
+
{data.newInteractions}
+
Neue Interaktionen
+
+ {getTrendIcon(data.comparison.totalInteractions)}{' '} + {Math.abs(data.comparison.totalInteractions)}% vs. Vorperiode +
+
+ + {/* Response Rate */} +
+
+
{data.responseRate.toFixed(0)}%
+
Response Rate
+
+ {getTrendIcon(data.comparison.responseRate)}{' '} + {Math.abs(data.comparison.responseRate).toFixed(1)}% +
+
+ + {/* Ø Antwortzeit */} +
+
⏱️
+
{formatTime(data.avgResponseTimeHours)}
+
Ø Antwortzeit
+
Median
+
+ + {/* Sentiment Score */} +
+
+ {getSentimentEmoji(data.avgSentimentScore)} +
+
+ {data.avgSentimentScore > 0 ? '+' : ''} + {data.avgSentimentScore.toFixed(2)} +
+
Sentiment Score
+
+ {getTrendIcon(data.comparison.avgSentimentScore)}{' '} + {Math.abs(data.comparison.avgSentimentScore).toFixed(2)} +
+
+ + {/* Medical Flags */} +
+
0 ? 'kpi-card__icon--red' : 'kpi-card__icon--gray'}`} + > + ⚕️ +
+
{data.medicalQuestions}
+
Med. Anfragen
+ {data.escalations > 0 && ( +
+ ⚠️ {data.escalations} Eskalationen +
+ )} +
+ + ) +} diff --git a/src/app/(payload)/admin/views/community/analytics/components/ResponseMetrics.tsx b/src/app/(payload)/admin/views/community/analytics/components/ResponseMetrics.tsx new file mode 100644 index 0000000..9a32956 --- /dev/null +++ b/src/app/(payload)/admin/views/community/analytics/components/ResponseMetrics.tsx @@ -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 = ({ period, channel }) => { + const [data, setData] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(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
Lade Metriken...
+ } + + if (error || !data) { + return
Fehler beim Laden
+ } + + 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 ( +
+ {/* Response Time */} +
+
Erste Antwort (Median)
+
{formatTime(data.firstResponseTime.median)}
+
+
+
+
Ziel: < 4h
+
+ + {/* Resolution Rate */} +
+
Resolution Rate
+
{data.resolutionRate.toFixed(0)}%
+
+
+
+
Ziel: > 90%
+
+ + {/* Escalation Rate */} +
+
Eskalationsrate
+
{data.escalationRate.toFixed(1)}%
+
+
+
+
Ziel: < 5%
+
+ + {/* Template Usage */} +
+
Template-Nutzung
+
{data.templateUsageRate.toFixed(0)}%
+
+
+
+
Ziel: > 60%
+
+ + {/* Priority Breakdown */} +
+

Nach Priorität

+
+ {Object.entries(data.byPriority).map(([priority, stats]) => ( +
+ + {priority.charAt(0).toUpperCase() + priority.slice(1)} + + {stats.count} + + {formatTime(stats.avgResponseTime)} + +
+ ))} +
+
+
+ ) +} diff --git a/src/app/(payload)/admin/views/community/analytics/components/SentimentTrendChart.tsx b/src/app/(payload)/admin/views/community/analytics/components/SentimentTrendChart.tsx new file mode 100644 index 0000000..160fc34 --- /dev/null +++ b/src/app/(payload)/admin/views/community/analytics/components/SentimentTrendChart.tsx @@ -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 = ({ period, channel }) => { + const [data, setData] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(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
Lade Daten...
+ } + + if (error) { + return
Fehler: {error}
+ } + + if (data.length === 0) { + return
Keine Daten für den gewählten Zeitraum
+ } + + const formatDate = (dateStr: string): string => { + const date = new Date(dateStr) + return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }) + } + + return ( +
+ + + + + + + + + + + + + + + + + +
+ ) +} diff --git a/src/app/(payload)/admin/views/community/analytics/components/TopContent.tsx b/src/app/(payload)/admin/views/community/analytics/components/TopContent.tsx new file mode 100644 index 0000000..fc52d72 --- /dev/null +++ b/src/app/(payload)/admin/views/community/analytics/components/TopContent.tsx @@ -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 = ({ period, channel }) => { + const [content, setContent] = useState([]) + const [sortBy, setSortBy] = useState('comments') + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(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 ( +
+
+
+ + +
+
+ + {isLoading &&
Lade Content...
} + + {error &&
Fehler: {error}
} + + {!isLoading && !error && content.length === 0 && ( +
Kein Content mit Interaktionen gefunden
+ )} + + {!isLoading && !error && content.length > 0 && ( +
+ {content.map((item, index) => ( +
+ {index + 1} + +
+ {item.thumbnailUrl ? ( + {item.title} + ) : ( +
🎬
+ )} +
+ +
+

{item.title}

+ {item.channelName} +
+ {item.topTopics.slice(0, 3).map((topic, i) => ( + + {topic} + + ))} +
+
+ +
+
+ {item.commentCount} + Interaktionen +
+
+ + {getSentimentEmoji(item.avgSentiment)} {item.avgSentiment.toFixed(2)} + + Sentiment +
+ {item.medicalQuestions > 0 && ( +
+ ⚕️ {item.medicalQuestions} + Med. Fragen +
+ )} +
+
+ ))} +
+ )} +
+ ) +} diff --git a/src/app/(payload)/admin/views/community/analytics/components/TopicCloud.tsx b/src/app/(payload)/admin/views/community/analytics/components/TopicCloud.tsx new file mode 100644 index 0000000..9e3aec4 --- /dev/null +++ b/src/app/(payload)/admin/views/community/analytics/components/TopicCloud.tsx @@ -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 = ({ period, channel }) => { + const [topics, setTopics] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(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
Lade Themen...
+ } + + if (error) { + return
Fehler: {error}
+ } + + if (topics.length === 0) { + return
Keine Themen gefunden
+ } + + // 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 ( +
+
+ {topics.map((topic, index) => ( + + {topic.topic} + + ))} +
+ +
+ + Positiv + + + Neutral + + + Negativ + +
+
+ ) +} diff --git a/src/app/(payload)/admin/views/community/analytics/page.tsx b/src/app/(payload)/admin/views/community/analytics/page.tsx new file mode 100644 index 0000000..b68e3cb --- /dev/null +++ b/src/app/(payload)/admin/views/community/analytics/page.tsx @@ -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 ( +
+ + + +
+ ) +} diff --git a/src/app/(payload)/admin/views/community/inbox/page.tsx b/src/app/(payload)/admin/views/community/inbox/page.tsx index f24f95d..791846a 100644 --- a/src/app/(payload)/admin/views/community/inbox/page.tsx +++ b/src/app/(payload)/admin/views/community/inbox/page.tsx @@ -1,21 +1,17 @@ +'use client' + import React from 'react' -import { DefaultTemplate } from '@payloadcms/next/templates' import { Gutter } from '@payloadcms/ui' import { CommunityInbox } from './CommunityInbox' import './inbox.scss' -export const metadata = { - title: 'Community Inbox', - description: 'Manage community interactions across all platforms', -} - export default function CommunityInboxPage() { return ( - +
- +
) } diff --git a/src/app/(payload)/api/community/analytics/channel-comparison/route.ts b/src/app/(payload)/api/community/analytics/channel-comparison/route.ts new file mode 100644 index 0000000..c3a3208 --- /dev/null +++ b/src/app/(payload)/api/community/analytics/channel-comparison/route.ts @@ -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 = {} + 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' diff --git a/src/app/(payload)/api/community/analytics/overview/route.ts b/src/app/(payload)/api/community/analytics/overview/route.ts new file mode 100644 index 0000000..2dc9aff --- /dev/null +++ b/src/app/(payload)/api/community/analytics/overview/route.ts @@ -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 = { + 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 = { + 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' diff --git a/src/app/(payload)/api/community/analytics/response-metrics/route.ts b/src/app/(payload)/api/community/analytics/response-metrics/route.ts new file mode 100644 index 0000000..ff89cb8 --- /dev/null +++ b/src/app/(payload)/api/community/analytics/response-metrics/route.ts @@ -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 = { + 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 = { + 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 = {} + + 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 = {} + + 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' diff --git a/src/app/(payload)/api/community/analytics/sentiment-trend/route.ts b/src/app/(payload)/api/community/analytics/sentiment-trend/route.ts new file mode 100644 index 0000000..c1d59dd --- /dev/null +++ b/src/app/(payload)/api/community/analytics/sentiment-trend/route.ts @@ -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 = { + 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' diff --git a/src/app/(payload)/api/community/analytics/top-content/route.ts b/src/app/(payload)/api/community/analytics/top-content/route.ts new file mode 100644 index 0000000..57f906e --- /dev/null +++ b/src/app/(payload)/api/community/analytics/top-content/route.ts @@ -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 = { + 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 = {} + 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' diff --git a/src/app/(payload)/api/community/analytics/topic-cloud/route.ts b/src/app/(payload)/api/community/analytics/topic-cloud/route.ts new file mode 100644 index 0000000..6ed70a0 --- /dev/null +++ b/src/app/(payload)/api/community/analytics/topic-cloud/route.ts @@ -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 = { + 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 + } + > = 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'