- 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>
49 KiB
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):
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:
- Content Calendar – Kalenderansicht aller geplanten Videos
- Posting Calendar – Veröffentlichungsplan mit Timeline
- Batch Overview Dashboard – Produktions-Batch Übersicht
- Monthly Goals Dashboard – KPI-Tracking und Fortschritt
- Custom Navigation – YouTube-spezifisches Navigationsmenü
Teil 1: Content Calendar View
Datei: src/components/YouTubeCalendar/index.tsx
'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
.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
'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
.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
'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:
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
'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
.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)
-
Komponenten erstellen:
src/components/YouTubeCalendar/index.tsxsrc/components/YouTubeCalendar/styles.module.csssrc/components/YouTubeBatchDashboard/index.tsxsrc/components/YouTubeBatchDashboard/styles.module.csssrc/components/YouTubeGoalsDashboard/index.tsxsrc/components/YouTubeGoalsDashboard/styles.module.csssrc/components/YouTubeNavigation/index.tsxsrc/components/YouTubeNavigation/styles.module.css
-
payload.config.ts aktualisieren:
- Imports hinzufügen
- Views in admin.components.views registrieren
- Navigation in admin.components.afterNavLinks hinzufügen
-
Build erstellen:
pnpm build -
PM2 neustarten:
pm2 restart payload -
Testen:
/admin/youtube/calendaraufrufen/admin/youtube/batchesaufrufen/admin/youtube/goalsaufrufen- Navigation im Seitenmenü prüfen
Testschritte
- Kalender-View zeigt Videos korrekt an
- Umschalten zwischen Produktions- und Posting-Kalender funktioniert
- Kanal-Filter funktioniert
- Batch Dashboard zeigt alle Batches mit Fortschritt
- Status-Filter funktioniert
- Überfällige Batches werden hervorgehoben
- Monthly Goals Dashboard zeigt Ziele korrekt an
- Progress-Balken zeigen korrekten Fortschritt
- Custom Navigation erscheint im Admin-Panel
- 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.classNameNotation verwenden
API-Fehler in Views:
- Browser DevTools → Network Tab prüfen
- CORS-Probleme ausschließen
- Authentifizierung prüfen