mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 16:14:12 +00:00
870 lines
30 KiB
TypeScript
870 lines
30 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useEffect, useCallback } from 'react'
|
|
import './YouTubeAnalyticsDashboard.scss'
|
|
|
|
// Types
|
|
type Tab = 'performance' | 'pipeline' | 'goals' | 'community'
|
|
type Period = '7d' | '30d' | '90d'
|
|
|
|
interface Channel {
|
|
id: number
|
|
name: string
|
|
slug: string
|
|
}
|
|
|
|
interface VideoSummary {
|
|
id: number
|
|
title: string
|
|
thumbnailText: string
|
|
views: number
|
|
ctr: number
|
|
avgRetention: number
|
|
likes: number
|
|
}
|
|
|
|
interface PerformanceData {
|
|
stats: {
|
|
totalViews: number
|
|
totalWatchTimeHours: number
|
|
avgCtr: number
|
|
subscribersGained: number
|
|
}
|
|
comparison: {
|
|
views: number
|
|
watchTime: number
|
|
subscribers: number
|
|
}
|
|
topVideos: VideoSummary[]
|
|
bottomVideos: VideoSummary[]
|
|
engagement: {
|
|
likes: number
|
|
comments: number
|
|
shares: number
|
|
}
|
|
}
|
|
|
|
interface PipelineData {
|
|
stats: {
|
|
inProduction: number
|
|
thisWeekCount: number
|
|
overdueTasksCount: number
|
|
pendingApprovals: number
|
|
}
|
|
pipeline: Record<string, number>
|
|
thisWeekVideos: Array<{
|
|
id: number
|
|
title: string
|
|
status: string
|
|
scheduledPublishDate: string
|
|
channel: string
|
|
}>
|
|
overdueTasks: Array<{
|
|
id: number
|
|
title: string
|
|
dueDate: string
|
|
assignedTo: string
|
|
video: string | null
|
|
}>
|
|
}
|
|
|
|
interface GoalsData {
|
|
stats: {
|
|
contentProgress: number
|
|
contentLabel: string
|
|
subscriberProgress: number
|
|
subscriberLabel: string
|
|
viewsProgress: number
|
|
viewsLabel: string
|
|
overall: number
|
|
}
|
|
goals: any[]
|
|
customGoals: Array<{
|
|
metric: string
|
|
target: string
|
|
current: string
|
|
status: string
|
|
}>
|
|
}
|
|
|
|
interface CommunityData {
|
|
stats: {
|
|
unresolvedCount: number
|
|
positivePct: number
|
|
avgResponseHours: number
|
|
escalationsCount: number
|
|
}
|
|
sentiment: Record<string, number>
|
|
topTopics: Array<{
|
|
topic: string
|
|
count: number
|
|
}>
|
|
recentUnresolved: Array<{
|
|
id: number
|
|
message: string
|
|
authorName: string
|
|
authorHandle: string
|
|
publishedAt: string
|
|
platform: string
|
|
sentiment: string
|
|
}>
|
|
}
|
|
|
|
interface ApiResponse {
|
|
tab: string
|
|
channel: string
|
|
period: string
|
|
channels: Channel[]
|
|
data: PerformanceData | PipelineData | GoalsData | CommunityData
|
|
}
|
|
|
|
const tabConfig: Array<{ key: Tab; label: string; icon: React.ReactNode }> = [
|
|
{
|
|
key: 'performance',
|
|
label: 'Performance',
|
|
icon: (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
|
<circle cx="12" cy="12" r="3" />
|
|
</svg>
|
|
),
|
|
},
|
|
{
|
|
key: 'pipeline',
|
|
label: 'Pipeline',
|
|
icon: (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
|
<path d="M2 17l10 5 10-5M2 12l10 5 10-5" />
|
|
</svg>
|
|
),
|
|
},
|
|
{
|
|
key: 'goals',
|
|
label: 'Ziele',
|
|
icon: (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<circle cx="12" cy="12" r="10" />
|
|
<circle cx="12" cy="12" r="6" />
|
|
<circle cx="12" cy="12" r="2" />
|
|
</svg>
|
|
),
|
|
},
|
|
{
|
|
key: 'community',
|
|
label: 'Community',
|
|
icon: (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
|
<circle cx="9" cy="7" r="4" />
|
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
|
</svg>
|
|
),
|
|
},
|
|
]
|
|
|
|
const periodLabels: Record<Period, string> = {
|
|
'7d': 'Letzte 7 Tage',
|
|
'30d': 'Letzte 30 Tage',
|
|
'90d': 'Letzte 90 Tage',
|
|
}
|
|
|
|
const statusLabels: Record<string, string> = {
|
|
idea: 'Idee',
|
|
script_draft: 'Skript',
|
|
script_review: 'Review',
|
|
script_approved: 'Skript',
|
|
shoot_scheduled: 'Produktion',
|
|
shot: 'Produktion',
|
|
rough_cut: 'Schnitt',
|
|
fine_cut: 'Schnitt',
|
|
final_review: 'Review',
|
|
approved: 'Bereit',
|
|
upload_scheduled: 'Bereit',
|
|
published: 'Veröffentlicht',
|
|
}
|
|
|
|
const formatNumber = (n: number): string => {
|
|
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`
|
|
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`
|
|
return n.toString()
|
|
}
|
|
|
|
const formatDate = (dateString: string): string => {
|
|
return new Date(dateString).toLocaleString('de-DE', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})
|
|
}
|
|
|
|
const renderTrend = (value: number): React.ReactNode => {
|
|
if (value === 0) return null
|
|
const isUp = value > 0
|
|
return (
|
|
<span
|
|
className={`yt-analytics__trend ${isUp ? 'yt-analytics__trend--up' : 'yt-analytics__trend--down'}`}
|
|
>
|
|
<svg
|
|
width="12"
|
|
height="12"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
{isUp ? (
|
|
<polyline points="18 15 12 9 6 15" />
|
|
) : (
|
|
<polyline points="6 9 12 15 18 9" />
|
|
)}
|
|
</svg>
|
|
{Math.abs(value)}%
|
|
</span>
|
|
)
|
|
}
|
|
|
|
export const YouTubeAnalyticsDashboard: React.FC = () => {
|
|
const [activeTab, setActiveTab] = useState<Tab>('performance')
|
|
const [period, setPeriod] = useState<Period>('30d')
|
|
const [channel, setChannel] = useState<string>('all')
|
|
const [channels, setChannels] = useState<Channel[]>([])
|
|
const [data, setData] = useState<ApiResponse['data'] | null>(null)
|
|
const [loading, setLoading] = useState<boolean>(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const fetchData = useCallback(async () => {
|
|
setLoading(true)
|
|
setError(null)
|
|
|
|
try {
|
|
const response = await fetch(
|
|
`/api/youtube/analytics?tab=${activeTab}&channel=${channel}&period=${period}`,
|
|
{
|
|
credentials: 'include',
|
|
},
|
|
)
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`API returned ${response.status}`)
|
|
}
|
|
|
|
const result: ApiResponse = await response.json()
|
|
|
|
if (result.channels && result.channels.length > 0) {
|
|
setChannels(result.channels)
|
|
}
|
|
|
|
setData(result.data)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Fehler beim Laden der Daten')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [activeTab, channel, period])
|
|
|
|
useEffect(() => {
|
|
fetchData()
|
|
}, [fetchData])
|
|
|
|
const renderPerformance = () => {
|
|
const perfData = data as PerformanceData
|
|
if (!perfData?.stats) return null
|
|
|
|
return (
|
|
<>
|
|
<div className="yt-analytics__stats-grid">
|
|
<div className="yt-analytics__stat-card yt-analytics__stat-card--views">
|
|
<div className="yt-analytics__stat-header">
|
|
<div className="yt-analytics__stat-icon">
|
|
<svg
|
|
width="24"
|
|
height="24"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
|
<circle cx="12" cy="12" r="3" />
|
|
</svg>
|
|
</div>
|
|
<span className="yt-analytics__stat-label">Aufrufe</span>
|
|
</div>
|
|
<div className="yt-analytics__stat-value">
|
|
{formatNumber(perfData.stats.totalViews)}
|
|
{renderTrend(perfData.comparison.views)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="yt-analytics__stat-card yt-analytics__stat-card--time">
|
|
<div className="yt-analytics__stat-header">
|
|
<div className="yt-analytics__stat-icon">
|
|
<svg
|
|
width="24"
|
|
height="24"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<circle cx="12" cy="12" r="10" />
|
|
<polyline points="12 6 12 12 16 14" />
|
|
</svg>
|
|
</div>
|
|
<span className="yt-analytics__stat-label">Wiedergabezeit</span>
|
|
</div>
|
|
<div className="yt-analytics__stat-value">
|
|
{formatNumber(perfData.stats.totalWatchTimeHours)}h
|
|
{renderTrend(perfData.comparison.watchTime)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="yt-analytics__stat-card yt-analytics__stat-card--ctr">
|
|
<div className="yt-analytics__stat-header">
|
|
<div className="yt-analytics__stat-icon">
|
|
<svg
|
|
width="24"
|
|
height="24"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<circle cx="12" cy="12" r="10" />
|
|
<circle cx="12" cy="12" r="6" />
|
|
<circle cx="12" cy="12" r="2" />
|
|
</svg>
|
|
</div>
|
|
<span className="yt-analytics__stat-label">CTR</span>
|
|
</div>
|
|
<div className="yt-analytics__stat-value">{perfData.stats.avgCtr.toFixed(1)}%</div>
|
|
</div>
|
|
|
|
<div className="yt-analytics__stat-card yt-analytics__stat-card--subs">
|
|
<div className="yt-analytics__stat-header">
|
|
<div className="yt-analytics__stat-icon">
|
|
<svg
|
|
width="24"
|
|
height="24"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
|
<circle cx="9" cy="7" r="4" />
|
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
|
</svg>
|
|
</div>
|
|
<span className="yt-analytics__stat-label">Neue Abos</span>
|
|
</div>
|
|
<div className="yt-analytics__stat-value">
|
|
{formatNumber(perfData.stats.subscribersGained)}
|
|
{renderTrend(perfData.comparison.subscribers)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{perfData.topVideos && perfData.topVideos.length > 0 && (
|
|
<div className="yt-analytics__section">
|
|
<h3 className="yt-analytics__section-title">Top 5 Videos</h3>
|
|
<div className="yt-analytics__video-list">
|
|
{perfData.topVideos.map((video) => (
|
|
<div key={video.id} className="yt-analytics__video-row">
|
|
<div className="yt-analytics__video-title">{video.title}</div>
|
|
<div className="yt-analytics__video-metric">{formatNumber(video.views)} Aufrufe</div>
|
|
<div className="yt-analytics__video-metric">{video.ctr.toFixed(1)}% CTR</div>
|
|
<div className="yt-analytics__video-metric">
|
|
{video.avgRetention.toFixed(0)}% Retention
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{perfData.bottomVideos && perfData.bottomVideos.length > 0 && (
|
|
<div className="yt-analytics__section">
|
|
<h3 className="yt-analytics__section-title">Schwächste Videos</h3>
|
|
<div className="yt-analytics__video-list">
|
|
{perfData.bottomVideos.map((video) => (
|
|
<div key={video.id} className="yt-analytics__video-row">
|
|
<div className="yt-analytics__video-title">{video.title}</div>
|
|
<div className="yt-analytics__video-metric">{formatNumber(video.views)} Aufrufe</div>
|
|
<div className="yt-analytics__video-metric">{video.ctr.toFixed(1)}% CTR</div>
|
|
<div className="yt-analytics__video-metric">
|
|
{video.avgRetention.toFixed(0)}% Retention
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="yt-analytics__section">
|
|
<h3 className="yt-analytics__section-title">Engagement</h3>
|
|
<div className="yt-analytics__engagement-grid">
|
|
<div className="yt-analytics__engagement-item">
|
|
<div className="yt-analytics__engagement-value">
|
|
{formatNumber(perfData.engagement.likes)}
|
|
</div>
|
|
<div className="yt-analytics__engagement-label">Likes</div>
|
|
</div>
|
|
<div className="yt-analytics__engagement-item">
|
|
<div className="yt-analytics__engagement-value">
|
|
{formatNumber(perfData.engagement.comments)}
|
|
</div>
|
|
<div className="yt-analytics__engagement-label">Kommentare</div>
|
|
</div>
|
|
<div className="yt-analytics__engagement-item">
|
|
<div className="yt-analytics__engagement-value">
|
|
{formatNumber(perfData.engagement.shares)}
|
|
</div>
|
|
<div className="yt-analytics__engagement-label">Shares</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
const renderPipeline = () => {
|
|
const pipeData = data as PipelineData
|
|
if (!pipeData?.pipeline) return null
|
|
|
|
// Group statuses for pipeline bar
|
|
const statusGroups: Record<string, { label: string; className: string }> = {
|
|
idea: { label: 'Idee', className: 'idea' },
|
|
script: { label: 'Skript', className: 'script' },
|
|
review: { label: 'Review', className: 'review' },
|
|
production: { label: 'Produktion', className: 'production' },
|
|
editing: { label: 'Schnitt', className: 'editing' },
|
|
ready: { label: 'Bereit', className: 'ready' },
|
|
published: { label: 'Veröffentlicht', className: 'published' },
|
|
}
|
|
|
|
const grouped = {
|
|
idea: (pipeData.pipeline.idea || 0),
|
|
script: (pipeData.pipeline.script_draft || 0) + (pipeData.pipeline.script_approved || 0),
|
|
review: (pipeData.pipeline.script_review || 0) + (pipeData.pipeline.final_review || 0),
|
|
production: (pipeData.pipeline.shoot_scheduled || 0) + (pipeData.pipeline.shot || 0),
|
|
editing: (pipeData.pipeline.rough_cut || 0) + (pipeData.pipeline.fine_cut || 0),
|
|
ready: (pipeData.pipeline.approved || 0) + (pipeData.pipeline.upload_scheduled || 0),
|
|
published: (pipeData.pipeline.published || 0),
|
|
}
|
|
|
|
const total = Object.values(grouped).reduce((sum, val) => sum + val, 0)
|
|
|
|
return (
|
|
<>
|
|
<div className="yt-analytics__stats-grid">
|
|
<div className="yt-analytics__stat-card yt-analytics__stat-card--production">
|
|
<div className="yt-analytics__stat-header">
|
|
<span className="yt-analytics__stat-label">In Produktion</span>
|
|
</div>
|
|
<div className="yt-analytics__stat-value">{pipeData.stats.inProduction}</div>
|
|
</div>
|
|
|
|
<div className="yt-analytics__stat-card yt-analytics__stat-card--success">
|
|
<div className="yt-analytics__stat-header">
|
|
<span className="yt-analytics__stat-label">Diese Woche</span>
|
|
</div>
|
|
<div className="yt-analytics__stat-value">{pipeData.stats.thisWeekCount}</div>
|
|
</div>
|
|
|
|
<div className="yt-analytics__stat-card yt-analytics__stat-card--error">
|
|
<div className="yt-analytics__stat-header">
|
|
<span className="yt-analytics__stat-label">Überfällige Tasks</span>
|
|
</div>
|
|
<div className="yt-analytics__stat-value">{pipeData.stats.overdueTasksCount}</div>
|
|
</div>
|
|
|
|
<div className="yt-analytics__stat-card yt-analytics__stat-card--warning">
|
|
<div className="yt-analytics__stat-header">
|
|
<span className="yt-analytics__stat-label">Offene Freigaben</span>
|
|
</div>
|
|
<div className="yt-analytics__stat-value">{pipeData.stats.pendingApprovals}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="yt-analytics__section">
|
|
<h3 className="yt-analytics__section-title">Pipeline Übersicht</h3>
|
|
<div className="yt-analytics__pipeline-legend">
|
|
{Object.entries(statusGroups).map(([key, { label, className }]) => (
|
|
<div key={key} className="yt-analytics__legend-item">
|
|
<div className={`yt-analytics__legend-dot yt-analytics__pipeline-segment--${className}`} />
|
|
<span>{label}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="yt-analytics__pipeline-bar">
|
|
{Object.entries(grouped).map(([key, count]) => {
|
|
const percentage = total > 0 ? (count / total) * 100 : 0
|
|
const { label, className } = statusGroups[key]
|
|
return count > 0 ? (
|
|
<div
|
|
key={key}
|
|
className={`yt-analytics__pipeline-segment yt-analytics__pipeline-segment--${className}`}
|
|
style={{ flexBasis: `${percentage}%` }}
|
|
title={`${label}: ${count}`}
|
|
>
|
|
{percentage > 5 && `${count}`}
|
|
</div>
|
|
) : null
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{pipeData.thisWeekVideos && pipeData.thisWeekVideos.length > 0 && (
|
|
<div className="yt-analytics__section">
|
|
<h3 className="yt-analytics__section-title">Nächste Veröffentlichungen</h3>
|
|
{pipeData.thisWeekVideos.map((video) => (
|
|
<div key={video.id} className="yt-analytics__schedule-item">
|
|
<div className="yt-analytics__schedule-title">{video.title}</div>
|
|
<div className="yt-analytics__schedule-date">
|
|
{formatDate(video.scheduledPublishDate)} • {video.channel}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{pipeData.overdueTasks && pipeData.overdueTasks.length > 0 && (
|
|
<div className="yt-analytics__section">
|
|
<h3 className="yt-analytics__section-title">Überfällige Aufgaben</h3>
|
|
{pipeData.overdueTasks.map((task) => (
|
|
<div key={task.id} className="yt-analytics__task-item">
|
|
<div className="yt-analytics__task-header">
|
|
<div className="yt-analytics__task-title">{task.title}</div>
|
|
<div className="yt-analytics__task-meta">Fällig: {formatDate(task.dueDate)}</div>
|
|
</div>
|
|
<div className="yt-analytics__task-detail">
|
|
{task.assignedTo}
|
|
{task.video && ` • ${task.video}`}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
const renderGoals = () => {
|
|
const goalsData = data as GoalsData
|
|
if (!goalsData?.stats) return null
|
|
|
|
return (
|
|
<>
|
|
<div className="yt-analytics__stats-grid">
|
|
<div className="yt-analytics__stat-card yt-analytics__stat-card--production">
|
|
<div className="yt-analytics__stat-header">
|
|
<span className="yt-analytics__stat-label">Content</span>
|
|
</div>
|
|
<div className="yt-analytics__stat-value">{goalsData.stats.contentLabel}</div>
|
|
</div>
|
|
|
|
<div className="yt-analytics__stat-card yt-analytics__stat-card--subs">
|
|
<div className="yt-analytics__stat-header">
|
|
<span className="yt-analytics__stat-label">Abonnenten</span>
|
|
</div>
|
|
<div className="yt-analytics__stat-value">{goalsData.stats.subscriberLabel}</div>
|
|
</div>
|
|
|
|
<div className="yt-analytics__stat-card yt-analytics__stat-card--views">
|
|
<div className="yt-analytics__stat-header">
|
|
<span className="yt-analytics__stat-label">Aufrufe</span>
|
|
</div>
|
|
<div className="yt-analytics__stat-value">{goalsData.stats.viewsLabel}</div>
|
|
</div>
|
|
|
|
<div className="yt-analytics__stat-card yt-analytics__stat-card--success">
|
|
<div className="yt-analytics__stat-header">
|
|
<span className="yt-analytics__stat-label">Gesamt</span>
|
|
</div>
|
|
<div className="yt-analytics__stat-value">{goalsData.stats.overall.toFixed(0)}%</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="yt-analytics__section">
|
|
<h3 className="yt-analytics__section-title">Fortschritt</h3>
|
|
<div className="yt-analytics__progress-item">
|
|
<div className="yt-analytics__progress-label">Content</div>
|
|
<div className="yt-analytics__progress-bar">
|
|
<div
|
|
className="yt-analytics__progress-fill"
|
|
style={{ width: `${Math.min(goalsData.stats.contentProgress, 100)}%` }}
|
|
/>
|
|
</div>
|
|
<div className="yt-analytics__progress-value">{goalsData.stats.contentLabel}</div>
|
|
</div>
|
|
|
|
<div className="yt-analytics__progress-item">
|
|
<div className="yt-analytics__progress-label">Abonnenten</div>
|
|
<div className="yt-analytics__progress-bar">
|
|
<div
|
|
className="yt-analytics__progress-fill"
|
|
style={{ width: `${Math.min(goalsData.stats.subscriberProgress, 100)}%` }}
|
|
/>
|
|
</div>
|
|
<div className="yt-analytics__progress-value">{goalsData.stats.subscriberLabel}</div>
|
|
</div>
|
|
|
|
<div className="yt-analytics__progress-item">
|
|
<div className="yt-analytics__progress-label">Aufrufe</div>
|
|
<div className="yt-analytics__progress-bar">
|
|
<div
|
|
className="yt-analytics__progress-fill"
|
|
style={{ width: `${Math.min(goalsData.stats.viewsProgress, 100)}%` }}
|
|
/>
|
|
</div>
|
|
<div className="yt-analytics__progress-value">{goalsData.stats.viewsLabel}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{goalsData.customGoals && goalsData.customGoals.length > 0 && (
|
|
<div className="yt-analytics__section">
|
|
<h3 className="yt-analytics__section-title">Benutzerdefinierte Ziele</h3>
|
|
{goalsData.customGoals.map((goal, index) => (
|
|
<div key={index} className="yt-analytics__video-row">
|
|
<div className="yt-analytics__video-title">{goal.metric}</div>
|
|
<div className="yt-analytics__video-metric">
|
|
{goal.current} / {goal.target}
|
|
</div>
|
|
<span className={`yt-analytics__status-badge yt-analytics__status-badge--${goal.status}`}>
|
|
{goal.status === 'on_track' && 'Im Plan'}
|
|
{goal.status === 'at_risk' && 'Gefährdet'}
|
|
{goal.status === 'achieved' && 'Erreicht'}
|
|
{goal.status === 'missed' && 'Verfehlt'}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
const renderCommunity = () => {
|
|
const commData = data as CommunityData
|
|
if (!commData?.stats) return null
|
|
|
|
const sentimentMax = Math.max(...Object.values(commData.sentiment))
|
|
|
|
return (
|
|
<>
|
|
<div className="yt-analytics__stats-grid">
|
|
<div className="yt-analytics__stat-card yt-analytics__stat-card--warning">
|
|
<div className="yt-analytics__stat-header">
|
|
<span className="yt-analytics__stat-label">Unbearbeitet</span>
|
|
</div>
|
|
<div className="yt-analytics__stat-value">{commData.stats.unresolvedCount}</div>
|
|
</div>
|
|
|
|
<div className="yt-analytics__stat-card yt-analytics__stat-card--success">
|
|
<div className="yt-analytics__stat-header">
|
|
<span className="yt-analytics__stat-label">Positiv</span>
|
|
</div>
|
|
<div className="yt-analytics__stat-value">{commData.stats.positivePct.toFixed(0)}%</div>
|
|
</div>
|
|
|
|
<div className="yt-analytics__stat-card yt-analytics__stat-card--production">
|
|
<div className="yt-analytics__stat-header">
|
|
<span className="yt-analytics__stat-label">Antwortzeit</span>
|
|
</div>
|
|
<div className="yt-analytics__stat-value">{commData.stats.avgResponseHours.toFixed(1)}h</div>
|
|
</div>
|
|
|
|
<div className="yt-analytics__stat-card yt-analytics__stat-card--error">
|
|
<div className="yt-analytics__stat-header">
|
|
<span className="yt-analytics__stat-label">Eskalationen</span>
|
|
</div>
|
|
<div className="yt-analytics__stat-value">{commData.stats.escalationsCount}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="yt-analytics__section">
|
|
<h3 className="yt-analytics__section-title">Sentiment</h3>
|
|
<div className="yt-analytics__sentiment-list">
|
|
{Object.entries(commData.sentiment).map(([sentiment, count]) => {
|
|
const percentage = sentimentMax > 0 ? (count / sentimentMax) * 100 : 0
|
|
return (
|
|
<div key={sentiment} className="yt-analytics__sentiment-item">
|
|
<div className="yt-analytics__sentiment-label">
|
|
{sentiment === 'positive' && 'Positiv'}
|
|
{sentiment === 'neutral' && 'Neutral'}
|
|
{sentiment === 'negative' && 'Negativ'}
|
|
{sentiment === 'question' && 'Frage'}
|
|
</div>
|
|
<div className="yt-analytics__sentiment-bar-track">
|
|
<div
|
|
className={`yt-analytics__sentiment-bar-fill yt-analytics__sentiment-bar-fill--${sentiment}`}
|
|
style={{ width: `${percentage}%` }}
|
|
/>
|
|
</div>
|
|
<div className="yt-analytics__sentiment-count">{count}</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{commData.topTopics && commData.topTopics.length > 0 && (
|
|
<div className="yt-analytics__section">
|
|
<h3 className="yt-analytics__section-title">Top-Themen</h3>
|
|
<div className="yt-analytics__topic-list">
|
|
{commData.topTopics.map((topic, index) => (
|
|
<div key={index} className="yt-analytics__topic-tag">
|
|
{topic.topic}
|
|
<span className="yt-analytics__topic-count">{topic.count}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{commData.recentUnresolved && commData.recentUnresolved.length > 0 && (
|
|
<div className="yt-analytics__section">
|
|
<h3 className="yt-analytics__section-title">Neueste unbearbeitete Kommentare</h3>
|
|
{commData.recentUnresolved.map((comment) => (
|
|
<div key={comment.id} className="yt-analytics__comment-item">
|
|
<div className="yt-analytics__comment-header">
|
|
<div className="yt-analytics__comment-author">
|
|
{comment.authorName} (@{comment.authorHandle})
|
|
</div>
|
|
<div className="yt-analytics__comment-meta">
|
|
{comment.platform} • {formatDate(comment.publishedAt)}
|
|
</div>
|
|
</div>
|
|
<div className="yt-analytics__comment-message">{comment.message}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="yt-analytics">
|
|
<div className="yt-analytics__header">
|
|
<div className="yt-analytics__title-section">
|
|
<h2>YouTube Analytics</h2>
|
|
<p>Übersicht über Performance, Pipeline, Ziele und Community</p>
|
|
</div>
|
|
<div className="yt-analytics__controls">
|
|
<select
|
|
className="yt-analytics__channel-select"
|
|
value={channel}
|
|
onChange={(e) => setChannel(e.target.value)}
|
|
>
|
|
<option value="all">Alle Kanäle</option>
|
|
{channels.map((ch) => (
|
|
<option key={ch.id} value={ch.id}>
|
|
{ch.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<select
|
|
className="yt-analytics__period-select"
|
|
value={period}
|
|
onChange={(e) => setPeriod(e.target.value as Period)}
|
|
>
|
|
{Object.entries(periodLabels).map(([key, label]) => (
|
|
<option key={key} value={key}>
|
|
{label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
className="yt-analytics__refresh-btn"
|
|
onClick={fetchData}
|
|
disabled={loading}
|
|
aria-label="Aktualisieren"
|
|
>
|
|
<svg
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
className={loading ? 'yt-analytics__spinner' : ''}
|
|
>
|
|
<path d="M21 2v6h-6M3 12a9 9 0 0 1 15-6.7L21 8M3 22v-6h6M21 12a9 9 0 0 1-15 6.7L3 16" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="yt-analytics__tabs">
|
|
{tabConfig.map((tab) => (
|
|
<button
|
|
key={tab.key}
|
|
className={`yt-analytics__tab ${activeTab === tab.key ? 'yt-analytics__tab--active' : ''}`}
|
|
onClick={() => {
|
|
if (tab.key !== activeTab) {
|
|
setData(null)
|
|
setActiveTab(tab.key)
|
|
}
|
|
}}
|
|
>
|
|
{tab.icon}
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{loading && !data && (
|
|
<div className="yt-analytics__loading">
|
|
<svg
|
|
width="40"
|
|
height="40"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
className="yt-analytics__spinner"
|
|
>
|
|
<path d="M21 2v6h-6M3 12a9 9 0 0 1 15-6.7L21 8M3 22v-6h6M21 12a9 9 0 0 1-15 6.7L3 16" />
|
|
</svg>
|
|
<p>Lade Daten...</p>
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="yt-analytics__error">
|
|
<svg
|
|
width="24"
|
|
height="24"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<circle cx="12" cy="12" r="10" />
|
|
<line x1="12" y1="8" x2="12" y2="12" />
|
|
<line x1="12" y1="16" x2="12.01" y2="16" />
|
|
</svg>
|
|
<p>{error}</p>
|
|
</div>
|
|
)}
|
|
|
|
{!loading && !error && data && (
|
|
<div className="yt-analytics__content">
|
|
{activeTab === 'performance' && renderPerformance()}
|
|
{activeTab === 'pipeline' && renderPipeline()}
|
|
{activeTab === 'goals' && renderGoals()}
|
|
{activeTab === 'community' && renderCommunity()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default YouTubeAnalyticsDashboard
|