cms.c2sgmbh/prompts/hub-concept.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

1830 lines
48 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# YouTube Operations Hub v2 Vollständiges Konzept
## Übersicht der Erweiterungen
### Neue Features:
1. **Script Editor** Custom Lexical Rich-Text-Editor mit Section-Blöcken
2. **Kalenderansichten** Content Calendar + Posting Calendar direkt in Payload
3. **Batch-Planung** Detaillierte Produktions-Batches mit Kapazitätsplanung
4. **Monthly Goals Dashboard** KPI-Tracking pro Kanal und Monat
5. **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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```css
/* 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
```typescript
// payload.config.ts - Admin Navigation
export default buildConfig({
admin: {
components: {
// Custom Navigation mit YouTube Views
Nav: '/components/CustomNav',
},
},
// ...
})
```
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
1. **Claude Code Integration Prompt erstellen** mit allen Collections, Blocks und Views
2. **Seed-Daten** mit BlogWoman Januar Content als Beispiel
3. **CSS Styles** für Calendar und Dashboard Views
4. **Lexical Custom Block** für Script Section Editor
Soll ich jetzt den vollständigen **Claude Code Integration Prompt** erstellen?