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

49 KiB
Raw Permalink Blame History

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:

  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

'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

.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)

  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:

    pnpm build
    
  4. PM2 neustarten:

    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