mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 15:04:14 +00:00
- Add .claude/ configuration (agents, commands, hooks, get-shit-done workflows) - Add prompts/ directory with development planning documents - Add scripts/setup-tenants/ with tenant configuration - Add docs/screenshots/ - Remove obsolete phase2.2-corrections-report.md - Update pnpm-lock.yaml - Update detect-secrets.sh to ignore setup.sh (env var usage, not secrets) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
48 KiB
48 KiB
YouTube Operations Hub v2 – Vollständiges Konzept
Übersicht der Erweiterungen
Neue Features:
- Script Editor – Custom Lexical Rich-Text-Editor mit Section-Blöcken
- Kalenderansichten – Content Calendar + Posting Calendar direkt in Payload
- Batch-Planung – Detaillierte Produktions-Batches mit Kapazitätsplanung
- Monthly Goals Dashboard – KPI-Tracking pro Kanal und Monat
- Upload Workflow – Strukturierte Checklisten mit Fortschrittsanzeige
1. Script Editor – Custom Lexical Blocks
1.1 Konzept
Payload 3.x verwendet Lexical als Rich-Text-Editor. Wir erstellen Custom Blocks für die Skript-Struktur:
┌─────────────────────────────────────────────────────────────┐
│ 📝 Script Editor │
├─────────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 🎬 HOOK [0-10 Sek] [Edit]│ │
│ │ "Board-Meeting in 20 Minuten. Der Morgen war │ │
│ │ Chaos, die Kinder waren anstrengend..." │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 🏷️ INTRO-IDENT [1-2 Sek] [Edit]│ │
│ │ [GRFI Serien-Badge einblenden] │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 📖 CONTEXT [45-60 Sek] [Edit]│ │
│ │ "Kennst du das? Du wachst auf mit einem Plan..." │ │
│ │ │ │
│ │ 🎥 B-Roll: Morgenroutine, Meetings │ │
│ │ 📝 Overlay: "System schlägt Motivation" │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 📚 TEIL 1: OUTFIT-LOGIK [2-3 Min] [Edit]│ │
│ │ "Fangen wir mit der Basis an: dem Outfit..." │ │
│ │ │ │
│ │ 🎥 B-Roll: Outfit zeigen, 3 Kombinationen │ │
│ │ 📝 Overlay: "3 Kombinationen = 0 Entscheidungen" │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ [+ Section hinzufügen ▾] │
│ • Hook │
│ • Intro/Ident │
│ • Context │
│ • Content Part │
│ • Summary │
│ • CTA │
│ • Outro │
│ • Disclaimer │
└─────────────────────────────────────────────────────────────┘
1.2 Script Section Block Schema
// src/blocks/ScriptSection.ts
import { Block } from 'payload/types'
export const ScriptSectionBlock: Block = {
slug: 'scriptSection',
labels: {
singular: 'Script Section',
plural: 'Script Sections',
},
fields: [
{
name: 'sectionType',
type: 'select',
required: true,
options: [
{ label: '🎬 Hook', value: 'hook' },
{ label: '🏷️ Intro/Ident', value: 'intro_ident' },
{ label: '📖 Context', value: 'context' },
{ label: '📚 Content Part', value: 'content_part' },
{ label: '📋 Summary', value: 'summary' },
{ label: '📢 CTA', value: 'cta' },
{ label: '🎬 Outro', value: 'outro' },
{ label: '⚠️ Disclaimer', value: 'disclaimer' },
],
admin: {
description: 'Art der Script-Section',
},
},
{
name: 'sectionTitle',
type: 'text',
label: 'Section-Titel',
admin: {
placeholder: 'z.B. "TEIL 1: OUTFIT-LOGIK"',
condition: (data, siblingData) =>
siblingData?.sectionType === 'content_part',
},
},
{
name: 'duration',
type: 'text',
label: 'Dauer',
admin: {
placeholder: 'z.B. "2-3 Min" oder "45-60 Sek"',
width: '25%',
},
},
{
name: 'spokenText',
type: 'richText',
label: 'Gesprochener Text',
required: true,
admin: {
description: 'Der Text, den Caroline spricht',
},
},
{
name: 'bRollInstructions',
type: 'array',
label: 'B-Roll Anweisungen',
labels: {
singular: 'B-Roll',
plural: 'B-Roll Anweisungen',
},
fields: [
{
name: 'instruction',
type: 'text',
required: true,
admin: {
placeholder: 'z.B. "Outfit zeigen", "Kleiderschrank-Aufnahme"',
},
},
{
name: 'timestamp',
type: 'text',
admin: {
placeholder: 'Optional: "bei 0:45"',
width: '30%',
},
},
],
},
{
name: 'textOverlays',
type: 'array',
label: 'Text-Overlays',
labels: {
singular: 'Overlay',
plural: 'Text-Overlays',
},
fields: [
{
name: 'text',
type: 'text',
required: true,
admin: {
placeholder: 'z.B. "3 Kombinationen = 0 Entscheidungen"',
},
},
{
name: 'style',
type: 'select',
defaultValue: 'standard',
options: [
{ label: 'Standard', value: 'standard' },
{ label: 'Highlight', value: 'highlight' },
{ label: 'Quote', value: 'quote' },
{ label: 'Statistik', value: 'statistic' },
{ label: 'Liste', value: 'list' },
],
},
],
},
{
name: 'visualNotes',
type: 'textarea',
label: 'Visuelle Notizen',
admin: {
placeholder: 'Zusätzliche Anweisungen für Schnitt/Grafik',
rows: 2,
},
},
],
}
1.3 Script Editor Field in YouTube Content
// In YouTubeContent.ts
{
name: 'script',
type: 'blocks',
label: 'Script',
labels: {
singular: 'Section',
plural: 'Script Sections',
},
blocks: [ScriptSectionBlock],
admin: {
description: 'Strukturiertes Video-Script mit Sections',
},
}
1.4 Script Templates
// src/collections/ScriptTemplates.ts
export const ScriptTemplates: CollectionConfig = {
slug: 'yt-script-templates',
labels: {
singular: 'Script Template',
plural: 'Script Templates',
},
admin: {
group: 'YouTube',
useAsTitle: 'name',
defaultColumns: ['name', 'series', 'format', 'updatedAt'],
},
fields: [
{
name: 'name',
type: 'text',
required: true,
localized: true,
// z.B. "GRFI Longform Template", "M2M Short Template"
},
{
name: 'series',
type: 'text',
required: true,
// z.B. "GRFI", "M2M", "SPARK"
},
{
name: 'format',
type: 'select',
required: true,
options: [
{ label: 'Short (45-60s)', value: 'short' },
{ label: 'Longform (8-16 Min)', value: 'longform' },
],
},
{
name: 'channel',
type: 'relationship',
relationTo: 'youtube-channels',
required: true,
},
{
name: 'templateSections',
type: 'blocks',
blocks: [ScriptSectionBlock],
// Vorgefüllte Section-Struktur
},
{
name: 'notes',
type: 'textarea',
localized: true,
admin: {
description: 'Hinweise zur Verwendung dieses Templates',
},
},
],
}
2. Kalenderansichten in Payload Admin
2.1 Custom Admin Views Struktur
src/
├── app/(payload)/admin/
│ ├── [[...segments]]/
│ │ └── page.tsx # Standard Payload Admin
│ └── views/
│ └── youtube/
│ ├── content-calendar/
│ │ └── page.tsx # Content/Production Calendar
│ ├── posting-calendar/
│ │ └── page.tsx # Publishing Calendar
│ └── batch-overview/
│ └── page.tsx # Batch Dashboard
2.2 Content Calendar View
// src/app/(payload)/admin/views/youtube/content-calendar/page.tsx
'use client'
import React, { useState, useEffect } from 'react'
import { useConfig } from '@payloadcms/ui'
import { format, startOfMonth, endOfMonth, eachDayOfInterval,
startOfWeek, endOfWeek, isSameMonth, isSameDay } from 'date-fns'
import { de } from 'date-fns/locale'
interface CalendarVideo {
id: string
title: string
format: 'short' | 'longform'
series: string
status: string
productionDate?: string
scheduledPublishDate?: string
channel: {
name: string
slug: string
}
}
export default function ContentCalendarView() {
const [currentMonth, setCurrentMonth] = useState(new Date())
const [videos, setVideos] = useState<CalendarVideo[]>([])
const [selectedChannel, setSelectedChannel] = useState<string>('all')
const [viewType, setViewType] = useState<'production' | 'publishing'>('production')
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchVideos()
}, [currentMonth, selectedChannel])
const fetchVideos = async () => {
setLoading(true)
const start = startOfMonth(currentMonth)
const end = endOfMonth(currentMonth)
const dateField = viewType === 'production' ? 'productionDate' : 'scheduledPublishDate'
let query = `where[${dateField}][greater_than_equal]=${start.toISOString()}&where[${dateField}][less_than_equal]=${end.toISOString()}`
if (selectedChannel !== 'all') {
query += `&where[channel][equals]=${selectedChannel}`
}
const res = await fetch(`/api/youtube-content?${query}&depth=1&limit=100`)
const data = await res.json()
setVideos(data.docs)
setLoading(false)
}
const renderCalendar = () => {
const monthStart = startOfMonth(currentMonth)
const monthEnd = endOfMonth(currentMonth)
const calendarStart = startOfWeek(monthStart, { locale: de })
const calendarEnd = endOfWeek(monthEnd, { locale: de })
const days = eachDayOfInterval({ start: calendarStart, end: calendarEnd })
return (
<div className="calendar-grid">
{/* Header */}
<div className="calendar-header">
{['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'].map(day => (
<div key={day} className="calendar-header-cell">{day}</div>
))}
</div>
{/* Days */}
<div className="calendar-body">
{days.map(day => {
const dateField = viewType === 'production' ? 'productionDate' : 'scheduledPublishDate'
const dayVideos = videos.filter(v =>
v[dateField] && isSameDay(new Date(v[dateField]), day)
)
return (
<div
key={day.toISOString()}
className={`calendar-cell ${!isSameMonth(day, currentMonth) ? 'outside-month' : ''}`}
>
<div className="calendar-date">{format(day, 'd')}</div>
<div className="calendar-videos">
{dayVideos.map(video => (
<a
key={video.id}
href={`/admin/collections/youtube-content/${video.id}`}
className={`calendar-video ${video.format} ${video.status}`}
>
<span className="video-series">{video.series}</span>
<span className="video-title">{video.title}</span>
</a>
))}
</div>
</div>
)
})}
</div>
</div>
)
}
return (
<div className="content-calendar-view">
<header className="calendar-controls">
<h1>📅 Content Calendar</h1>
<div className="controls-row">
<div className="month-navigation">
<button onClick={() => setCurrentMonth(prev =>
new Date(prev.getFullYear(), prev.getMonth() - 1)
)}>
← Vorheriger
</button>
<h2>{format(currentMonth, 'MMMM yyyy', { locale: de })}</h2>
<button onClick={() => setCurrentMonth(prev =>
new Date(prev.getFullYear(), prev.getMonth() + 1)
)}>
Nächster →
</button>
</div>
<div className="filters">
<select
value={viewType}
onChange={e => setViewType(e.target.value as any)}
>
<option value="production">Produktion</option>
<option value="publishing">Veröffentlichung</option>
</select>
<select
value={selectedChannel}
onChange={e => setSelectedChannel(e.target.value)}
>
<option value="all">Alle Kanäle</option>
<option value="blogwoman">BlogWoman</option>
<option value="corporate-de">Corporate DE</option>
<option value="business-en">Business EN</option>
</select>
</div>
</div>
{/* Legend */}
<div className="calendar-legend">
<span className="legend-item short">Short</span>
<span className="legend-item longform">Longform</span>
<span className="legend-item status-idea">Idee</span>
<span className="legend-item status-production">In Produktion</span>
<span className="legend-item status-ready">Fertig</span>
<span className="legend-item status-published">Veröffentlicht</span>
</div>
</header>
{loading ? (
<div className="loading">Lade Kalender...</div>
) : (
renderCalendar()
)}
</div>
)
}
2.3 Calendar Styles
/* src/app/(payload)/admin/views/youtube/calendar.css */
.content-calendar-view {
padding: 20px;
max-width: 1400px;
margin: 0 auto;
}
.calendar-controls {
margin-bottom: 24px;
}
.controls-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 16px;
}
.month-navigation {
display: flex;
align-items: center;
gap: 16px;
}
.month-navigation button {
padding: 8px 16px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
}
.month-navigation h2 {
min-width: 200px;
text-align: center;
}
.filters {
display: flex;
gap: 12px;
}
.filters select {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
}
.calendar-legend {
display: flex;
gap: 16px;
margin-top: 16px;
padding: 12px;
background: #f5f5f5;
border-radius: 4px;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.legend-item::before {
content: '';
width: 12px;
height: 12px;
border-radius: 2px;
}
.legend-item.short::before { background: #3B82F6; }
.legend-item.longform::before { background: #8B5CF6; }
.legend-item.status-idea::before { background: #9CA3AF; }
.legend-item.status-production::before { background: #F59E0B; }
.legend-item.status-ready::before { background: #10B981; }
.legend-item.status-published::before { background: #059669; }
/* Calendar Grid */
.calendar-grid {
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
}
.calendar-header {
display: grid;
grid-template-columns: repeat(7, 1fr);
background: #f5f5f5;
border-bottom: 1px solid #ddd;
}
.calendar-header-cell {
padding: 12px;
text-align: center;
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
}
.calendar-body {
display: grid;
grid-template-columns: repeat(7, 1fr);
}
.calendar-cell {
min-height: 120px;
border-right: 1px solid #eee;
border-bottom: 1px solid #eee;
padding: 8px;
}
.calendar-cell:nth-child(7n) {
border-right: none;
}
.calendar-cell.outside-month {
background: #fafafa;
opacity: 0.5;
}
.calendar-date {
font-weight: 600;
font-size: 14px;
margin-bottom: 8px;
}
.calendar-videos {
display: flex;
flex-direction: column;
gap: 4px;
}
.calendar-video {
display: block;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
text-decoration: none;
color: white;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.calendar-video.short {
background: #3B82F6;
}
.calendar-video.longform {
background: #8B5CF6;
}
.calendar-video .video-series {
font-weight: 600;
margin-right: 4px;
}
.calendar-video:hover {
opacity: 0.9;
transform: scale(1.02);
}
/* Status Colors als Border */
.calendar-video.idea { border-left: 3px solid #9CA3AF; }
.calendar-video.script_draft { border-left: 3px solid #F59E0B; }
.calendar-video.in_production { border-left: 3px solid #F59E0B; }
.calendar-video.ready { border-left: 3px solid #10B981; }
.calendar-video.published { border-left: 3px solid #059669; }
2.4 Admin Navigation erweitern
// payload.config.ts - Admin Navigation
export default buildConfig({
admin: {
components: {
// Custom Navigation mit YouTube Views
Nav: '/components/CustomNav',
},
},
// ...
})
// src/components/CustomNav.tsx
'use client'
import { NavGroup, Nav as DefaultNav } from '@payloadcms/ui'
import Link from 'next/link'
export const CustomNav = () => {
return (
<nav>
<DefaultNav />
{/* YouTube Views */}
<NavGroup label="YouTube Views">
<Link href="/admin/views/youtube/content-calendar">
📅 Content Calendar
</Link>
<Link href="/admin/views/youtube/posting-calendar">
📤 Posting Calendar
</Link>
<Link href="/admin/views/youtube/batch-overview">
📦 Batch Overview
</Link>
<Link href="/admin/views/youtube/monthly-goals">
🎯 Monthly Goals
</Link>
</NavGroup>
</nav>
)
}
3. Detaillierte Batch-Planung
3.1 Batch Collection
// src/collections/YtBatches.ts
import { CollectionConfig } from 'payload/types'
import { isYouTubeManager, isYouTubeCreatorOrAbove } from '../lib/youtubeAccess'
export const YtBatches: CollectionConfig = {
slug: 'yt-batches',
labels: {
singular: 'Production Batch',
plural: 'Production Batches',
},
admin: {
group: 'YouTube',
useAsTitle: 'name',
defaultColumns: ['name', 'channel', 'status', 'productionStart', 'progress'],
listSearchableFields: ['name'],
},
access: {
read: isYouTubeCreatorOrAbove,
create: isYouTubeManager,
update: isYouTubeCreatorOrAbove,
delete: isYouTubeManager,
},
fields: [
// === BASIC INFO ===
{
type: 'row',
fields: [
{
name: 'name',
type: 'text',
required: true,
admin: {
placeholder: 'z.B. "Januar Woche 1" oder "Batch 1"',
width: '50%',
},
},
{
name: 'channel',
type: 'relationship',
relationTo: 'youtube-channels',
required: true,
admin: {
width: '50%',
},
},
],
},
// === ZEITRAUM ===
{
name: 'productionPeriod',
type: 'group',
label: 'Produktionszeitraum',
fields: [
{
type: 'row',
fields: [
{
name: 'start',
type: 'date',
required: true,
label: 'Start',
admin: {
width: '50%',
date: {
pickerAppearance: 'dayOnly',
displayFormat: 'dd.MM.yyyy',
},
},
},
{
name: 'end',
type: 'date',
required: true,
label: 'Ende',
admin: {
width: '50%',
date: {
pickerAppearance: 'dayOnly',
displayFormat: 'dd.MM.yyyy',
},
},
},
],
},
{
name: 'shootDays',
type: 'array',
label: 'Drehtage',
labels: {
singular: 'Drehtag',
plural: 'Drehtage',
},
fields: [
{
type: 'row',
fields: [
{
name: 'date',
type: 'date',
required: true,
admin: {
width: '30%',
date: {
pickerAppearance: 'dayOnly',
},
},
},
{
name: 'location',
type: 'select',
options: [
{ label: '🏠 Home Studio', value: 'home' },
{ label: '🏢 Office', value: 'office' },
{ label: '🚗 Unterwegs', value: 'mobile' },
{ label: '📍 Extern', value: 'external' },
],
admin: { width: '25%' },
},
{
name: 'duration',
type: 'select',
options: [
{ label: '2 Stunden', value: '2h' },
{ label: '4 Stunden (Halbtag)', value: '4h' },
{ label: '8 Stunden (Ganztag)', value: '8h' },
],
admin: { width: '25%' },
},
{
name: 'notes',
type: 'text',
admin: {
width: '20%',
placeholder: 'Notizen',
},
},
],
},
],
},
],
},
// === CONTENT TARGETS ===
{
name: 'targets',
type: 'group',
label: 'Content-Ziele',
fields: [
{
type: 'row',
fields: [
{
name: 'shortsTarget',
type: 'number',
label: 'Shorts (Ziel)',
required: true,
defaultValue: 7,
admin: { width: '25%' },
},
{
name: 'longformsTarget',
type: 'number',
label: 'Longforms (Ziel)',
required: true,
defaultValue: 3,
admin: { width: '25%' },
},
{
name: 'totalTarget',
type: 'number',
label: 'Gesamt (Ziel)',
admin: {
width: '25%',
readOnly: true,
description: 'Automatisch berechnet',
},
hooks: {
beforeChange: [
({ siblingData }) => {
return (siblingData.shortsTarget || 0) + (siblingData.longformsTarget || 0)
},
],
},
},
{
name: 'bufferDays',
type: 'number',
label: 'Puffer (Tage)',
defaultValue: 3,
admin: {
width: '25%',
description: 'Tage zwischen Produktion und Publish',
},
},
],
},
],
},
// === SERIEN-VERTEILUNG ===
{
name: 'seriesDistribution',
type: 'array',
label: 'Serien-Verteilung',
admin: {
description: 'Welche Serien in diesem Batch produziert werden',
},
fields: [
{
type: 'row',
fields: [
{
name: 'series',
type: 'text',
required: true,
admin: {
width: '30%',
placeholder: 'z.B. GRFI',
},
},
{
name: 'shortsCount',
type: 'number',
label: 'Shorts',
defaultValue: 0,
admin: { width: '20%' },
},
{
name: 'longformsCount',
type: 'number',
label: 'Longforms',
defaultValue: 0,
admin: { width: '20%' },
},
{
name: 'priority',
type: 'select',
options: [
{ label: '🔴 Hoch', value: 'high' },
{ label: '🟡 Normal', value: 'normal' },
{ label: '🟢 Niedrig', value: 'low' },
],
defaultValue: 'normal',
admin: { width: '30%' },
},
],
},
],
},
// === STATUS & PROGRESS ===
{
name: 'status',
type: 'select',
required: true,
defaultValue: 'planning',
options: [
{ label: '📝 Planung', value: 'planning' },
{ label: '✍️ Scripts', value: 'scripting' },
{ label: '🎬 Produktion', value: 'production' },
{ label: '✂️ Schnitt', value: 'editing' },
{ label: '✅ Review', value: 'review' },
{ label: '📤 Upload-Ready', value: 'ready' },
{ label: '🎉 Veröffentlicht', value: 'published' },
],
admin: {
position: 'sidebar',
},
},
// Virtuelle Felder für Progress (berechnet via Hook)
{
name: 'progress',
type: 'group',
label: 'Fortschritt',
admin: {
position: 'sidebar',
readOnly: true,
},
fields: [
{
name: 'shortsCompleted',
type: 'number',
label: 'Shorts fertig',
admin: { readOnly: true },
},
{
name: 'longformsCompleted',
type: 'number',
label: 'Longforms fertig',
admin: { readOnly: true },
},
{
name: 'percentage',
type: 'number',
label: 'Gesamt %',
admin: { readOnly: true },
},
],
},
// === TEAM & RESOURCES ===
{
name: 'team',
type: 'group',
label: 'Team',
fields: [
{
type: 'row',
fields: [
{
name: 'producer',
type: 'relationship',
relationTo: 'users',
label: 'Producer',
admin: { width: '33%' },
},
{
name: 'editor',
type: 'relationship',
relationTo: 'users',
label: 'Editor',
admin: { width: '33%' },
},
{
name: 'reviewer',
type: 'relationship',
relationTo: 'users',
label: 'Reviewer',
admin: { width: '33%' },
},
],
},
],
},
// === NOTES ===
{
name: 'notes',
type: 'textarea',
label: 'Notizen',
admin: {
rows: 4,
},
},
],
// === HOOKS ===
hooks: {
afterRead: [
// Progress aus verknüpften Videos berechnen
async ({ doc, req }) => {
if (!doc?.id) return doc
const videos = await req.payload.find({
collection: 'youtube-content',
where: {
productionBatch: { equals: doc.id },
},
limit: 100,
})
const shorts = videos.docs.filter(v => v.format === 'short')
const longforms = videos.docs.filter(v => v.format === 'longform')
const completedStatuses = ['approved', 'upload_scheduled', 'published', 'tracked']
const shortsCompleted = shorts.filter(v =>
completedStatuses.includes(v.status)
).length
const longformsCompleted = longforms.filter(v =>
completedStatuses.includes(v.status)
).length
const totalTarget = (doc.targets?.shortsTarget || 0) + (doc.targets?.longformsTarget || 0)
const totalCompleted = shortsCompleted + longformsCompleted
const percentage = totalTarget > 0 ? Math.round((totalCompleted / totalTarget) * 100) : 0
return {
...doc,
progress: {
shortsCompleted,
longformsCompleted,
percentage,
},
}
},
],
},
}
3.2 Batch Overview Dashboard
// src/app/(payload)/admin/views/youtube/batch-overview/page.tsx
'use client'
import React, { useState, useEffect } from 'react'
import { format } from 'date-fns'
import { de } from 'date-fns/locale'
interface Batch {
id: string
name: string
channel: { name: string; slug: string }
status: string
productionPeriod: {
start: string
end: string
}
targets: {
shortsTarget: number
longformsTarget: number
totalTarget: number
}
progress: {
shortsCompleted: number
longformsCompleted: number
percentage: number
}
}
export default function BatchOverviewView() {
const [batches, setBatches] = useState<Batch[]>([])
const [selectedChannel, setSelectedChannel] = useState('all')
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchBatches()
}, [selectedChannel])
const fetchBatches = async () => {
setLoading(true)
let query = 'depth=1&limit=50&sort=-productionPeriod.start'
if (selectedChannel !== 'all') {
query += `&where[channel][equals]=${selectedChannel}`
}
const res = await fetch(`/api/yt-batches?${query}`)
const data = await res.json()
setBatches(data.docs)
setLoading(false)
}
const getStatusColor = (status: string) => {
const colors: Record<string, string> = {
planning: '#9CA3AF',
scripting: '#60A5FA',
production: '#FBBF24',
editing: '#F97316',
review: '#A78BFA',
ready: '#34D399',
published: '#10B981',
}
return colors[status] || '#9CA3AF'
}
const getStatusLabel = (status: string) => {
const labels: Record<string, string> = {
planning: '📝 Planung',
scripting: '✍️ Scripts',
production: '🎬 Produktion',
editing: '✂️ Schnitt',
review: '✅ Review',
ready: '📤 Ready',
published: '🎉 Published',
}
return labels[status] || status
}
return (
<div className="batch-overview">
<header>
<h1>📦 Batch Overview</h1>
<div className="controls">
<select
value={selectedChannel}
onChange={e => setSelectedChannel(e.target.value)}
>
<option value="all">Alle Kanäle</option>
<option value="blogwoman">BlogWoman</option>
<option value="corporate-de">Corporate DE</option>
<option value="business-en">Business EN</option>
</select>
<a href="/admin/collections/yt-batches/create" className="btn-primary">
+ Neuer Batch
</a>
</div>
</header>
{loading ? (
<div className="loading">Lade Batches...</div>
) : (
<div className="batch-grid">
{batches.map(batch => (
<a
key={batch.id}
href={`/admin/collections/yt-batches/${batch.id}`}
className="batch-card"
>
<div className="batch-header">
<h3>{batch.name}</h3>
<span
className="status-badge"
style={{ backgroundColor: getStatusColor(batch.status) }}
>
{getStatusLabel(batch.status)}
</span>
</div>
<div className="batch-channel">
{batch.channel?.name}
</div>
<div className="batch-dates">
📅 {format(new Date(batch.productionPeriod.start), 'dd.MM.', { locale: de })}
- {format(new Date(batch.productionPeriod.end), 'dd.MM.yyyy', { locale: de })}
</div>
<div className="batch-progress">
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${batch.progress?.percentage || 0}%` }}
/>
</div>
<span className="progress-text">
{batch.progress?.percentage || 0}%
</span>
</div>
<div className="batch-counts">
<div className="count">
<span className="count-value">
{batch.progress?.shortsCompleted || 0}/{batch.targets?.shortsTarget || 0}
</span>
<span className="count-label">Shorts</span>
</div>
<div className="count">
<span className="count-value">
{batch.progress?.longformsCompleted || 0}/{batch.targets?.longformsTarget || 0}
</span>
<span className="count-label">Longforms</span>
</div>
</div>
</a>
))}
</div>
)}
</div>
)
}
4. Monthly Goals Collection
// src/collections/YtMonthlyGoals.ts
import { CollectionConfig } from 'payload/types'
export const YtMonthlyGoals: CollectionConfig = {
slug: 'yt-monthly-goals',
labels: {
singular: 'Monthly Goal',
plural: 'Monthly Goals',
},
admin: {
group: 'YouTube',
useAsTitle: 'displayTitle',
defaultColumns: ['channel', 'month', 'status', 'updatedAt'],
},
fields: [
{
name: 'channel',
type: 'relationship',
relationTo: 'youtube-channels',
required: true,
},
{
name: 'month',
type: 'date',
required: true,
admin: {
date: {
pickerAppearance: 'monthOnly',
displayFormat: 'MMMM yyyy',
},
},
},
{
name: 'displayTitle',
type: 'text',
admin: {
hidden: true,
},
hooks: {
beforeChange: [
async ({ siblingData, req }) => {
if (siblingData.channel && siblingData.month) {
const channel = await req.payload.findByID({
collection: 'youtube-channels',
id: siblingData.channel,
})
const monthStr = new Date(siblingData.month).toLocaleDateString('de-DE', {
month: 'long',
year: 'numeric',
})
return `${channel?.name} - ${monthStr}`
}
return 'Neues Monatsziel'
},
],
},
},
// === CONTENT GOALS ===
{
name: 'contentGoals',
type: 'group',
label: 'Content-Ziele',
fields: [
{
type: 'row',
fields: [
{
name: 'longformsTarget',
type: 'number',
label: 'Longforms',
defaultValue: 12,
admin: { width: '25%' },
},
{
name: 'longformsCurrent',
type: 'number',
label: 'Aktuell',
defaultValue: 0,
admin: { width: '25%' },
},
{
name: 'shortsTarget',
type: 'number',
label: 'Shorts',
defaultValue: 28,
admin: { width: '25%' },
},
{
name: 'shortsCurrent',
type: 'number',
label: 'Aktuell',
defaultValue: 0,
admin: { width: '25%' },
},
],
},
],
},
// === AUDIENCE GOALS ===
{
name: 'audienceGoals',
type: 'group',
label: 'Audience-Ziele',
fields: [
{
type: 'row',
fields: [
{
name: 'subscribersTarget',
type: 'number',
label: 'Neue Abos (Ziel)',
admin: { width: '25%' },
},
{
name: 'subscribersCurrent',
type: 'number',
label: 'Aktuell',
admin: { width: '25%' },
},
{
name: 'viewsTarget',
type: 'number',
label: 'Views (Ziel)',
admin: { width: '25%' },
},
{
name: 'viewsCurrent',
type: 'number',
label: 'Aktuell',
admin: { width: '25%' },
},
],
},
],
},
// === ENGAGEMENT GOALS ===
{
name: 'engagementGoals',
type: 'group',
label: 'Engagement-Ziele',
fields: [
{
type: 'row',
fields: [
{
name: 'avgCtrTarget',
type: 'text',
label: 'Ø CTR (Ziel)',
admin: {
width: '25%',
placeholder: 'z.B. ">4%"',
},
},
{
name: 'avgCtrCurrent',
type: 'text',
label: 'Aktuell',
admin: { width: '25%' },
},
{
name: 'avgRetentionTarget',
type: 'text',
label: 'Ø Retention (Ziel)',
admin: {
width: '25%',
placeholder: 'z.B. ">50%"',
},
},
{
name: 'avgRetentionCurrent',
type: 'text',
label: 'Aktuell',
admin: { width: '25%' },
},
],
},
],
},
// === BUSINESS GOALS ===
{
name: 'businessGoals',
type: 'group',
label: 'Business-Ziele',
fields: [
{
type: 'row',
fields: [
{
name: 'newsletterSignupsTarget',
type: 'number',
label: 'Newsletter-Anmeldungen',
admin: { width: '25%' },
},
{
name: 'newsletterSignupsCurrent',
type: 'number',
label: 'Aktuell',
admin: { width: '25%' },
},
{
name: 'affiliateClicksTarget',
type: 'number',
label: 'Affiliate-Klicks',
admin: { width: '25%' },
},
{
name: 'affiliateClicksCurrent',
type: 'number',
label: 'Aktuell',
admin: { width: '25%' },
},
],
},
],
},
// === CUSTOM GOALS ===
{
name: 'customGoals',
type: 'array',
label: 'Weitere Ziele',
fields: [
{
type: 'row',
fields: [
{
name: 'metric',
type: 'text',
required: true,
admin: {
width: '40%',
placeholder: 'z.B. "SPARK Videos"',
},
},
{
name: 'target',
type: 'text',
required: true,
admin: {
width: '20%',
placeholder: 'Ziel',
},
},
{
name: 'current',
type: 'text',
admin: {
width: '20%',
placeholder: 'Aktuell',
},
},
{
name: 'status',
type: 'select',
options: [
{ label: '🟢 On Track', value: 'on_track' },
{ label: '🟡 At Risk', value: 'at_risk' },
{ label: '✅ Achieved', value: 'achieved' },
{ label: '❌ Missed', value: 'missed' },
],
admin: { width: '20%' },
},
],
},
],
},
{
name: 'notes',
type: 'textarea',
label: 'Notizen / Learnings',
},
],
}
5. Erweiterte YouTube Content Collection
// Erweiterungen für YouTubeContent.ts
// Neue Felder hinzufügen:
// === PRODUKTION ===
{
name: 'productionBatch',
type: 'relationship',
relationTo: 'yt-batches',
label: 'Produktions-Batch',
admin: {
position: 'sidebar',
},
},
{
name: 'productionWeek',
type: 'number',
label: 'Produktionswoche',
min: 1,
max: 52,
admin: {
position: 'sidebar',
},
},
{
name: 'calendarWeek',
type: 'number',
label: 'Kalenderwoche',
min: 1,
max: 52,
admin: {
position: 'sidebar',
},
},
{
name: 'productionDate',
type: 'date',
label: 'Produktionsdatum',
admin: {
date: {
pickerAppearance: 'dayOnly',
displayFormat: 'dd.MM.yyyy',
},
},
},
{
name: 'targetDuration',
type: 'text',
label: 'Ziel-Dauer',
admin: {
placeholder: 'z.B. "8-12 Min" oder "45-58s"',
},
},
{
name: 'bRollNotes',
type: 'textarea',
label: 'B-Roll / Setting Notizen',
admin: {
rows: 2,
},
},
// === POSTING ===
{
name: 'publishTime',
type: 'text',
label: 'Posting-Uhrzeit',
admin: {
placeholder: 'z.B. "07:00" oder "17:00"',
},
},
{
name: 'thumbnailText',
type: 'text',
label: 'Thumbnail-Text',
admin: {
placeholder: 'z.B. "BOARD READY | 7 MIN"',
},
},
{
name: 'ctaType',
type: 'select',
label: 'CTA-Typ',
options: [
{ label: 'Link in Bio', value: 'link_in_bio' },
{ label: 'Newsletter', value: 'newsletter' },
{ label: 'Longform verlinken', value: 'longform_link' },
{ label: 'Benutzerdefiniert', value: 'custom' },
],
},
{
name: 'ctaDetail',
type: 'text',
label: 'CTA-Detail',
admin: {
placeholder: 'z.B. "GRFI-Checkliste" oder "CPW-Rechner"',
condition: (data) => data?.ctaType,
},
},
// === SCRIPT (Block-basiert) ===
{
name: 'script',
type: 'blocks',
label: 'Script',
labels: {
singular: 'Section',
plural: 'Script Sections',
},
blocks: [ScriptSectionBlock],
},
// === UPLOAD CHECKLIST ===
{
name: 'uploadChecklist',
type: 'array',
label: 'Upload-Checkliste',
admin: {
condition: (data) => ['approved', 'upload_scheduled', 'published'].includes(data?.status),
},
fields: [
{
type: 'row',
fields: [
{
name: 'step',
type: 'text',
required: true,
admin: { width: '40%' },
},
{
name: 'completed',
type: 'checkbox',
label: '✓',
admin: { width: '10%' },
},
{
name: 'completedAt',
type: 'date',
admin: {
width: '25%',
readOnly: true,
},
},
{
name: 'completedBy',
type: 'relationship',
relationTo: 'users',
admin: {
width: '25%',
readOnly: true,
},
},
],
},
],
},
// === DISCLAIMERS ===
{
name: 'disclaimers',
type: 'array',
label: 'Disclaimers',
fields: [
{
type: 'row',
fields: [
{
name: 'type',
type: 'select',
required: true,
options: [
{ label: '⚠️ Medizinisch', value: 'medical' },
{ label: '⚖️ Rechtlich', value: 'legal' },
{ label: '🔗 Affiliate', value: 'affiliate' },
{ label: '🤝 Sponsored', value: 'sponsored' },
],
admin: { width: '25%' },
},
{
name: 'text',
type: 'text',
admin: { width: '50%' },
},
{
name: 'placement',
type: 'select',
options: [
{ label: 'Gesprochen', value: 'spoken' },
{ label: 'Text-Overlay', value: 'overlay' },
{ label: 'Beschreibung', value: 'description' },
{ label: 'Überall', value: 'all' },
],
admin: { width: '25%' },
},
],
},
],
},
6. Zusammenfassung der neuen Collections
| Collection | Slug | Zweck |
|---|---|---|
| YouTube Channels | youtube-channels |
Kanäle (existiert) |
| YouTube Content | youtube-content |
Videos + Script (erweitert) |
| YT Tasks | yt-tasks |
Aufgaben (existiert) |
| YT Notifications | yt-notifications |
Benachrichtigungen (existiert) |
| YT Batches | yt-batches |
NEU: Produktions-Batches |
| YT Monthly Goals | yt-monthly-goals |
NEU: Monatsziele |
| YT Script Templates | yt-script-templates |
NEU: Skript-Vorlagen |
| YT Checklist Templates | yt-checklist-templates |
NEU: Upload-Checklisten |
7. Custom Admin Views
| View | Pfad | Zweck |
|---|---|---|
| Content Calendar | /admin/views/youtube/content-calendar |
Produktions- & Publishing-Kalender |
| Batch Overview | /admin/views/youtube/batch-overview |
Dashboard aller Batches |
| Monthly Goals | /admin/views/youtube/monthly-goals |
KPI-Dashboard |
8. Migrations
// src/migrations/20260112_200000_youtube_ops_v2.ts
import { MigrateUpArgs, MigrateDownArgs } from '@payloadcms/db-postgres'
export async function up({ payload }: MigrateUpArgs): Promise<void> {
// 1. Neue Enums
await payload.db.drizzle.execute(`
CREATE TYPE enum_yt_batches_status AS ENUM (
'planning', 'scripting', 'production', 'editing', 'review', 'ready', 'published'
);
CREATE TYPE enum_script_section_type AS ENUM (
'hook', 'intro_ident', 'context', 'content_part', 'summary', 'cta', 'outro', 'disclaimer'
);
CREATE TYPE enum_cta_type AS ENUM (
'link_in_bio', 'newsletter', 'longform_link', 'custom'
);
CREATE TYPE enum_disclaimer_type AS ENUM (
'medical', 'legal', 'affiliate', 'sponsored'
);
CREATE TYPE enum_disclaimer_placement AS ENUM (
'spoken', 'overlay', 'description', 'all'
);
`);
// 2. Neue Tabellen
// ... (vollständige Migration)
}
export async function down({ payload }: MigrateDownArgs): Promise<void> {
// Rollback
}
Nächste Schritte
- Claude Code Integration Prompt erstellen mit allen Collections, Blocks und Views
- Seed-Daten mit BlogWoman Januar Content als Beispiel
- CSS Styles für Calendar und Dashboard Views
- Lexical Custom Block für Script Section Editor
Soll ich jetzt den vollständigen Claude Code Integration Prompt erstellen?