mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 16:14:12 +00:00
- Add .claude/ configuration (agents, commands, hooks, get-shit-done workflows) - Add prompts/ directory with development planning documents - Add scripts/setup-tenants/ with tenant configuration - Add docs/screenshots/ - Remove obsolete phase2.2-corrections-report.md - Update pnpm-lock.yaml - Update detect-secrets.sh to ignore setup.sh (env var usage, not secrets) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1867 lines
49 KiB
Markdown
1867 lines
49 KiB
Markdown
# YouTube Operations Hub v3 – Custom Admin Views
|
||
|
||
## Hinweis
|
||
|
||
**WICHTIG:** Dieser Prompt kann derzeit NICHT implementiert werden!
|
||
|
||
Custom Admin Views sind in Payload CMS 3.x aktuell durch einen **path-to-regexp Bug** blockiert. Die Registrierung von Custom Views unter `/admin/youtube/*` führt zu Fehlern wie:
|
||
|
||
```
|
||
Error: Missing parameter name at 1
|
||
```
|
||
|
||
**Workaround-Versuch (fehlgeschlagen):**
|
||
```typescript
|
||
admin: {
|
||
views: {
|
||
youtubeCalendar: {
|
||
path: '/youtube-calendar', // Verschiedene Formate getestet
|
||
Component: YouTubeCalendarView,
|
||
},
|
||
},
|
||
},
|
||
```
|
||
|
||
**Bug-Ticket:** https://github.com/payloadcms/payload/issues/[TBD]
|
||
|
||
Dieser Prompt wird implementiert, sobald der Bug in einer zukünftigen Payload-Version behoben ist.
|
||
|
||
---
|
||
|
||
## Projektkontext
|
||
|
||
**Server:** sv-payload (10.10.181.100)
|
||
**Projekt-Pfad:** `/home/payload/payload-cms`
|
||
**Datenbank:** PostgreSQL 17 auf sv-postgres (10.10.181.101)
|
||
**Tech Stack:** Payload CMS 3.x, Next.js 15.x, TypeScript, Drizzle ORM
|
||
**Package Manager:** pnpm
|
||
|
||
**Voraussetzungen:**
|
||
- YouTube Operations Hub v1 implementiert (YouTubeChannels, YouTubeContent, YtTasks, YtNotifications)
|
||
- YouTube Operations Hub v2 implementiert (YtBatches, YtMonthlyGoals, YtScriptTemplates, YtChecklistTemplates)
|
||
|
||
---
|
||
|
||
## Aufgabe
|
||
|
||
Implementiere Custom Admin Views für das YouTube Operations Hub:
|
||
|
||
1. **Content Calendar** – Kalenderansicht aller geplanten Videos
|
||
2. **Posting Calendar** – Veröffentlichungsplan mit Timeline
|
||
3. **Batch Overview Dashboard** – Produktions-Batch Übersicht
|
||
4. **Monthly Goals Dashboard** – KPI-Tracking und Fortschritt
|
||
5. **Custom Navigation** – YouTube-spezifisches Navigationsmenü
|
||
|
||
---
|
||
|
||
## Teil 1: Content Calendar View
|
||
|
||
### Datei: `src/components/YouTubeCalendar/index.tsx`
|
||
|
||
```typescript
|
||
'use client'
|
||
|
||
import React, { useEffect, useState, useCallback } from 'react'
|
||
import { useConfig, useAuth } from '@payloadcms/ui'
|
||
import styles from './styles.module.css'
|
||
|
||
interface CalendarVideo {
|
||
id: number
|
||
title: string
|
||
format: 'short' | 'longform'
|
||
status: string
|
||
contentSeries?: string
|
||
scheduledPublishDate?: string
|
||
productionDate?: string
|
||
channel: {
|
||
id: number
|
||
name: string
|
||
}
|
||
}
|
||
|
||
interface CalendarDay {
|
||
date: Date
|
||
videos: CalendarVideo[]
|
||
isCurrentMonth: boolean
|
||
isToday: boolean
|
||
}
|
||
|
||
export const YouTubeCalendarView: React.FC = () => {
|
||
const { config } = useConfig()
|
||
const { user } = useAuth()
|
||
const [currentDate, setCurrentDate] = useState(new Date())
|
||
const [videos, setVideos] = useState<CalendarVideo[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [viewMode, setViewMode] = useState<'production' | 'posting'>('posting')
|
||
const [selectedChannel, setSelectedChannel] = useState<number | null>(null)
|
||
const [channels, setChannels] = useState<{ id: number; name: string }[]>([])
|
||
|
||
// Kanäle laden
|
||
useEffect(() => {
|
||
const fetchChannels = async () => {
|
||
try {
|
||
const res = await fetch('/api/youtube-channels?limit=100&depth=0')
|
||
const data = await res.json()
|
||
setChannels(data.docs || [])
|
||
} catch (error) {
|
||
console.error('Error fetching channels:', error)
|
||
}
|
||
}
|
||
fetchChannels()
|
||
}, [])
|
||
|
||
// Videos für den aktuellen Monat laden
|
||
useEffect(() => {
|
||
const fetchVideos = async () => {
|
||
setLoading(true)
|
||
try {
|
||
const startOfMonth = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1)
|
||
const endOfMonth = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0)
|
||
|
||
const dateField = viewMode === 'production' ? 'productionDate' : 'scheduledPublishDate'
|
||
|
||
let query = `/api/youtube-content?limit=500&depth=1`
|
||
query += `&where[${dateField}][greater_than_equal]=${startOfMonth.toISOString()}`
|
||
query += `&where[${dateField}][less_than_equal]=${endOfMonth.toISOString()}`
|
||
|
||
if (selectedChannel) {
|
||
query += `&where[channel][equals]=${selectedChannel}`
|
||
}
|
||
|
||
const res = await fetch(query)
|
||
const data = await res.json()
|
||
setVideos(data.docs || [])
|
||
} catch (error) {
|
||
console.error('Error fetching videos:', error)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
fetchVideos()
|
||
}, [currentDate, viewMode, selectedChannel])
|
||
|
||
// Kalender-Tage generieren
|
||
const generateCalendarDays = useCallback((): CalendarDay[] => {
|
||
const days: CalendarDay[] = []
|
||
const year = currentDate.getFullYear()
|
||
const month = currentDate.getMonth()
|
||
|
||
const firstDayOfMonth = new Date(year, month, 1)
|
||
const lastDayOfMonth = new Date(year, month + 1, 0)
|
||
const today = new Date()
|
||
|
||
// Tage vom vorherigen Monat (um die Woche zu füllen)
|
||
const firstDayWeekday = firstDayOfMonth.getDay() || 7 // Montag = 1
|
||
for (let i = firstDayWeekday - 1; i > 0; i--) {
|
||
const date = new Date(year, month, 1 - i)
|
||
days.push({
|
||
date,
|
||
videos: [],
|
||
isCurrentMonth: false,
|
||
isToday: false,
|
||
})
|
||
}
|
||
|
||
// Tage des aktuellen Monats
|
||
for (let day = 1; day <= lastDayOfMonth.getDate(); day++) {
|
||
const date = new Date(year, month, day)
|
||
const dateStr = date.toISOString().split('T')[0]
|
||
const dateField = viewMode === 'production' ? 'productionDate' : 'scheduledPublishDate'
|
||
|
||
const dayVideos = videos.filter((v) => {
|
||
const videoDate = v[dateField as keyof CalendarVideo] as string | undefined
|
||
return videoDate?.startsWith(dateStr)
|
||
})
|
||
|
||
days.push({
|
||
date,
|
||
videos: dayVideos,
|
||
isCurrentMonth: true,
|
||
isToday:
|
||
date.getDate() === today.getDate() &&
|
||
date.getMonth() === today.getMonth() &&
|
||
date.getFullYear() === today.getFullYear(),
|
||
})
|
||
}
|
||
|
||
// Tage vom nächsten Monat (um die letzte Woche zu füllen)
|
||
const remainingDays = 42 - days.length // 6 Wochen * 7 Tage
|
||
for (let i = 1; i <= remainingDays; i++) {
|
||
const date = new Date(year, month + 1, i)
|
||
days.push({
|
||
date,
|
||
videos: [],
|
||
isCurrentMonth: false,
|
||
isToday: false,
|
||
})
|
||
}
|
||
|
||
return days
|
||
}, [currentDate, videos, viewMode])
|
||
|
||
const navigateMonth = (direction: -1 | 1) => {
|
||
setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + direction, 1))
|
||
}
|
||
|
||
const goToToday = () => {
|
||
setCurrentDate(new Date())
|
||
}
|
||
|
||
const calendarDays = generateCalendarDays()
|
||
|
||
const getStatusColor = (status: string): string => {
|
||
const colors: Record<string, string> = {
|
||
idea: '#9CA3AF',
|
||
scripting: '#60A5FA',
|
||
production: '#FBBF24',
|
||
editing: '#F97316',
|
||
review: '#A855F7',
|
||
approved: '#34D399',
|
||
upload_scheduled: '#22D3EE',
|
||
published: '#10B981',
|
||
tracked: '#6366F1',
|
||
}
|
||
return colors[status] || '#9CA3AF'
|
||
}
|
||
|
||
const getFormatBadge = (format: string): string => {
|
||
return format === 'short' ? 'S' : 'L'
|
||
}
|
||
|
||
const weekDays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||
const monthNames = [
|
||
'Januar',
|
||
'Februar',
|
||
'März',
|
||
'April',
|
||
'Mai',
|
||
'Juni',
|
||
'Juli',
|
||
'August',
|
||
'September',
|
||
'Oktober',
|
||
'November',
|
||
'Dezember',
|
||
]
|
||
|
||
return (
|
||
<div className={styles.calendarContainer}>
|
||
{/* Header */}
|
||
<div className={styles.header}>
|
||
<div className={styles.headerLeft}>
|
||
<h1 className={styles.title}>
|
||
{viewMode === 'production' ? 'Produktionskalender' : 'Posting-Kalender'}
|
||
</h1>
|
||
<div className={styles.viewToggle}>
|
||
<button
|
||
className={`${styles.toggleBtn} ${viewMode === 'posting' ? styles.active : ''}`}
|
||
onClick={() => setViewMode('posting')}
|
||
>
|
||
Posting
|
||
</button>
|
||
<button
|
||
className={`${styles.toggleBtn} ${viewMode === 'production' ? styles.active : ''}`}
|
||
onClick={() => setViewMode('production')}
|
||
>
|
||
Produktion
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={styles.headerCenter}>
|
||
<button className={styles.navBtn} onClick={() => navigateMonth(-1)}>
|
||
‹
|
||
</button>
|
||
<span className={styles.currentMonth}>
|
||
{monthNames[currentDate.getMonth()]} {currentDate.getFullYear()}
|
||
</span>
|
||
<button className={styles.navBtn} onClick={() => navigateMonth(1)}>
|
||
›
|
||
</button>
|
||
<button className={styles.todayBtn} onClick={goToToday}>
|
||
Heute
|
||
</button>
|
||
</div>
|
||
|
||
<div className={styles.headerRight}>
|
||
<select
|
||
className={styles.channelSelect}
|
||
value={selectedChannel || ''}
|
||
onChange={(e) => setSelectedChannel(e.target.value ? Number(e.target.value) : null)}
|
||
>
|
||
<option value="">Alle Kanäle</option>
|
||
{channels.map((ch) => (
|
||
<option key={ch.id} value={ch.id}>
|
||
{ch.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Legende */}
|
||
<div className={styles.legend}>
|
||
<span className={styles.legendItem}>
|
||
<span className={styles.formatBadge} data-format="short">
|
||
S
|
||
</span>{' '}
|
||
Short
|
||
</span>
|
||
<span className={styles.legendItem}>
|
||
<span className={styles.formatBadge} data-format="longform">
|
||
L
|
||
</span>{' '}
|
||
Longform
|
||
</span>
|
||
<span className={styles.legendSeparator}>|</span>
|
||
{Object.entries({
|
||
idea: 'Idee',
|
||
scripting: 'Script',
|
||
production: 'Produktion',
|
||
editing: 'Schnitt',
|
||
review: 'Review',
|
||
approved: 'Freigegeben',
|
||
published: 'Veröffentlicht',
|
||
}).map(([status, label]) => (
|
||
<span key={status} className={styles.legendItem}>
|
||
<span className={styles.statusDot} style={{ backgroundColor: getStatusColor(status) }} />
|
||
{label}
|
||
</span>
|
||
))}
|
||
</div>
|
||
|
||
{/* Kalender Grid */}
|
||
{loading ? (
|
||
<div className={styles.loading}>Lade Kalender...</div>
|
||
) : (
|
||
<div className={styles.calendarGrid}>
|
||
{/* Wochentage Header */}
|
||
{weekDays.map((day) => (
|
||
<div key={day} className={styles.weekDayHeader}>
|
||
{day}
|
||
</div>
|
||
))}
|
||
|
||
{/* Kalender-Tage */}
|
||
{calendarDays.map((day, index) => (
|
||
<div
|
||
key={index}
|
||
className={`${styles.calendarDay} ${!day.isCurrentMonth ? styles.otherMonth : ''} ${day.isToday ? styles.today : ''}`}
|
||
>
|
||
<div className={styles.dayNumber}>{day.date.getDate()}</div>
|
||
<div className={styles.dayContent}>
|
||
{day.videos.slice(0, 4).map((video) => (
|
||
<a
|
||
key={video.id}
|
||
href={`/admin/collections/youtube-content/${video.id}`}
|
||
className={styles.videoItem}
|
||
style={{ borderLeftColor: getStatusColor(video.status) }}
|
||
title={video.title}
|
||
>
|
||
<span className={styles.formatBadge} data-format={video.format}>
|
||
{getFormatBadge(video.format)}
|
||
</span>
|
||
<span className={styles.videoTitle}>{video.title}</span>
|
||
</a>
|
||
))}
|
||
{day.videos.length > 4 && (
|
||
<div className={styles.moreVideos}>+{day.videos.length - 4} weitere</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Stats Footer */}
|
||
<div className={styles.statsFooter}>
|
||
<div className={styles.stat}>
|
||
<span className={styles.statLabel}>Videos im Monat:</span>
|
||
<span className={styles.statValue}>{videos.length}</span>
|
||
</div>
|
||
<div className={styles.stat}>
|
||
<span className={styles.statLabel}>Shorts:</span>
|
||
<span className={styles.statValue}>{videos.filter((v) => v.format === 'short').length}</span>
|
||
</div>
|
||
<div className={styles.stat}>
|
||
<span className={styles.statLabel}>Longforms:</span>
|
||
<span className={styles.statValue}>{videos.filter((v) => v.format === 'longform').length}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default YouTubeCalendarView
|
||
```
|
||
|
||
### Datei: `src/components/YouTubeCalendar/styles.module.css`
|
||
|
||
```css
|
||
.calendarContainer {
|
||
padding: 24px;
|
||
background: var(--theme-elevation-0);
|
||
min-height: 100vh;
|
||
}
|
||
|
||
.header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 20px;
|
||
flex-wrap: wrap;
|
||
gap: 16px;
|
||
}
|
||
|
||
.headerLeft {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 20px;
|
||
}
|
||
|
||
.title {
|
||
font-size: 24px;
|
||
font-weight: 600;
|
||
color: var(--theme-text);
|
||
margin: 0;
|
||
}
|
||
|
||
.viewToggle {
|
||
display: flex;
|
||
background: var(--theme-elevation-100);
|
||
border-radius: 6px;
|
||
padding: 4px;
|
||
}
|
||
|
||
.toggleBtn {
|
||
padding: 8px 16px;
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--theme-text);
|
||
cursor: pointer;
|
||
border-radius: 4px;
|
||
font-size: 14px;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.toggleBtn:hover {
|
||
background: var(--theme-elevation-200);
|
||
}
|
||
|
||
.toggleBtn.active {
|
||
background: var(--theme-elevation-500);
|
||
color: white;
|
||
}
|
||
|
||
.headerCenter {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.navBtn {
|
||
width: 36px;
|
||
height: 36px;
|
||
border: 1px solid var(--theme-elevation-200);
|
||
background: var(--theme-elevation-50);
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 20px;
|
||
color: var(--theme-text);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.navBtn:hover {
|
||
background: var(--theme-elevation-100);
|
||
}
|
||
|
||
.currentMonth {
|
||
font-size: 18px;
|
||
font-weight: 500;
|
||
min-width: 160px;
|
||
text-align: center;
|
||
color: var(--theme-text);
|
||
}
|
||
|
||
.todayBtn {
|
||
padding: 8px 16px;
|
||
border: 1px solid var(--theme-elevation-200);
|
||
background: var(--theme-elevation-50);
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
color: var(--theme-text);
|
||
}
|
||
|
||
.todayBtn:hover {
|
||
background: var(--theme-elevation-100);
|
||
}
|
||
|
||
.headerRight {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.channelSelect {
|
||
padding: 8px 12px;
|
||
border: 1px solid var(--theme-elevation-200);
|
||
background: var(--theme-elevation-50);
|
||
border-radius: 6px;
|
||
font-size: 14px;
|
||
color: var(--theme-text);
|
||
min-width: 180px;
|
||
}
|
||
|
||
.legend {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 16px;
|
||
padding: 12px 16px;
|
||
background: var(--theme-elevation-50);
|
||
border-radius: 8px;
|
||
margin-bottom: 20px;
|
||
align-items: center;
|
||
}
|
||
|
||
.legendItem {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 13px;
|
||
color: var(--theme-text);
|
||
}
|
||
|
||
.legendSeparator {
|
||
color: var(--theme-elevation-300);
|
||
}
|
||
|
||
.statusDot {
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.formatBadge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 18px;
|
||
height: 18px;
|
||
border-radius: 4px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: white;
|
||
}
|
||
|
||
.formatBadge[data-format='short'] {
|
||
background: #f97316;
|
||
}
|
||
|
||
.formatBadge[data-format='longform'] {
|
||
background: #3b82f6;
|
||
}
|
||
|
||
.loading {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
height: 400px;
|
||
color: var(--theme-text);
|
||
font-size: 16px;
|
||
}
|
||
|
||
.calendarGrid {
|
||
display: grid;
|
||
grid-template-columns: repeat(7, 1fr);
|
||
gap: 1px;
|
||
background: var(--theme-elevation-200);
|
||
border: 1px solid var(--theme-elevation-200);
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.weekDayHeader {
|
||
padding: 12px;
|
||
text-align: center;
|
||
font-weight: 600;
|
||
font-size: 13px;
|
||
color: var(--theme-text);
|
||
background: var(--theme-elevation-100);
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.calendarDay {
|
||
min-height: 120px;
|
||
padding: 8px;
|
||
background: var(--theme-elevation-0);
|
||
position: relative;
|
||
}
|
||
|
||
.calendarDay.otherMonth {
|
||
background: var(--theme-elevation-50);
|
||
}
|
||
|
||
.calendarDay.otherMonth .dayNumber {
|
||
color: var(--theme-elevation-400);
|
||
}
|
||
|
||
.calendarDay.today {
|
||
background: var(--theme-elevation-100);
|
||
}
|
||
|
||
.calendarDay.today .dayNumber {
|
||
background: var(--theme-elevation-500);
|
||
color: white;
|
||
border-radius: 50%;
|
||
width: 28px;
|
||
height: 28px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.dayNumber {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: var(--theme-text);
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.dayContent {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.videoItem {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 4px 6px;
|
||
background: var(--theme-elevation-50);
|
||
border-radius: 4px;
|
||
border-left: 3px solid;
|
||
text-decoration: none;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
}
|
||
|
||
.videoItem:hover {
|
||
background: var(--theme-elevation-100);
|
||
transform: translateX(2px);
|
||
}
|
||
|
||
.videoTitle {
|
||
font-size: 12px;
|
||
color: var(--theme-text);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
flex: 1;
|
||
}
|
||
|
||
.moreVideos {
|
||
font-size: 11px;
|
||
color: var(--theme-elevation-500);
|
||
padding: 4px 6px;
|
||
text-align: center;
|
||
}
|
||
|
||
.statsFooter {
|
||
display: flex;
|
||
gap: 32px;
|
||
padding: 16px;
|
||
margin-top: 20px;
|
||
background: var(--theme-elevation-50);
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.stat {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.statLabel {
|
||
font-size: 14px;
|
||
color: var(--theme-elevation-500);
|
||
}
|
||
|
||
.statValue {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: var(--theme-text);
|
||
}
|
||
|
||
/* Responsive */
|
||
@media (max-width: 1200px) {
|
||
.calendarDay {
|
||
min-height: 100px;
|
||
}
|
||
|
||
.videoItem {
|
||
padding: 3px 4px;
|
||
}
|
||
|
||
.videoTitle {
|
||
font-size: 11px;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.header {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
|
||
.headerLeft,
|
||
.headerCenter,
|
||
.headerRight {
|
||
justify-content: center;
|
||
}
|
||
|
||
.calendarDay {
|
||
min-height: 80px;
|
||
padding: 4px;
|
||
}
|
||
|
||
.dayNumber {
|
||
font-size: 12px;
|
||
}
|
||
|
||
.formatBadge {
|
||
display: none;
|
||
}
|
||
|
||
.videoTitle {
|
||
font-size: 10px;
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Teil 2: Batch Overview Dashboard
|
||
|
||
### Datei: `src/components/YouTubeBatchDashboard/index.tsx`
|
||
|
||
```typescript
|
||
'use client'
|
||
|
||
import React, { useEffect, useState } from 'react'
|
||
import styles from './styles.module.css'
|
||
|
||
interface BatchData {
|
||
id: number
|
||
name: string
|
||
channel: { id: number; name: string }
|
||
status: string
|
||
productionPeriod: {
|
||
start: string
|
||
end: string
|
||
}
|
||
targets: {
|
||
shortsTarget: number
|
||
longformsTarget: number
|
||
totalTarget: number
|
||
}
|
||
progress: {
|
||
shortsCompleted: number
|
||
longformsCompleted: number
|
||
percentage: number
|
||
}
|
||
}
|
||
|
||
export const YouTubeBatchDashboard: React.FC = () => {
|
||
const [batches, setBatches] = useState<BatchData[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [selectedChannel, setSelectedChannel] = useState<number | null>(null)
|
||
const [channels, setChannels] = useState<{ id: number; name: string }[]>([])
|
||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||
|
||
useEffect(() => {
|
||
const fetchChannels = async () => {
|
||
try {
|
||
const res = await fetch('/api/youtube-channels?limit=100&depth=0')
|
||
const data = await res.json()
|
||
setChannels(data.docs || [])
|
||
} catch (error) {
|
||
console.error('Error fetching channels:', error)
|
||
}
|
||
}
|
||
fetchChannels()
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
const fetchBatches = async () => {
|
||
setLoading(true)
|
||
try {
|
||
let query = '/api/yt-batches?limit=50&depth=1&sort=-productionPeriod.start'
|
||
|
||
if (selectedChannel) {
|
||
query += `&where[channel][equals]=${selectedChannel}`
|
||
}
|
||
|
||
if (statusFilter !== 'all') {
|
||
query += `&where[status][equals]=${statusFilter}`
|
||
}
|
||
|
||
const res = await fetch(query)
|
||
const data = await res.json()
|
||
setBatches(data.docs || [])
|
||
} catch (error) {
|
||
console.error('Error fetching batches:', error)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
fetchBatches()
|
||
}, [selectedChannel, statusFilter])
|
||
|
||
const getStatusLabel = (status: string): string => {
|
||
const labels: Record<string, string> = {
|
||
planning: 'Planung',
|
||
scripting: 'Scripts',
|
||
production: 'Produktion',
|
||
editing: 'Schnitt',
|
||
review: 'Review',
|
||
ready: 'Upload-Ready',
|
||
published: 'Veröffentlicht',
|
||
}
|
||
return labels[status] || status
|
||
}
|
||
|
||
const getStatusColor = (status: string): string => {
|
||
const colors: Record<string, string> = {
|
||
planning: '#9CA3AF',
|
||
scripting: '#60A5FA',
|
||
production: '#FBBF24',
|
||
editing: '#F97316',
|
||
review: '#A855F7',
|
||
ready: '#22D3EE',
|
||
published: '#10B981',
|
||
}
|
||
return colors[status] || '#9CA3AF'
|
||
}
|
||
|
||
const formatDateRange = (start: string, end: string): string => {
|
||
const startDate = new Date(start)
|
||
const endDate = new Date(end)
|
||
const options: Intl.DateTimeFormatOptions = { day: '2-digit', month: 'short' }
|
||
return `${startDate.toLocaleDateString('de-DE', options)} - ${endDate.toLocaleDateString('de-DE', options)}`
|
||
}
|
||
|
||
const calculateDaysRemaining = (endDate: string): number => {
|
||
const end = new Date(endDate)
|
||
const today = new Date()
|
||
const diff = Math.ceil((end.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
|
||
return diff
|
||
}
|
||
|
||
// Statistiken berechnen
|
||
const stats = {
|
||
total: batches.length,
|
||
inProgress: batches.filter((b) =>
|
||
['scripting', 'production', 'editing', 'review'].includes(b.status)
|
||
).length,
|
||
completed: batches.filter((b) => b.status === 'published').length,
|
||
avgProgress: batches.length
|
||
? Math.round(batches.reduce((sum, b) => sum + (b.progress?.percentage || 0), 0) / batches.length)
|
||
: 0,
|
||
}
|
||
|
||
return (
|
||
<div className={styles.dashboard}>
|
||
{/* Header */}
|
||
<div className={styles.header}>
|
||
<h1 className={styles.title}>Batch Overview</h1>
|
||
|
||
<div className={styles.filters}>
|
||
<select
|
||
className={styles.filterSelect}
|
||
value={selectedChannel || ''}
|
||
onChange={(e) => setSelectedChannel(e.target.value ? Number(e.target.value) : null)}
|
||
>
|
||
<option value="">Alle Kanäle</option>
|
||
{channels.map((ch) => (
|
||
<option key={ch.id} value={ch.id}>
|
||
{ch.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
|
||
<select
|
||
className={styles.filterSelect}
|
||
value={statusFilter}
|
||
onChange={(e) => setStatusFilter(e.target.value)}
|
||
>
|
||
<option value="all">Alle Status</option>
|
||
<option value="planning">Planung</option>
|
||
<option value="scripting">Scripts</option>
|
||
<option value="production">Produktion</option>
|
||
<option value="editing">Schnitt</option>
|
||
<option value="review">Review</option>
|
||
<option value="ready">Upload-Ready</option>
|
||
<option value="published">Veröffentlicht</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Stats Cards */}
|
||
<div className={styles.statsGrid}>
|
||
<div className={styles.statCard}>
|
||
<div className={styles.statValue}>{stats.total}</div>
|
||
<div className={styles.statLabel}>Batches gesamt</div>
|
||
</div>
|
||
<div className={styles.statCard}>
|
||
<div className={styles.statValue}>{stats.inProgress}</div>
|
||
<div className={styles.statLabel}>In Bearbeitung</div>
|
||
</div>
|
||
<div className={styles.statCard}>
|
||
<div className={styles.statValue}>{stats.completed}</div>
|
||
<div className={styles.statLabel}>Abgeschlossen</div>
|
||
</div>
|
||
<div className={styles.statCard}>
|
||
<div className={styles.statValue}>{stats.avgProgress}%</div>
|
||
<div className={styles.statLabel}>Ø Fortschritt</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Batches Grid */}
|
||
{loading ? (
|
||
<div className={styles.loading}>Lade Batches...</div>
|
||
) : batches.length === 0 ? (
|
||
<div className={styles.empty}>Keine Batches gefunden</div>
|
||
) : (
|
||
<div className={styles.batchGrid}>
|
||
{batches.map((batch) => {
|
||
const daysRemaining = calculateDaysRemaining(batch.productionPeriod.end)
|
||
const isOverdue = daysRemaining < 0 && batch.status !== 'published'
|
||
|
||
return (
|
||
<a
|
||
key={batch.id}
|
||
href={`/admin/collections/yt-batches/${batch.id}`}
|
||
className={`${styles.batchCard} ${isOverdue ? styles.overdue : ''}`}
|
||
>
|
||
<div className={styles.batchHeader}>
|
||
<span className={styles.batchName}>{batch.name}</span>
|
||
<span
|
||
className={styles.statusBadge}
|
||
style={{ backgroundColor: getStatusColor(batch.status) }}
|
||
>
|
||
{getStatusLabel(batch.status)}
|
||
</span>
|
||
</div>
|
||
|
||
<div className={styles.batchChannel}>{batch.channel?.name || 'Kein Kanal'}</div>
|
||
|
||
<div className={styles.batchPeriod}>
|
||
{formatDateRange(batch.productionPeriod.start, batch.productionPeriod.end)}
|
||
{isOverdue ? (
|
||
<span className={styles.overdueBadge}>Überfällig</span>
|
||
) : daysRemaining <= 3 && batch.status !== 'published' ? (
|
||
<span className={styles.urgentBadge}>{daysRemaining} Tage</span>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className={styles.progressSection}>
|
||
<div className={styles.progressBar}>
|
||
<div
|
||
className={styles.progressFill}
|
||
style={{ width: `${batch.progress?.percentage || 0}%` }}
|
||
/>
|
||
</div>
|
||
<span className={styles.progressText}>{batch.progress?.percentage || 0}%</span>
|
||
</div>
|
||
|
||
<div className={styles.targetStats}>
|
||
<div className={styles.targetStat}>
|
||
<span className={styles.targetLabel}>Shorts</span>
|
||
<span className={styles.targetValue}>
|
||
{batch.progress?.shortsCompleted || 0}/{batch.targets?.shortsTarget || 0}
|
||
</span>
|
||
</div>
|
||
<div className={styles.targetStat}>
|
||
<span className={styles.targetLabel}>Longforms</span>
|
||
<span className={styles.targetValue}>
|
||
{batch.progress?.longformsCompleted || 0}/{batch.targets?.longformsTarget || 0}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</a>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default YouTubeBatchDashboard
|
||
```
|
||
|
||
### Datei: `src/components/YouTubeBatchDashboard/styles.module.css`
|
||
|
||
```css
|
||
.dashboard {
|
||
padding: 24px;
|
||
background: var(--theme-elevation-0);
|
||
min-height: 100vh;
|
||
}
|
||
|
||
.header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 24px;
|
||
flex-wrap: wrap;
|
||
gap: 16px;
|
||
}
|
||
|
||
.title {
|
||
font-size: 24px;
|
||
font-weight: 600;
|
||
color: var(--theme-text);
|
||
margin: 0;
|
||
}
|
||
|
||
.filters {
|
||
display: flex;
|
||
gap: 12px;
|
||
}
|
||
|
||
.filterSelect {
|
||
padding: 8px 12px;
|
||
border: 1px solid var(--theme-elevation-200);
|
||
background: var(--theme-elevation-50);
|
||
border-radius: 6px;
|
||
font-size: 14px;
|
||
color: var(--theme-text);
|
||
min-width: 150px;
|
||
}
|
||
|
||
.statsGrid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 16px;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.statCard {
|
||
padding: 20px;
|
||
background: var(--theme-elevation-50);
|
||
border-radius: 12px;
|
||
text-align: center;
|
||
}
|
||
|
||
.statValue {
|
||
font-size: 32px;
|
||
font-weight: 700;
|
||
color: var(--theme-text);
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.statLabel {
|
||
font-size: 14px;
|
||
color: var(--theme-elevation-500);
|
||
}
|
||
|
||
.loading,
|
||
.empty {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
height: 200px;
|
||
color: var(--theme-text);
|
||
font-size: 16px;
|
||
}
|
||
|
||
.batchGrid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||
gap: 16px;
|
||
}
|
||
|
||
.batchCard {
|
||
padding: 20px;
|
||
background: var(--theme-elevation-50);
|
||
border-radius: 12px;
|
||
border: 1px solid var(--theme-elevation-100);
|
||
text-decoration: none;
|
||
transition: all 0.2s;
|
||
display: block;
|
||
}
|
||
|
||
.batchCard:hover {
|
||
border-color: var(--theme-elevation-300);
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.batchCard.overdue {
|
||
border-color: #ef4444;
|
||
background: rgba(239, 68, 68, 0.05);
|
||
}
|
||
|
||
.batchHeader {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.batchName {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: var(--theme-text);
|
||
}
|
||
|
||
.statusBadge {
|
||
padding: 4px 10px;
|
||
border-radius: 20px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
color: white;
|
||
}
|
||
|
||
.batchChannel {
|
||
font-size: 14px;
|
||
color: var(--theme-elevation-500);
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.batchPeriod {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 13px;
|
||
color: var(--theme-text);
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.overdueBadge {
|
||
padding: 2px 8px;
|
||
background: #ef4444;
|
||
color: white;
|
||
border-radius: 4px;
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.urgentBadge {
|
||
padding: 2px 8px;
|
||
background: #f59e0b;
|
||
color: white;
|
||
border-radius: 4px;
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.progressSection {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.progressBar {
|
||
flex: 1;
|
||
height: 8px;
|
||
background: var(--theme-elevation-200);
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.progressFill {
|
||
height: 100%;
|
||
background: linear-gradient(90deg, #3b82f6, #10b981);
|
||
border-radius: 4px;
|
||
transition: width 0.3s ease;
|
||
}
|
||
|
||
.progressText {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--theme-text);
|
||
min-width: 40px;
|
||
text-align: right;
|
||
}
|
||
|
||
.targetStats {
|
||
display: flex;
|
||
gap: 24px;
|
||
}
|
||
|
||
.targetStat {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
|
||
.targetLabel {
|
||
font-size: 12px;
|
||
color: var(--theme-elevation-500);
|
||
}
|
||
|
||
.targetValue {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: var(--theme-text);
|
||
}
|
||
|
||
/* Responsive */
|
||
@media (max-width: 1200px) {
|
||
.statsGrid {
|
||
grid-template-columns: repeat(2, 1fr);
|
||
}
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.header {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
|
||
.filters {
|
||
flex-direction: column;
|
||
}
|
||
|
||
.statsGrid {
|
||
grid-template-columns: repeat(2, 1fr);
|
||
}
|
||
|
||
.batchGrid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Teil 3: Monthly Goals Dashboard
|
||
|
||
### Datei: `src/components/YouTubeGoalsDashboard/index.tsx`
|
||
|
||
```typescript
|
||
'use client'
|
||
|
||
import React, { useEffect, useState } from 'react'
|
||
import styles from './styles.module.css'
|
||
|
||
interface GoalData {
|
||
id: number
|
||
displayTitle: string
|
||
channel: { id: number; name: string }
|
||
month: string
|
||
contentGoals: {
|
||
longformsTarget: number
|
||
longformsCurrent: number
|
||
shortsTarget: number
|
||
shortsCurrent: number
|
||
}
|
||
audienceGoals: {
|
||
subscribersTarget: number
|
||
subscribersCurrent: number
|
||
viewsTarget: number
|
||
viewsCurrent: number
|
||
}
|
||
engagementGoals: {
|
||
avgCtrTarget: string
|
||
avgCtrCurrent: string
|
||
avgRetentionTarget: string
|
||
avgRetentionCurrent: string
|
||
}
|
||
businessGoals: {
|
||
newsletterSignupsTarget: number
|
||
newsletterSignupsCurrent: number
|
||
affiliateRevenueTarget: number
|
||
affiliateRevenueCurrent: number
|
||
}
|
||
customGoals: Array<{
|
||
metric: string
|
||
target: string
|
||
current: string
|
||
status: string
|
||
}>
|
||
}
|
||
|
||
export const YouTubeGoalsDashboard: React.FC = () => {
|
||
const [goals, setGoals] = useState<GoalData[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [selectedChannel, setSelectedChannel] = useState<number | null>(null)
|
||
const [channels, setChannels] = useState<{ id: number; name: string }[]>([])
|
||
const [selectedMonth, setSelectedMonth] = useState<string>(
|
||
new Date().toISOString().slice(0, 7) + '-01'
|
||
)
|
||
|
||
useEffect(() => {
|
||
const fetchChannels = async () => {
|
||
try {
|
||
const res = await fetch('/api/youtube-channels?limit=100&depth=0')
|
||
const data = await res.json()
|
||
setChannels(data.docs || [])
|
||
} catch (error) {
|
||
console.error('Error fetching channels:', error)
|
||
}
|
||
}
|
||
fetchChannels()
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
const fetchGoals = async () => {
|
||
setLoading(true)
|
||
try {
|
||
let query = '/api/yt-monthly-goals?limit=50&depth=1'
|
||
|
||
if (selectedChannel) {
|
||
query += `&where[channel][equals]=${selectedChannel}`
|
||
}
|
||
|
||
// Filter für Monat (Start des Monats)
|
||
const monthStart = new Date(selectedMonth)
|
||
const monthEnd = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 0)
|
||
query += `&where[month][greater_than_equal]=${monthStart.toISOString()}`
|
||
query += `&where[month][less_than_equal]=${monthEnd.toISOString()}`
|
||
|
||
const res = await fetch(query)
|
||
const data = await res.json()
|
||
setGoals(data.docs || [])
|
||
} catch (error) {
|
||
console.error('Error fetching goals:', error)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
fetchGoals()
|
||
}, [selectedChannel, selectedMonth])
|
||
|
||
const calculateProgress = (current: number, target: number): number => {
|
||
if (!target) return 0
|
||
return Math.min(100, Math.round((current / target) * 100))
|
||
}
|
||
|
||
const getProgressColor = (percentage: number): string => {
|
||
if (percentage >= 100) return '#10B981'
|
||
if (percentage >= 75) return '#3B82F6'
|
||
if (percentage >= 50) return '#F59E0B'
|
||
return '#EF4444'
|
||
}
|
||
|
||
const formatMonth = (dateStr: string): string => {
|
||
const date = new Date(dateStr)
|
||
return date.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })
|
||
}
|
||
|
||
// Monatsauswahl generieren (12 Monate zurück + 3 Monate voraus)
|
||
const generateMonthOptions = (): { value: string; label: string }[] => {
|
||
const options = []
|
||
const now = new Date()
|
||
for (let i = -12; i <= 3; i++) {
|
||
const date = new Date(now.getFullYear(), now.getMonth() + i, 1)
|
||
options.push({
|
||
value: date.toISOString().slice(0, 10),
|
||
label: formatMonth(date.toISOString()),
|
||
})
|
||
}
|
||
return options
|
||
}
|
||
|
||
return (
|
||
<div className={styles.dashboard}>
|
||
{/* Header */}
|
||
<div className={styles.header}>
|
||
<h1 className={styles.title}>Monthly Goals</h1>
|
||
|
||
<div className={styles.filters}>
|
||
<select
|
||
className={styles.filterSelect}
|
||
value={selectedChannel || ''}
|
||
onChange={(e) => setSelectedChannel(e.target.value ? Number(e.target.value) : null)}
|
||
>
|
||
<option value="">Alle Kanäle</option>
|
||
{channels.map((ch) => (
|
||
<option key={ch.id} value={ch.id}>
|
||
{ch.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
|
||
<select
|
||
className={styles.filterSelect}
|
||
value={selectedMonth}
|
||
onChange={(e) => setSelectedMonth(e.target.value)}
|
||
>
|
||
{generateMonthOptions().map((opt) => (
|
||
<option key={opt.value} value={opt.value}>
|
||
{opt.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<div className={styles.loading}>Lade Ziele...</div>
|
||
) : goals.length === 0 ? (
|
||
<div className={styles.empty}>
|
||
<p>Keine Monatsziele für diesen Zeitraum gefunden.</p>
|
||
<a href="/admin/collections/yt-monthly-goals/create" className={styles.createBtn}>
|
||
+ Monatsziel erstellen
|
||
</a>
|
||
</div>
|
||
) : (
|
||
<div className={styles.goalsGrid}>
|
||
{goals.map((goal) => (
|
||
<a
|
||
key={goal.id}
|
||
href={`/admin/collections/yt-monthly-goals/${goal.id}`}
|
||
className={styles.goalCard}
|
||
>
|
||
<div className={styles.goalHeader}>
|
||
<span className={styles.goalTitle}>{goal.displayTitle}</span>
|
||
</div>
|
||
|
||
{/* Content Goals */}
|
||
<div className={styles.goalSection}>
|
||
<h3 className={styles.sectionTitle}>Content</h3>
|
||
<div className={styles.metricsGrid}>
|
||
<div className={styles.metric}>
|
||
<div className={styles.metricHeader}>
|
||
<span>Longforms</span>
|
||
<span className={styles.metricValue}>
|
||
{goal.contentGoals?.longformsCurrent || 0}/
|
||
{goal.contentGoals?.longformsTarget || 0}
|
||
</span>
|
||
</div>
|
||
<div className={styles.progressBar}>
|
||
<div
|
||
className={styles.progressFill}
|
||
style={{
|
||
width: `${calculateProgress(goal.contentGoals?.longformsCurrent || 0, goal.contentGoals?.longformsTarget || 0)}%`,
|
||
backgroundColor: getProgressColor(
|
||
calculateProgress(
|
||
goal.contentGoals?.longformsCurrent || 0,
|
||
goal.contentGoals?.longformsTarget || 0
|
||
)
|
||
),
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className={styles.metric}>
|
||
<div className={styles.metricHeader}>
|
||
<span>Shorts</span>
|
||
<span className={styles.metricValue}>
|
||
{goal.contentGoals?.shortsCurrent || 0}/{goal.contentGoals?.shortsTarget || 0}
|
||
</span>
|
||
</div>
|
||
<div className={styles.progressBar}>
|
||
<div
|
||
className={styles.progressFill}
|
||
style={{
|
||
width: `${calculateProgress(goal.contentGoals?.shortsCurrent || 0, goal.contentGoals?.shortsTarget || 0)}%`,
|
||
backgroundColor: getProgressColor(
|
||
calculateProgress(
|
||
goal.contentGoals?.shortsCurrent || 0,
|
||
goal.contentGoals?.shortsTarget || 0
|
||
)
|
||
),
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Audience Goals */}
|
||
<div className={styles.goalSection}>
|
||
<h3 className={styles.sectionTitle}>Audience</h3>
|
||
<div className={styles.metricsGrid}>
|
||
<div className={styles.metric}>
|
||
<div className={styles.metricHeader}>
|
||
<span>Neue Abos</span>
|
||
<span className={styles.metricValue}>
|
||
{goal.audienceGoals?.subscribersCurrent || 0}/
|
||
{goal.audienceGoals?.subscribersTarget || '-'}
|
||
</span>
|
||
</div>
|
||
{goal.audienceGoals?.subscribersTarget ? (
|
||
<div className={styles.progressBar}>
|
||
<div
|
||
className={styles.progressFill}
|
||
style={{
|
||
width: `${calculateProgress(goal.audienceGoals?.subscribersCurrent || 0, goal.audienceGoals?.subscribersTarget)}%`,
|
||
backgroundColor: getProgressColor(
|
||
calculateProgress(
|
||
goal.audienceGoals?.subscribersCurrent || 0,
|
||
goal.audienceGoals?.subscribersTarget
|
||
)
|
||
),
|
||
}}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<div className={styles.metric}>
|
||
<div className={styles.metricHeader}>
|
||
<span>Views</span>
|
||
<span className={styles.metricValue}>
|
||
{(goal.audienceGoals?.viewsCurrent || 0).toLocaleString()}/
|
||
{goal.audienceGoals?.viewsTarget
|
||
? goal.audienceGoals.viewsTarget.toLocaleString()
|
||
: '-'}
|
||
</span>
|
||
</div>
|
||
{goal.audienceGoals?.viewsTarget ? (
|
||
<div className={styles.progressBar}>
|
||
<div
|
||
className={styles.progressFill}
|
||
style={{
|
||
width: `${calculateProgress(goal.audienceGoals?.viewsCurrent || 0, goal.audienceGoals?.viewsTarget)}%`,
|
||
backgroundColor: getProgressColor(
|
||
calculateProgress(
|
||
goal.audienceGoals?.viewsCurrent || 0,
|
||
goal.audienceGoals?.viewsTarget
|
||
)
|
||
),
|
||
}}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Engagement Goals */}
|
||
<div className={styles.goalSection}>
|
||
<h3 className={styles.sectionTitle}>Engagement</h3>
|
||
<div className={styles.engagementGrid}>
|
||
<div className={styles.engagementMetric}>
|
||
<span className={styles.engagementLabel}>Ø CTR</span>
|
||
<span className={styles.engagementValue}>
|
||
{goal.engagementGoals?.avgCtrCurrent || '-'} / {goal.engagementGoals?.avgCtrTarget || '-'}
|
||
</span>
|
||
</div>
|
||
<div className={styles.engagementMetric}>
|
||
<span className={styles.engagementLabel}>Ø Retention</span>
|
||
<span className={styles.engagementValue}>
|
||
{goal.engagementGoals?.avgRetentionCurrent || '-'} /{' '}
|
||
{goal.engagementGoals?.avgRetentionTarget || '-'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Custom Goals */}
|
||
{goal.customGoals && goal.customGoals.length > 0 && (
|
||
<div className={styles.goalSection}>
|
||
<h3 className={styles.sectionTitle}>Weitere Ziele</h3>
|
||
<div className={styles.customGoalsGrid}>
|
||
{goal.customGoals.map((cg, idx) => (
|
||
<div key={idx} className={styles.customGoal}>
|
||
<span className={styles.customGoalMetric}>{cg.metric}</span>
|
||
<span className={styles.customGoalValue}>
|
||
{cg.current || '-'} / {cg.target}
|
||
</span>
|
||
{cg.status && (
|
||
<span className={`${styles.customGoalStatus} ${styles[cg.status]}`}>
|
||
{cg.status === 'on_track'
|
||
? 'On Track'
|
||
: cg.status === 'at_risk'
|
||
? 'At Risk'
|
||
: cg.status === 'achieved'
|
||
? 'Erreicht'
|
||
: 'Verfehlt'}
|
||
</span>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</a>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default YouTubeGoalsDashboard
|
||
```
|
||
|
||
---
|
||
|
||
## Teil 4: Payload Config Integration
|
||
|
||
### Datei: `src/payload.config.ts` (Ergänzungen)
|
||
|
||
Sobald der path-to-regexp Bug behoben ist, diese Views registrieren:
|
||
|
||
```typescript
|
||
import { YouTubeCalendarView } from './components/YouTubeCalendar'
|
||
import { YouTubeBatchDashboard } from './components/YouTubeBatchDashboard'
|
||
import { YouTubeGoalsDashboard } from './components/YouTubeGoalsDashboard'
|
||
|
||
// In der buildConfig:
|
||
admin: {
|
||
// ... bestehende config
|
||
components: {
|
||
views: {
|
||
// Content Calendar
|
||
youtubeCalendar: {
|
||
path: '/youtube/calendar',
|
||
Component: YouTubeCalendarView,
|
||
exact: true,
|
||
},
|
||
// Batch Dashboard
|
||
youtubeBatches: {
|
||
path: '/youtube/batches',
|
||
Component: YouTubeBatchDashboard,
|
||
exact: true,
|
||
},
|
||
// Monthly Goals Dashboard
|
||
youtubeGoals: {
|
||
path: '/youtube/goals',
|
||
Component: YouTubeGoalsDashboard,
|
||
exact: true,
|
||
},
|
||
},
|
||
// Optional: Custom Navigation für YouTube
|
||
afterNavLinks: [YouTubeNavigation],
|
||
},
|
||
},
|
||
```
|
||
|
||
---
|
||
|
||
## Teil 5: Custom YouTube Navigation
|
||
|
||
### Datei: `src/components/YouTubeNavigation/index.tsx`
|
||
|
||
```typescript
|
||
'use client'
|
||
|
||
import React, { useState } from 'react'
|
||
import { usePathname } from 'next/navigation'
|
||
import styles from './styles.module.css'
|
||
|
||
export const YouTubeNavigation: React.FC = () => {
|
||
const pathname = usePathname()
|
||
const [isExpanded, setIsExpanded] = useState(pathname?.startsWith('/admin/youtube'))
|
||
|
||
const navItems = [
|
||
{
|
||
label: 'Kalender',
|
||
href: '/admin/youtube/calendar',
|
||
icon: '📅',
|
||
},
|
||
{
|
||
label: 'Batches',
|
||
href: '/admin/youtube/batches',
|
||
icon: '📦',
|
||
},
|
||
{
|
||
label: 'Monatsziele',
|
||
href: '/admin/youtube/goals',
|
||
icon: '🎯',
|
||
},
|
||
]
|
||
|
||
return (
|
||
<div className={styles.navContainer}>
|
||
<button className={styles.navToggle} onClick={() => setIsExpanded(!isExpanded)}>
|
||
<span className={styles.navIcon}>🎬</span>
|
||
<span className={styles.navLabel}>YouTube Hub</span>
|
||
<span className={`${styles.chevron} ${isExpanded ? styles.expanded : ''}`}>›</span>
|
||
</button>
|
||
|
||
{isExpanded && (
|
||
<div className={styles.navItems}>
|
||
{navItems.map((item) => (
|
||
<a
|
||
key={item.href}
|
||
href={item.href}
|
||
className={`${styles.navItem} ${pathname === item.href ? styles.active : ''}`}
|
||
>
|
||
<span className={styles.itemIcon}>{item.icon}</span>
|
||
<span className={styles.itemLabel}>{item.label}</span>
|
||
</a>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default YouTubeNavigation
|
||
```
|
||
|
||
### Datei: `src/components/YouTubeNavigation/styles.module.css`
|
||
|
||
```css
|
||
.navContainer {
|
||
margin: 8px 0;
|
||
border-top: 1px solid var(--theme-elevation-100);
|
||
padding-top: 8px;
|
||
}
|
||
|
||
.navToggle {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
width: 100%;
|
||
padding: 10px 16px;
|
||
background: transparent;
|
||
border: none;
|
||
cursor: pointer;
|
||
color: var(--theme-text);
|
||
font-size: 14px;
|
||
text-align: left;
|
||
border-radius: 4px;
|
||
transition: background 0.15s;
|
||
}
|
||
|
||
.navToggle:hover {
|
||
background: var(--theme-elevation-50);
|
||
}
|
||
|
||
.navIcon {
|
||
font-size: 18px;
|
||
}
|
||
|
||
.navLabel {
|
||
flex: 1;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.chevron {
|
||
transition: transform 0.2s;
|
||
color: var(--theme-elevation-400);
|
||
}
|
||
|
||
.chevron.expanded {
|
||
transform: rotate(90deg);
|
||
}
|
||
|
||
.navItems {
|
||
padding-left: 16px;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.navItem {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 16px;
|
||
color: var(--theme-text);
|
||
text-decoration: none;
|
||
font-size: 13px;
|
||
border-radius: 4px;
|
||
transition: all 0.15s;
|
||
}
|
||
|
||
.navItem:hover {
|
||
background: var(--theme-elevation-50);
|
||
}
|
||
|
||
.navItem.active {
|
||
background: var(--theme-elevation-100);
|
||
color: var(--theme-elevation-800);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.itemIcon {
|
||
font-size: 14px;
|
||
opacity: 0.8;
|
||
}
|
||
|
||
.itemLabel {
|
||
flex: 1;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Ausführungsreihenfolge (wenn Bug behoben)
|
||
|
||
1. **Komponenten erstellen:**
|
||
- `src/components/YouTubeCalendar/index.tsx`
|
||
- `src/components/YouTubeCalendar/styles.module.css`
|
||
- `src/components/YouTubeBatchDashboard/index.tsx`
|
||
- `src/components/YouTubeBatchDashboard/styles.module.css`
|
||
- `src/components/YouTubeGoalsDashboard/index.tsx`
|
||
- `src/components/YouTubeGoalsDashboard/styles.module.css`
|
||
- `src/components/YouTubeNavigation/index.tsx`
|
||
- `src/components/YouTubeNavigation/styles.module.css`
|
||
|
||
2. **payload.config.ts aktualisieren:**
|
||
- Imports hinzufügen
|
||
- Views in admin.components.views registrieren
|
||
- Navigation in admin.components.afterNavLinks hinzufügen
|
||
|
||
3. **Build erstellen:**
|
||
```bash
|
||
pnpm build
|
||
```
|
||
|
||
4. **PM2 neustarten:**
|
||
```bash
|
||
pm2 restart payload
|
||
```
|
||
|
||
5. **Testen:**
|
||
- `/admin/youtube/calendar` aufrufen
|
||
- `/admin/youtube/batches` aufrufen
|
||
- `/admin/youtube/goals` aufrufen
|
||
- Navigation im Seitenmenü prüfen
|
||
|
||
---
|
||
|
||
## Testschritte
|
||
|
||
1. [ ] Kalender-View zeigt Videos korrekt an
|
||
2. [ ] Umschalten zwischen Produktions- und Posting-Kalender funktioniert
|
||
3. [ ] Kanal-Filter funktioniert
|
||
4. [ ] Batch Dashboard zeigt alle Batches mit Fortschritt
|
||
5. [ ] Status-Filter funktioniert
|
||
6. [ ] Überfällige Batches werden hervorgehoben
|
||
7. [ ] Monthly Goals Dashboard zeigt Ziele korrekt an
|
||
8. [ ] Progress-Balken zeigen korrekten Fortschritt
|
||
9. [ ] Custom Navigation erscheint im Admin-Panel
|
||
10. [ ] Alle Links funktionieren korrekt
|
||
|
||
---
|
||
|
||
## Bekannte Einschränkungen
|
||
|
||
- **path-to-regexp Bug:** Custom Admin Views können erst implementiert werden, wenn dieser Bug in Payload behoben ist
|
||
- **CSS Modules:** Payload 3.x verwendet CSS Modules, kein SCSS
|
||
- **Client Components:** Views müssen `'use client'` Direktive haben
|
||
- **API-Aufrufe:** Erfolgen clientseitig via fetch(), keine Server Components
|
||
|
||
---
|
||
|
||
## Troubleshooting
|
||
|
||
**"Missing parameter name" Fehler:**
|
||
- path-to-regexp Bug noch nicht behoben
|
||
- Prüfen ob neuere Payload-Version verfügbar
|
||
|
||
**View wird nicht angezeigt:**
|
||
- Import in payload.config.ts prüfen
|
||
- Build neu erstellen
|
||
- Browser-Cache leeren
|
||
|
||
**Styling funktioniert nicht:**
|
||
- CSS Module korrekt importiert?
|
||
- Klassennamen ohne Bindestriche in JSX?
|
||
- `styles.className` Notation verwenden
|
||
|
||
**API-Fehler in Views:**
|
||
- Browser DevTools → Network Tab prüfen
|
||
- CORS-Probleme ausschließen
|
||
- Authentifizierung prüfen
|