# 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([]) const [loading, setLoading] = useState(true) const [viewMode, setViewMode] = useState<'production' | 'posting'>('posting') const [selectedChannel, setSelectedChannel] = useState(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 = { 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 (
{/* Header */}

{viewMode === 'production' ? 'Produktionskalender' : 'Posting-Kalender'}

{monthNames[currentDate.getMonth()]} {currentDate.getFullYear()}
{/* Legende */}
S {' '} Short L {' '} Longform | {Object.entries({ idea: 'Idee', scripting: 'Script', production: 'Produktion', editing: 'Schnitt', review: 'Review', approved: 'Freigegeben', published: 'Veröffentlicht', }).map(([status, label]) => ( {label} ))}
{/* Kalender Grid */} {loading ? (
Lade Kalender...
) : (
{/* Wochentage Header */} {weekDays.map((day) => (
{day}
))} {/* Kalender-Tage */} {calendarDays.map((day, index) => (
{day.date.getDate()}
{day.videos.slice(0, 4).map((video) => ( {getFormatBadge(video.format)} {video.title} ))} {day.videos.length > 4 && (
+{day.videos.length - 4} weitere
)}
))}
)} {/* Stats Footer */}
Videos im Monat: {videos.length}
Shorts: {videos.filter((v) => v.format === 'short').length}
Longforms: {videos.filter((v) => v.format === 'longform').length}
) } 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([]) const [loading, setLoading] = useState(true) const [selectedChannel, setSelectedChannel] = useState(null) const [channels, setChannels] = useState<{ id: number; name: string }[]>([]) const [statusFilter, setStatusFilter] = useState('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 = { 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 = { 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 (
{/* Header */}

Batch Overview

{/* Stats Cards */}
{stats.total}
Batches gesamt
{stats.inProgress}
In Bearbeitung
{stats.completed}
Abgeschlossen
{stats.avgProgress}%
Ø Fortschritt
{/* Batches Grid */} {loading ? (
Lade Batches...
) : batches.length === 0 ? (
Keine Batches gefunden
) : ( ) } 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([]) const [loading, setLoading] = useState(true) const [selectedChannel, setSelectedChannel] = useState(null) const [channels, setChannels] = useState<{ id: number; name: string }[]>([]) const [selectedMonth, setSelectedMonth] = useState( 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 (
{/* Header */}

Monthly Goals

{loading ? (
Lade Ziele...
) : goals.length === 0 ? (

Keine Monatsziele für diesen Zeitraum gefunden.

+ Monatsziel erstellen
) : (
{goals.map((goal) => (
{goal.displayTitle}
{/* Content Goals */}

Content

Longforms {goal.contentGoals?.longformsCurrent || 0}/ {goal.contentGoals?.longformsTarget || 0}
Shorts {goal.contentGoals?.shortsCurrent || 0}/{goal.contentGoals?.shortsTarget || 0}
{/* Audience Goals */}

Audience

Neue Abos {goal.audienceGoals?.subscribersCurrent || 0}/ {goal.audienceGoals?.subscribersTarget || '-'}
{goal.audienceGoals?.subscribersTarget ? (
) : null}
Views {(goal.audienceGoals?.viewsCurrent || 0).toLocaleString()}/ {goal.audienceGoals?.viewsTarget ? goal.audienceGoals.viewsTarget.toLocaleString() : '-'}
{goal.audienceGoals?.viewsTarget ? (
) : null}
{/* Engagement Goals */}

Engagement

Ø CTR {goal.engagementGoals?.avgCtrCurrent || '-'} / {goal.engagementGoals?.avgCtrTarget || '-'}
Ø Retention {goal.engagementGoals?.avgRetentionCurrent || '-'} /{' '} {goal.engagementGoals?.avgRetentionTarget || '-'}
{/* Custom Goals */} {goal.customGoals && goal.customGoals.length > 0 && (

Weitere Ziele

{goal.customGoals.map((cg, idx) => (
{cg.metric} {cg.current || '-'} / {cg.target} {cg.status && ( {cg.status === 'on_track' ? 'On Track' : cg.status === 'at_risk' ? 'At Risk' : cg.status === 'achieved' ? 'Erreicht' : 'Verfehlt'} )}
))}
)}
))}
)}
) } 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 (
{isExpanded && (
{navItems.map((item) => ( {item.icon} {item.label} ))}
)}
) } 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