cms.c2sgmbh/prompts/youtube3-admin-views.md
Martin Porwoll 77f70876f4 chore: add Claude Code config, prompts, and tenant setup scripts
- 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>
2026-01-18 10:18:05 +00:00

1867 lines
49 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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)}>
&#8249;
</button>
<span className={styles.currentMonth}>
{monthNames[currentDate.getMonth()]} {currentDate.getFullYear()}
</span>
<button className={styles.navBtn} onClick={() => navigateMonth(1)}>
&#8250;
</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