diff --git a/scripts/dokploy_apply_pipeline_calendar_link.py b/scripts/dokploy_apply_pipeline_calendar_link.py new file mode 100644 index 0000000..aee9bd4 --- /dev/null +++ b/scripts/dokploy_apply_pipeline_calendar_link.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Apply sql/creative_pipeline_calendar_link.sql via Dokploy supabase-db terminal.""" +from __future__ import annotations + +import base64 +import os +import re +import sys +from pathlib import Path + +from playwright.sync_api import sync_playwright + +ROOT = Path("/Users/4ventures/4ventures-client-portal") +BPS = Path("/Users/4ventures/Projects/bps-facturacion") +SQL = ROOT / "sql" / "creative_pipeline_calendar_link.sql" +BASE = "https://dokploy.4ventures.es" + + +def env(key: str) -> str: + v = os.environ.get(key, "").strip() + if v: + return v + p = BPS / ".env" + if not p.is_file(): + return "" + for line in p.read_text().splitlines(): + if line.startswith(f"{key}="): + return line.split("=", 1)[1].strip().strip('"').strip("'") + return "" + + +def main() -> int: + email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es" + password = env("DOKPLOY_PASSWORD") + if not password: + print("missing DOKPLOY_PASSWORD", flush=True) + return 1 + b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode() + chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)] + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1400, "height": 1000}) + page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000) + page.wait_for_timeout(800) + if "/dashboard" not in page.url: + page.locator('input[type="email"], input[name="email"]').first.fill(email) + page.locator('input[type="password"]').first.fill(password) + page.locator('button[type="submit"]').first.click() + page.wait_for_url(re.compile(r"/dashboard"), timeout=90000) + print("login ok", flush=True) + page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000) + page.wait_for_timeout(800) + page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill( + "supabase-db" + ) + page.wait_for_timeout(1000) + page.keyboard.press("Escape") + page.wait_for_timeout(200) + page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click() + page.wait_for_timeout(400) + page.get_by_text("Terminal", exact=True).click() + page.wait_for_timeout(1000) + if page.locator("button", has_text="Bash").count(): + page.locator("button", has_text="Bash").first.click() + page.wait_for_timeout(2800) + if page.locator("textarea.xterm-helper-textarea").count(): + page.locator("textarea.xterm-helper-textarea").first.click() + + def run(cmd: str, wait: int = 800) -> None: + page.keyboard.type(cmd, delay=1) + page.keyboard.press("Enter") + page.wait_for_timeout(wait) + + run("rm -f /tmp/cpl.b64 /tmp/cpl.sql; echo READY", 1200) + for i, ch in enumerate(chunks): + op = ">" if i == 0 else ">>" + run(f"printf '%s' '{ch}' {op} /tmp/cpl.b64", 400) + run( + "base64 -d /tmp/cpl.b64 > /tmp/cpl.sql && " + "psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/cpl.sql; echo EXIT:$?", + 12000, + ) + text = page.evaluate( + "() => (document.querySelector('.xterm-rows')||{}).innerText||''" + ) + browser.close() + ok = "EXIT:0" in text + print("SQL_OK", ok, flush=True) + print(text[-500:], flush=True) + return 0 if ok else 6 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sql/creative_pipeline_calendar_link.sql b/sql/creative_pipeline_calendar_link.sql new file mode 100644 index 0000000..8cc9eeb --- /dev/null +++ b/sql/creative_pipeline_calendar_link.sql @@ -0,0 +1,12 @@ +-- Link pipeline creatives to RRSS calendar pieces (documents content.pieces[].id). +-- Idempotent. + +alter table public.creative_pipeline + add column if not exists calendar_piece_id text; + +create index if not exists creative_pipeline_calendar_piece_idx + on public.creative_pipeline (tenant_id, calendar_piece_id) + where calendar_piece_id is not null; + +comment on column public.creative_pipeline.calendar_piece_id is + 'Stable id of the RRSS calendar piece (documents type=calendar → pieces[].id). Agency links pipeline ↔ editorial plan.'; diff --git a/src/app/api/calendar/pieces/route.ts b/src/app/api/calendar/pieces/route.ts new file mode 100644 index 0000000..7327f25 --- /dev/null +++ b/src/app/api/calendar/pieces/route.ts @@ -0,0 +1,256 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { resolveActiveTenant } from '@/lib/tenant-context'; +import { + isAgencyRole, + serviceHeaders, + supabaseUrl, + userRole, +} from '@/lib/agency'; +import type { RrssPiece } from '@/components/RrssMonthView'; +import { + asPlanObject, + emptyMonthPlan, + newPieceId, + piecesFromDocs, + type CalendarDoc, +} from '@/lib/calendar-documents'; + +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +function str(v: unknown, max = 4000): string | null { + if (typeof v !== 'string') return null; + const t = v.trim(); + if (!t) return null; + return t.slice(0, max); +} + +async function loadDocs( + base: string, + headers: { apikey: string; Authorization: string }, + tenantId: string +): Promise { + const r = await fetch( + `${base}/rest/v1/documents?tenant_id=eq.${encodeURIComponent( + tenantId + )}&type=eq.calendar&select=id,title,content`, + { headers, cache: 'no-store' } + ); + return r.ok ? ((await r.json()) as CalendarDoc[]) : []; +} + +/** Agency: list RRSS calendar pieces for the active client. */ +export async function GET() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + if (!isAgencyRole(userRole(user))) { + return NextResponse.json({ error: 'Solo agencia' }, { status: 403 }); + } + const ctx = await resolveActiveTenant(user); + if (!ctx.tenantId || ctx.portfolioWide) { + return NextResponse.json( + { pieces: [], warning: 'Selecciona un cliente en Contexto.' }, + { status: 200 } + ); + } + const headers = serviceHeaders(); + const base = supabaseUrl(); + if (!headers || !base) { + return NextResponse.json({ error: 'Service role required' }, { status: 503 }); + } + + const docs = await loadDocs(base, headers, ctx.tenantId); + const pieces = piecesFromDocs(docs); + + // Mark which are already linked in pipeline + const pipe = await fetch( + `${base}/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent( + ctx.tenantId + )}&calendar_piece_id=not.is.null&select=id,calendar_piece_id`, + { headers, cache: 'no-store' } + ); + const links = pipe.ok + ? ((await pipe.json()) as { id: string; calendar_piece_id: string }[]) + : []; + const linked = new Set(links.map((l) => l.calendar_piece_id)); + + return NextResponse.json({ + pieces: pieces.map((p) => ({ + ...p, + linked_pipeline: linked.has(p.id), + pipeline_id: links.find((l) => l.calendar_piece_id === p.id)?.id || null, + })), + }); +} + +/** + * Agency upsert of a calendar piece. + * Body: { pieceId?, date, title, format?, hook?, body?, cta?, hashtags?, pillar?, time?, platform?, kind? } + * - with pieceId: update existing + * - without: create in that month's document (create month plan if missing) + */ +export async function PATCH(req: NextRequest) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + if (!isAgencyRole(userRole(user))) { + return NextResponse.json({ error: 'Solo agencia' }, { status: 403 }); + } + const ctx = await resolveActiveTenant(user); + if (!ctx.tenantId || ctx.portfolioWide) { + return NextResponse.json( + { error: 'Selecciona un cliente en Contexto.' }, + { status: 400 } + ); + } + const headers = serviceHeaders(); + const base = supabaseUrl(); + if (!headers || !base) { + return NextResponse.json({ error: 'Service role required' }, { status: 503 }); + } + + const body = (await req.json().catch(() => ({}))) as Record; + const pieceId = str(body.pieceId, 120); + const title = str(body.title, 300); + const date = str(body.date, 10); + if (!title) { + return NextResponse.json({ error: 'title requerido' }, { status: 400 }); + } + if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) { + return NextResponse.json({ error: 'date (YYYY-MM-DD) requerida' }, { status: 400 }); + } + const ym = date.slice(0, 7); + + const docs = await loadDocs(base, headers, ctx.tenantId); + let target = docs.find((d) => { + const plan = asPlanObject(d.content); + return plan && String(plan.month) === ym; + }); + + let plan = target ? asPlanObject(target.content) : null; + if (!plan) { + plan = emptyMonthPlan(ym, ctx.label) as unknown as Record; + } + const pieces = Array.isArray(plan.pieces) + ? ([...plan.pieces] as RrssPiece[]) + : []; + + const patch: Partial = { + title, + date, + time: str(body.time, 8) || undefined, + platform: str(body.platform, 40) || 'instagram', + format: str(body.format, 40) || undefined, + pillar: str(body.pillar, 80) || undefined, + hook: str(body.hook, 500) || undefined, + body: str(body.body, 8000) || undefined, + cta: str(body.cta, 300) || undefined, + hashtags: str(body.hashtags, 500) || undefined, + kind: str(body.kind, 20) || undefined, + status: str(body.status, 40) || 'planned', + }; + + let saved: RrssPiece; + if (pieceId) { + const idx = pieces.findIndex((p) => p.id === pieceId); + if (idx < 0) { + // Piece moved month or missing — create with same id in target month + saved = { id: pieceId, title, date, platform: 'instagram', ...patch } as RrssPiece; + // Remove from any other month docs + for (const d of docs) { + const p = asPlanObject(d.content); + if (!p || !Array.isArray(p.pieces)) continue; + const list = p.pieces as RrssPiece[]; + const i = list.findIndex((x) => x.id === pieceId); + if (i < 0) continue; + if (d.id === target?.id) continue; + list.splice(i, 1); + p.pieces = list; + await fetch(`${base}/rest/v1/documents?id=eq.${d.id}`, { + method: 'PATCH', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=minimal', + }, + body: JSON.stringify({ content: p }), + }); + } + pieces.push(saved); + } else { + saved = { ...pieces[idx], ...patch, id: pieceId, title, date }; + pieces[idx] = saved; + } + } else { + saved = { + id: newPieceId(date, title), + platform: 'instagram', + status: 'planned', + ...patch, + title, + date, + } as RrssPiece; + pieces.push(saved); + } + + plan.pieces = pieces.sort((a, b) => + String(a.date).localeCompare(String(b.date)) + ); + plan.month = ym; + + if (target) { + const r = await fetch(`${base}/rest/v1/documents?id=eq.${target.id}`, { + method: 'PATCH', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=representation', + }, + body: JSON.stringify({ content: plan }), + }); + if (!r.ok) { + return NextResponse.json({ error: await r.text() }, { status: 500 }); + } + } else { + const row = { + tenant_id: ctx.tenantId, + type: 'calendar', + title: `RRSS ${(plan as { label?: string }).label || ym}`, + content: plan, + status: 'approved', + }; + const r = await fetch(`${base}/rest/v1/documents`, { + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=representation', + }, + body: JSON.stringify(row), + }); + if (!r.ok) { + return NextResponse.json({ error: await r.text() }, { status: 500 }); + } + const created = await r.json(); + target = Array.isArray(created) ? created[0] : created; + } + + return NextResponse.json({ + ok: true, + piece: { + ...saved, + month: ym, + document_id: target?.id, + }, + }); +} diff --git a/src/app/api/creative-pipeline/route.ts b/src/app/api/creative-pipeline/route.ts index a9073de..5963e13 100644 --- a/src/app/api/creative-pipeline/route.ts +++ b/src/app/api/creative-pipeline/route.ts @@ -12,9 +12,17 @@ import { parseScheduledAt, PUBLISH_STATUSES, } from '@/lib/pipeline-schedule'; +import { + asPlanObject, + buildCaption, + mapFormatToPipeline, + piecesFromDocs, + scheduledAtFromPiece, + type CalendarDoc, +} from '@/lib/calendar-documents'; const SELECT = - 'id,external_ref,title,format,month,status,cover_path,cover_mime,slide_paths,caption,notes,approved_at,created_at,updated_at,scheduled_at,channels,publish_status,publish_attempts,published_at,meta_ig_media_id,meta_fb_post_id,publish_error'; + 'id,external_ref,title,format,month,status,cover_path,cover_mime,slide_paths,caption,notes,approved_at,created_at,updated_at,scheduled_at,channels,publish_status,publish_attempts,published_at,meta_ig_media_id,meta_fb_post_id,publish_error,calendar_piece_id'; const FORMATS = new Set(['post', 'story', 'reel', 'carrusel']); @@ -153,29 +161,81 @@ export async function POST(req: NextRequest) { ); } - const body = (await req.json().catch(() => ({}))) as Record; - const title = str(body.title, 300); - if (!title) { - return NextResponse.json({ error: 'title requerido' }, { status: 400 }); - } - const format = str(body.format, 20); - - const row = { - tenant_id: ctx.tenantId, - title, - external_ref: str(body.external_ref, 80), - format: format && FORMATS.has(format) ? format : 'post', - month: str(body.month, 7), - caption: str(body.caption, 4000), - notes: str(body.notes, 2000), - status: 'propuesta', - created_by: user.id, - }; - const headers = serviceHeaders(); if (!headers) { return NextResponse.json({ error: 'Service role required' }, { status: 503 }); } + + const body = (await req.json().catch(() => ({}))) as Record; + const calendarPieceId = str(body.calendar_piece_id, 120); + + let title = str(body.title, 300); + let format = str(body.format, 20); + let month = str(body.month, 7); + let caption = str(body.caption, 4000); + let notes = str(body.notes, 2000); + let scheduledAt = parseScheduledAt(body.scheduled_at); + let channels = 'channels' in body ? normalizeChannels(body.channels) : null; + const resolvedCalendarId = calendarPieceId; + + // Inherit from RRSS calendar piece when linked. + if (calendarPieceId) { + const docsRes = await fetch( + `${supabaseUrl()}/rest/v1/documents?tenant_id=eq.${encodeURIComponent( + ctx.tenantId + )}&type=eq.calendar&select=id,title,content`, + { headers, cache: 'no-store' } + ); + const docs = docsRes.ok ? ((await docsRes.json()) as CalendarDoc[]) : []; + const cal = piecesFromDocs(docs).find((p) => p.id === calendarPieceId); + if (cal) { + title = title || cal.title; + format = format || mapFormatToPipeline(cal.format, cal.kind); + month = month || cal.month; + caption = caption || buildCaption(cal) || null; + if (!notes) { + const bits = [cal.pillar ? `Pilar: ${cal.pillar}` : '', cal.platform] + .filter(Boolean) + .join(' · '); + notes = bits || null; + } + if (scheduledAt === undefined || scheduledAt === null) { + scheduledAt = scheduledAtFromPiece(cal); + } + if (!channels) { + const plat = (cal.platform || '').toLowerCase(); + channels = + plat.includes('linkedin') + ? ['instagram'] + : plat.includes('facebook') + ? ['facebook', 'instagram'] + : ['instagram', 'facebook']; + } + } + } + + if (!title) { + return NextResponse.json({ error: 'title requerido' }, { status: 400 }); + } + + const row: Record = { + tenant_id: ctx.tenantId, + title, + external_ref: str(body.external_ref, 80), + format: format && FORMATS.has(format) ? format : 'post', + month: month || null, + caption: caption || null, + notes: notes || null, + status: 'propuesta', + created_by: user.id, + calendar_piece_id: resolvedCalendarId || null, + }; + if (scheduledAt) { + row.scheduled_at = scheduledAt; + row.publish_status = 'idle'; + } + if (channels) row.channels = channels; + const r = await fetch(`${supabaseUrl()}/rest/v1/creative_pipeline`, { method: 'POST', headers: { @@ -189,7 +249,39 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: await r.text() }, { status: 500 }); } const data = await r.json(); - return NextResponse.json({ item: Array.isArray(data) ? data[0] : data }, { status: 201 }); + const item = Array.isArray(data) ? data[0] : data; + + // Bidirectional: stamp calendar piece with pipeline_id for later merges. + if (resolvedCalendarId && item?.id && headers) { + const docsRes = await fetch( + `${supabaseUrl()}/rest/v1/documents?tenant_id=eq.${encodeURIComponent( + ctx.tenantId + )}&type=eq.calendar&select=id,title,content`, + { headers, cache: 'no-store' } + ); + const docs = docsRes.ok ? ((await docsRes.json()) as CalendarDoc[]) : []; + for (const doc of docs) { + const plan = asPlanObject(doc.content); + if (!plan || !Array.isArray(plan.pieces)) continue; + const pieces = plan.pieces as { id: string; pipeline_id?: string }[]; + const idx = pieces.findIndex((p) => p.id === resolvedCalendarId); + if (idx < 0) continue; + pieces[idx] = { ...pieces[idx], pipeline_id: item.id }; + plan.pieces = pieces; + await fetch(`${supabaseUrl()}/rest/v1/documents?id=eq.${doc.id}`, { + method: 'PATCH', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=minimal', + }, + body: JSON.stringify({ content: plan }), + }); + break; + } + } + + return NextResponse.json({ item }, { status: 201 }); } export async function PATCH(req: NextRequest) { @@ -274,6 +366,9 @@ export async function PATCH(req: NextRequest) { if ('cover_path' in body) clean.cover_path = str(body.cover_path, 500); if ('cover_mime' in body) clean.cover_mime = str(body.cover_mime, 100); if ('slide_paths' in body) clean.slide_paths = slidePaths(body.slide_paths); + if ('calendar_piece_id' in body) { + clean.calendar_piece_id = str(body.calendar_piece_id, 120); + } // Scheduling — agency only. Client never receives these keys. if ('scheduled_at' in body) { diff --git a/src/components/CreativePipelineBoard.tsx b/src/components/CreativePipelineBoard.tsx index 34930de..427f366 100644 --- a/src/components/CreativePipelineBoard.tsx +++ b/src/components/CreativePipelineBoard.tsx @@ -21,6 +21,30 @@ import { toDatetimeLocalValue, type PublishChannel, } from '@/lib/pipeline-schedule'; +import { + buildCaption, + mapFormatToPipeline, + mapPipelineFormatToCalendar, + scheduledAtFromPiece, +} from '@/lib/calendar-documents'; + +type CalendarOption = { + id: string; + title: string; + date: string; + time?: string; + month: string; + format?: string; + kind?: string; + pillar?: string; + hook?: string; + body?: string; + cta?: string; + hashtags?: string; + platform?: string; + linked_pipeline?: boolean; + pipeline_id?: string | null; +}; type PipelineItem = { id: string; @@ -42,6 +66,7 @@ type PipelineItem = { publish_status?: string | null; publish_error?: string | null; publish_attempts?: number | null; + calendar_piece_id?: string | null; }; const STATUS_LABEL: Record = { @@ -112,9 +137,17 @@ export default function CreativePipelineBoard() { const [err, setErr] = useState(null); const [busyId, setBusyId] = useState(null); const [showForm, setShowForm] = useState(false); + const [calPieces, setCalPieces] = useState([]); + const [calPick, setCalPick] = useState('__new__'); const [newTitle, setNewTitle] = useState(''); const [newFormat, setNewFormat] = useState('post'); const [newMonth, setNewMonth] = useState(''); + const [newCaption, setNewCaption] = useState(''); + const [newHook, setNewHook] = useState(''); + const [newPillar, setNewPillar] = useState(''); + const [newDate, setNewDate] = useState(''); + const [newTime, setNewTime] = useState('10:00'); + const [syncCalendar, setSyncCalendar] = useState(true); const [selectedMonth, setSelectedMonth] = useState('all'); const [selectedFormat, setSelectedFormat] = useState('all'); const [previewItem, setPreviewItem] = useState(null); @@ -132,10 +165,65 @@ export default function CreativePipelineBoard() { } } + async function loadCalendarPieces() { + try { + const r = await fetch('/api/calendar/pieces'); + const j = await r.json(); + if (r.ok) setCalPieces((j.pieces || []) as CalendarOption[]); + } catch { + /* ignore */ + } + } + useEffect(() => { void load(); }, []); + useEffect(() => { + if (showForm && isAgency) void loadCalendarPieces(); + }, [showForm, isAgency]); + + function applyCalendarPiece(id: string) { + setCalPick(id); + if (id === '__new__') { + setNewTitle(''); + setNewCaption(''); + setNewHook(''); + setNewPillar(''); + setNewFormat('post'); + setNewMonth(''); + setNewDate(''); + setNewTime('10:00'); + return; + } + const p = calPieces.find((x) => x.id === id); + if (!p) return; + setNewTitle(p.title || ''); + setNewHook(p.hook || ''); + setNewCaption(buildCaption(p) || p.body || ''); + setNewPillar(p.pillar || ''); + setNewFormat( + mapFormatToPipeline(p.format, p.kind) as PipelineItem['format'] + ); + setNewMonth(p.month || p.date.slice(0, 7)); + setNewDate(p.date.slice(0, 10)); + setNewTime((p.time || '10:00').slice(0, 5)); + } + + function resetForm() { + setCalPick('__new__'); + setNewTitle(''); + setNewCaption(''); + setNewHook(''); + setNewPillar(''); + setNewFormat('post'); + setNewMonth(''); + setNewDate(''); + setNewTime('10:00'); + setSyncCalendar(true); + setShowForm(false); + } + const monthCounts = useMemo(() => { const map = new Map(); for (const it of items) { @@ -155,7 +243,8 @@ export default function CreativePipelineBoard() { const filteredItems = useMemo(() => { return items.filter((it) => { - if (selectedMonth !== 'all' && (it.month || 'sin-mes') !== selectedMonth) return false; + if (selectedMonth !== 'all' && (it.month || 'sin-mes') !== selectedMonth) + return false; if (selectedFormat !== 'all' && it.format !== selectedFormat) return false; return true; }); @@ -175,19 +264,64 @@ export default function CreativePipelineBoard() { e.preventDefault(); if (!newTitle.trim()) return; setErr(null); + + let calendarPieceId: string | null = + calPick !== '__new__' ? calPick : null; + + if (syncCalendar) { + const date = + newDate || + (newMonth ? `${newMonth}-01` : new Date().toISOString().slice(0, 10)); + const calFmt = mapPipelineFormatToCalendar(newFormat); + const calRes = await fetch('/api/calendar/pieces', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + pieceId: calendarPieceId, + title: newTitle.trim(), + date, + time: newTime || '10:00', + format: calFmt.format, + kind: calFmt.kind, + hook: newHook || undefined, + body: newCaption || undefined, + pillar: newPillar || undefined, + platform: 'instagram', + }), + }); + const calData = await calRes.json(); + if (!calRes.ok) { + setErr(calData.error || 'No se pudo guardar en el calendario RRSS'); + return; + } + calendarPieceId = calData.piece?.id || calendarPieceId; + void loadCalendarPieces(); + } + + const sched = newDate + ? scheduledAtFromPiece({ date: newDate, time: newTime }) + : null; + const res = await fetch('/api/creative-pipeline', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title: newTitle, format: newFormat, month: newMonth || null }), + body: JSON.stringify({ + title: newTitle.trim(), + format: newFormat, + month: newMonth || (newDate ? newDate.slice(0, 7) : null), + caption: newCaption || null, + notes: newPillar ? `Pilar: ${newPillar}` : null, + calendar_piece_id: calendarPieceId, + scheduled_at: sched, + channels: ['instagram', 'facebook'], + }), }); const data = await res.json(); if (!res.ok) { setErr(data.error || 'Error al crear'); return; } - setNewTitle(''); - setNewMonth(''); - setShowForm(false); + resetForm(); await load(); } @@ -387,44 +521,164 @@ export default function CreativePipelineBoard() { {showForm && isAgency ? ( -
+
- - setNewTitle(e.target.value)} - className="mt-1 rounded-lg border border-gray-200 px-3 py-2 text-sm" - placeholder="Certifica tu nivel" - /> -
-
- + +

+ Elige una pieza del plan editorial del cliente (Contexto) para + heredar título, copy, pilar y fecha. O crea una nueva. +

-
- + +
+
+ + setNewTitle(e.target.value)} + className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm" + placeholder="Nombre de la pieza" + required + /> +
+
+ + +
+
+ + setNewPillar(e.target.value)} + className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm" + placeholder="Educativo, reseña…" + /> +
+
+ + { + setNewDate(e.target.value); + if (e.target.value) setNewMonth(e.target.value.slice(0, 7)); + }} + className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm" + /> +
+
+ + setNewTime(e.target.value)} + className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm" + /> +
+
+ + setNewMonth(e.target.value)} + className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm" + /> +
+
+ + setNewHook(e.target.value)} + className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm" + placeholder="Gancho / primera línea" + /> +
+
+ +