feat(panel-cliente): soporte de filtrado mensual, histórico instagram ene-sep 2026 y enlaces a publicaciones
This commit is contained in:
292
scripts/sync_bps_instagram_history.ts
Normal file
292
scripts/sync_bps_instagram_history.ts
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
/**
|
||||||
|
* Extrae todas las publicaciones de Instagram de Blue Parrot School
|
||||||
|
* (Enero 2026 a Septiembre 2026) vía Meta Graph API:
|
||||||
|
* 1. Descarga portadas y slides de carruseles.
|
||||||
|
* 2. Sube los archivos a Supabase Storage (tenant-assets).
|
||||||
|
* 3. Inserta/actualiza las piezas en public.creative_pipeline (status: 'publicado').
|
||||||
|
* 4. Actualiza/sincroniza en public.documents (type: 'calendar') para reflejarlas en el Calendario.
|
||||||
|
*/
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
config({ path: resolve(process.cwd(), '.env.local') });
|
||||||
|
|
||||||
|
const BPS_TENANT_ID = '8bec977a-6032-4759-aa7f-b5042081f450';
|
||||||
|
const BPS_IG_ID = '17841431459801731';
|
||||||
|
const BUCKET = 'tenant-assets';
|
||||||
|
|
||||||
|
const META_TOKEN = process.env.META_ACCESS_TOKEN || '';
|
||||||
|
|
||||||
|
type IgMedia = {
|
||||||
|
id: string;
|
||||||
|
caption?: string;
|
||||||
|
media_type: 'IMAGE' | 'VIDEO' | 'CAROUSEL_ALBUM';
|
||||||
|
media_url?: string;
|
||||||
|
thumbnail_url?: string;
|
||||||
|
permalink?: string;
|
||||||
|
timestamp: string;
|
||||||
|
children?: {
|
||||||
|
data: Array<{
|
||||||
|
id: string;
|
||||||
|
media_type: string;
|
||||||
|
media_url: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function cleanTitle(caption?: string, date?: string): string {
|
||||||
|
if (!caption) return `Publicación Instagram ${date || ''}`.trim();
|
||||||
|
const firstLine = caption.split('\n')[0].trim();
|
||||||
|
// Quitar hashtags al inicio si los hay
|
||||||
|
const clean = firstLine.replace(/#\S+/g, '').trim();
|
||||||
|
if (clean.length > 5) {
|
||||||
|
return clean.slice(0, 100);
|
||||||
|
}
|
||||||
|
return caption.slice(0, 100).trim() || `Publicación ${date || ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchBuffer(url: string): Promise<Buffer | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const arrayBuffer = await res.arrayBuffer();
|
||||||
|
return Buffer.from(arrayBuffer);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error descargando ${url}:`, err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const url = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
|
||||||
|
const key = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim();
|
||||||
|
if (!url || !key) throw new Error('Faltan NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY');
|
||||||
|
|
||||||
|
const admin = createClient(url, key, { auth: { persistSession: false, autoRefreshToken: false } });
|
||||||
|
|
||||||
|
console.log('1. Consultando Meta Graph API para Blue Parrot School (@blueparrotschool)...');
|
||||||
|
const allMedia: IgMedia[] = [];
|
||||||
|
let nextUrl: string | null =
|
||||||
|
`https://graph.facebook.com/v21.0/${BPS_IG_ID}/media?fields=id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,children{id,media_type,media_url}&limit=50&access_token=${META_TOKEN}`;
|
||||||
|
|
||||||
|
while (nextUrl) {
|
||||||
|
const res = await fetch(nextUrl);
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text();
|
||||||
|
throw new Error(`Graph API error: ${err}`);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
const items: IgMedia[] = data.data || [];
|
||||||
|
if (!items.length) break;
|
||||||
|
|
||||||
|
let reachedPast = false;
|
||||||
|
for (const it of items) {
|
||||||
|
const date = it.timestamp.slice(0, 10);
|
||||||
|
if (date >= '2026-01-01' && date <= '2026-09-30') {
|
||||||
|
allMedia.push(it);
|
||||||
|
} else if (date < '2026-01-01') {
|
||||||
|
reachedPast = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (reachedPast) break;
|
||||||
|
nextUrl = data.paging?.next || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Encontradas ${allMedia.length} publicaciones en Instagram entre 2026-01 y 2026-09.`);
|
||||||
|
|
||||||
|
// Ordenar cronológicamente (antiguo a nuevo)
|
||||||
|
allMedia.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
||||||
|
|
||||||
|
// 2. Subir a Supabase Storage y a public.creative_pipeline
|
||||||
|
console.log('2. Subiendo imágenes a Storage y registrando en public.creative_pipeline...');
|
||||||
|
let successCount = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < allMedia.length; i++) {
|
||||||
|
const it = allMedia[i];
|
||||||
|
const date = it.timestamp.slice(0, 10);
|
||||||
|
const month = date.slice(0, 7);
|
||||||
|
const isCarousel = it.media_type === 'CAROUSEL_ALBUM';
|
||||||
|
const isReel = it.media_type === 'VIDEO';
|
||||||
|
const format = isCarousel ? 'carrusel' : isReel ? 'reel' : 'post';
|
||||||
|
const title = cleanTitle(it.caption, date);
|
||||||
|
|
||||||
|
// Obtener URL de portada
|
||||||
|
const coverUrl = isReel
|
||||||
|
? (it.thumbnail_url || it.media_url)
|
||||||
|
: isCarousel
|
||||||
|
? (it.children?.data?.[0]?.media_url || it.media_url)
|
||||||
|
: it.media_url;
|
||||||
|
|
||||||
|
const extRef = `ig-${it.id}`;
|
||||||
|
|
||||||
|
// 1) Upsert fila base en creative_pipeline
|
||||||
|
const { data: row, error: upErr } = await admin
|
||||||
|
.from('creative_pipeline')
|
||||||
|
.upsert(
|
||||||
|
{
|
||||||
|
tenant_id: BPS_TENANT_ID,
|
||||||
|
external_ref: extRef,
|
||||||
|
title,
|
||||||
|
format,
|
||||||
|
month,
|
||||||
|
status: 'publicado',
|
||||||
|
caption: it.caption || null,
|
||||||
|
notes: it.permalink ? `Instagram: ${it.permalink}` : null,
|
||||||
|
created_at: it.timestamp,
|
||||||
|
},
|
||||||
|
{ onConflict: 'tenant_id,external_ref' }
|
||||||
|
)
|
||||||
|
.select('id')
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (upErr || !row) {
|
||||||
|
console.error(`[ERROR upsert] ${extRef}: ${upErr?.message}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const itemId = row.id as string;
|
||||||
|
|
||||||
|
// 2) Descargar y subir portada
|
||||||
|
let coverPath: string | null = null;
|
||||||
|
if (coverUrl) {
|
||||||
|
const coverBuf = await fetchBuffer(coverUrl);
|
||||||
|
if (coverBuf) {
|
||||||
|
coverPath = `${BPS_TENANT_ID}/pipeline/${itemId}/cover.jpg`;
|
||||||
|
const upRes = await admin.storage.from(BUCKET).upload(coverPath, coverBuf, {
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
upsert: true,
|
||||||
|
});
|
||||||
|
if (upRes.error) {
|
||||||
|
console.error(`[WARN cover upload] ${extRef}: ${upRes.error.message}`);
|
||||||
|
coverPath = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Si es carrusel, descargar y subir slides
|
||||||
|
const slidePaths: string[] = [];
|
||||||
|
if (isCarousel && it.children?.data) {
|
||||||
|
const slides = it.children.data.slice(1); // slides posteriores a la portada
|
||||||
|
for (let sIdx = 0; sIdx < slides.length; sIdx++) {
|
||||||
|
const slide = slides[sIdx];
|
||||||
|
if (!slide.media_url) continue;
|
||||||
|
const slideBuf = await fetchBuffer(slide.media_url);
|
||||||
|
if (slideBuf) {
|
||||||
|
const sPath = `${BPS_TENANT_ID}/pipeline/${itemId}/slide-${sIdx + 1}.jpg`;
|
||||||
|
const sUp = await admin.storage.from(BUCKET).upload(sPath, slideBuf, {
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
upsert: true,
|
||||||
|
});
|
||||||
|
if (!sUp.error) {
|
||||||
|
slidePaths.push(sPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Actualizar registro con las rutas de storage
|
||||||
|
await admin
|
||||||
|
.from('creative_pipeline')
|
||||||
|
.update({
|
||||||
|
cover_path: coverPath,
|
||||||
|
cover_mime: coverPath ? 'image/jpeg' : null,
|
||||||
|
slide_paths: slidePaths,
|
||||||
|
})
|
||||||
|
.eq('id', itemId);
|
||||||
|
|
||||||
|
successCount++;
|
||||||
|
console.log(`[${i + 1}/${allMedia.length}] ${month} [${format}] ${title} (${slidePaths.length} slides)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nCreatividades cargadas en creative_pipeline: ${successCount} de ${allMedia.length}.`);
|
||||||
|
|
||||||
|
// 3. Sincronizar en public.documents (type = 'calendar')
|
||||||
|
console.log('\n3. Sincronizando con public.documents para el Calendario...');
|
||||||
|
// Agrupar las publicaciones de Instagram por mes
|
||||||
|
const igByMonth = new Map<string, IgMedia[]>();
|
||||||
|
for (const m of allMedia) {
|
||||||
|
const ym = m.timestamp.slice(0, 7);
|
||||||
|
const list = igByMonth.get(ym) || [];
|
||||||
|
list.push(m);
|
||||||
|
igByMonth.set(ym, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: allDocs } = await admin
|
||||||
|
.from('documents')
|
||||||
|
.select('id, title, content')
|
||||||
|
.eq('tenant_id', BPS_TENANT_ID)
|
||||||
|
.eq('type', 'calendar');
|
||||||
|
|
||||||
|
const docsList = allDocs || [];
|
||||||
|
|
||||||
|
for (const [ym, igList] of igByMonth.entries()) {
|
||||||
|
// Buscar documento del mes
|
||||||
|
const doc = docsList.find((d) => {
|
||||||
|
try {
|
||||||
|
const c = typeof d.content === 'string' ? JSON.parse(d.content) : d.content;
|
||||||
|
return c?.month === ym;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (doc) {
|
||||||
|
let content = typeof doc.content === 'string' ? JSON.parse(doc.content) : doc.content;
|
||||||
|
const pieces: any[] = content?.pieces || [];
|
||||||
|
|
||||||
|
// Mapear posts de IG en piezas de calendario si no están
|
||||||
|
for (const ig of igList) {
|
||||||
|
const date = ig.timestamp.slice(0, 10);
|
||||||
|
const title = cleanTitle(ig.caption, date);
|
||||||
|
const isReel = ig.media_type === 'VIDEO';
|
||||||
|
const isCarousel = ig.media_type === 'CAROUSEL_ALBUM';
|
||||||
|
const format = isCarousel ? 'Carrusel' : isReel ? 'Reel' : 'Post';
|
||||||
|
|
||||||
|
// Buscar si ya existe una pieza en esa fecha
|
||||||
|
const match = pieces.find(
|
||||||
|
(p) => p.date === date && (p.title?.toLowerCase().includes(title.slice(0, 15).toLowerCase()) || p.platform === 'Instagram')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
match.status = 'published';
|
||||||
|
match.meta_line = `Instagram: ${ig.permalink || ''}`;
|
||||||
|
if (!match.body && ig.caption) match.body = ig.caption;
|
||||||
|
} else {
|
||||||
|
// Agregar nueva pieza publicada
|
||||||
|
pieces.push({
|
||||||
|
id: `bps-${date}-ig-${ig.id}`,
|
||||||
|
date,
|
||||||
|
platform: 'Instagram',
|
||||||
|
format,
|
||||||
|
pillar: isReel ? 'reels' : isCarousel ? 'carrusel' : 'feed',
|
||||||
|
title,
|
||||||
|
body: ig.caption || undefined,
|
||||||
|
status: 'published',
|
||||||
|
kind: isReel ? 'reel' : 'post',
|
||||||
|
color: isReel ? '#7C66B8' : isCarousel ? '#2071cf' : '#0F9D8F',
|
||||||
|
meta_line: `Instagram: ${ig.permalink || ''}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordenar piezas por fecha
|
||||||
|
pieces.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
content.pieces = pieces;
|
||||||
|
|
||||||
|
await admin
|
||||||
|
.from('documents')
|
||||||
|
.update({ content })
|
||||||
|
.eq('id', doc.id);
|
||||||
|
|
||||||
|
console.log(`Mes ${ym} actualizado en Calendario: ${pieces.length} piezas.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nSincronización completada con éxito.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -65,7 +65,7 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
let q = `${supabaseUrl()}/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent(
|
let q = `${supabaseUrl()}/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent(
|
||||||
ctx.tenantId
|
ctx.tenantId
|
||||||
)}&select=${SELECT}&order=month.asc,created_at.asc`;
|
)}&select=${SELECT}&order=month.asc,created_at.asc&limit=1000`;
|
||||||
if (month) q += `&month=eq.${encodeURIComponent(month)}`;
|
if (month) q += `&month=eq.${encodeURIComponent(month)}`;
|
||||||
|
|
||||||
if (ctx.viaView) {
|
if (ctx.viaView) {
|
||||||
@@ -87,7 +87,8 @@ export async function GET(req: NextRequest) {
|
|||||||
.from('creative_pipeline')
|
.from('creative_pipeline')
|
||||||
.select(SELECT)
|
.select(SELECT)
|
||||||
.order('month', { ascending: true })
|
.order('month', { ascending: true })
|
||||||
.order('created_at', { ascending: true });
|
.order('created_at', { ascending: true })
|
||||||
|
.limit(1000);
|
||||||
if (month) query = query.eq('month', month);
|
if (month) query = query.eq('month', month);
|
||||||
|
|
||||||
const { data, error } = await query;
|
const { data, error } = await query;
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { ImagePlus, Plus, Trash2, Upload } from 'lucide-react';
|
import {
|
||||||
|
ImagePlus,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
|
Instagram,
|
||||||
|
ExternalLink,
|
||||||
|
X,
|
||||||
|
Eye,
|
||||||
|
Layers,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Filter,
|
||||||
|
} from 'lucide-react';
|
||||||
import { PageHeader, Surface } from '@/components/ui';
|
import { PageHeader, Surface } from '@/components/ui';
|
||||||
|
|
||||||
type PipelineItem = {
|
type PipelineItem = {
|
||||||
@@ -31,12 +44,19 @@ const STATUS_LABEL: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_COLOR: Record<string, string> = {
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
propuesta: 'bg-gray-100 text-gray-700',
|
propuesta: 'bg-gray-100 text-gray-700 border-gray-200',
|
||||||
en_diseno: 'bg-blue-100 text-blue-700',
|
en_diseno: 'bg-blue-100 text-blue-700 border-blue-200',
|
||||||
revision_interna: 'bg-amber-100 text-amber-800',
|
revision_interna: 'bg-amber-100 text-amber-800 border-amber-200',
|
||||||
aprobado: 'bg-emerald-100 text-emerald-700',
|
aprobado: 'bg-emerald-100 text-emerald-700 border-emerald-200',
|
||||||
rechazado: 'bg-red-100 text-red-700',
|
rechazado: 'bg-red-100 text-red-700 border-red-200',
|
||||||
publicado: 'bg-purple-100 text-purple-700',
|
publicado: 'bg-purple-100 text-purple-700 border-purple-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
const FORMAT_CONFIG: Record<string, { label: string; badgeCls: string }> = {
|
||||||
|
post: { label: 'Post', badgeCls: 'bg-blue-50 text-blue-700 border-blue-200' },
|
||||||
|
carrusel: { label: 'Carrusel', badgeCls: 'bg-indigo-50 text-indigo-700 border-indigo-200' },
|
||||||
|
reel: { label: 'Reel', badgeCls: 'bg-pink-50 text-pink-700 border-pink-200' },
|
||||||
|
story: { label: 'Story', badgeCls: 'bg-amber-50 text-amber-700 border-amber-200' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const AGENCY_STATUS_ORDER = [
|
const AGENCY_STATUS_ORDER = [
|
||||||
@@ -60,6 +80,20 @@ function monthLabel(m: string | null) {
|
|||||||
return d.toLocaleDateString('es-ES', { month: 'long', year: 'numeric' });
|
return d.toLocaleDateString('es-ES', { month: 'long', year: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function monthShortLabel(m: string | null) {
|
||||||
|
if (!m) return 'Sin mes';
|
||||||
|
const [y, mm] = m.split('-');
|
||||||
|
const d = new Date(Number(y), Number(mm) - 1, 1);
|
||||||
|
const name = d.toLocaleDateString('es-ES', { month: 'short' });
|
||||||
|
return `${name} ${y.slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInstagramUrl(notes: string | null): string | null {
|
||||||
|
if (!notes) return null;
|
||||||
|
const match = notes.match(/https?:\/\/(?:www\.)?instagram\.com\S+/i);
|
||||||
|
return match ? match[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function CreativePipelineBoard() {
|
export default function CreativePipelineBoard() {
|
||||||
const [items, setItems] = useState<PipelineItem[]>([]);
|
const [items, setItems] = useState<PipelineItem[]>([]);
|
||||||
const [isAgency, setIsAgency] = useState(false);
|
const [isAgency, setIsAgency] = useState(false);
|
||||||
@@ -71,6 +105,9 @@ export default function CreativePipelineBoard() {
|
|||||||
const [newTitle, setNewTitle] = useState('');
|
const [newTitle, setNewTitle] = useState('');
|
||||||
const [newFormat, setNewFormat] = useState<PipelineItem['format']>('post');
|
const [newFormat, setNewFormat] = useState<PipelineItem['format']>('post');
|
||||||
const [newMonth, setNewMonth] = useState('');
|
const [newMonth, setNewMonth] = useState('');
|
||||||
|
const [selectedMonth, setSelectedMonth] = useState<string>('all');
|
||||||
|
const [selectedFormat, setSelectedFormat] = useState<string>('all');
|
||||||
|
const [previewItem, setPreviewItem] = useState<PipelineItem | null>(null);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -89,15 +126,40 @@ export default function CreativePipelineBoard() {
|
|||||||
void load();
|
void load();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const monthCounts = useMemo(() => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (const it of items) {
|
||||||
|
const key = it.month || 'sin-mes';
|
||||||
|
map.set(key, (map.get(key) || 0) + 1);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
const months = useMemo(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const it of items) {
|
||||||
|
if (it.month) set.add(it.month);
|
||||||
|
}
|
||||||
|
return Array.from(set).sort();
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
return items.filter((it) => {
|
||||||
|
if (selectedMonth !== 'all' && (it.month || 'sin-mes') !== selectedMonth) return false;
|
||||||
|
if (selectedFormat !== 'all' && it.format !== selectedFormat) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [items, selectedMonth, selectedFormat]);
|
||||||
|
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
const map = new Map<string, PipelineItem[]>();
|
const map = new Map<string, PipelineItem[]>();
|
||||||
for (const it of items) {
|
for (const it of filteredItems) {
|
||||||
const key = it.month || 'sin-mes';
|
const key = it.month || 'sin-mes';
|
||||||
if (!map.has(key)) map.set(key, []);
|
if (!map.has(key)) map.set(key, []);
|
||||||
map.get(key)!.push(it);
|
map.get(key)!.push(it);
|
||||||
}
|
}
|
||||||
return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b));
|
return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b));
|
||||||
}, [items]);
|
}, [filteredItems]);
|
||||||
|
|
||||||
async function createItem(e: React.FormEvent) {
|
async function createItem(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -133,6 +195,9 @@ export default function CreativePipelineBoard() {
|
|||||||
setErr(data.error || 'Error al cambiar estado');
|
setErr(data.error || 'Error al cambiar estado');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (previewItem && previewItem.id === id) {
|
||||||
|
setPreviewItem({ ...previewItem, status });
|
||||||
|
}
|
||||||
await load();
|
await load();
|
||||||
} finally {
|
} finally {
|
||||||
setBusyId(null);
|
setBusyId(null);
|
||||||
@@ -143,6 +208,7 @@ export default function CreativePipelineBoard() {
|
|||||||
setBusyId(id);
|
setBusyId(id);
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/creative-pipeline?id=${encodeURIComponent(id)}`, { method: 'DELETE' });
|
await fetch(`/api/creative-pipeline?id=${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||||
|
if (previewItem?.id === id) setPreviewItem(null);
|
||||||
await load();
|
await load();
|
||||||
} finally {
|
} finally {
|
||||||
setBusyId(null);
|
setBusyId(null);
|
||||||
@@ -180,7 +246,7 @@ export default function CreativePipelineBoard() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow="Producción editorial"
|
eyebrow="Producción editorial"
|
||||||
title="Panel de Creatividades"
|
title="Panel de Creatividades"
|
||||||
description="Propuesta → Diseño → Revisión interna → Aprobación del cliente."
|
description="Planificación, diseño y archivo histórico sincronizado con Instagram."
|
||||||
actions={
|
actions={
|
||||||
isAgency ? (
|
isAgency ? (
|
||||||
<button
|
<button
|
||||||
@@ -204,6 +270,77 @@ export default function CreativePipelineBoard() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* Barra de Filtros por Mes y Formato */}
|
||||||
|
<div className="mb-6 space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||||
|
Meses ({items.length} piezas):
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||||
|
<Filter className="h-3.5 w-3.5" />
|
||||||
|
Mostrando <strong>{filteredItems.length}</strong> de {items.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meses pills */}
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedMonth('all')}
|
||||||
|
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${
|
||||||
|
selectedMonth === 'all'
|
||||||
|
? 'bg-gray-900 text-white shadow-sm'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Todos ({items.length})
|
||||||
|
</button>
|
||||||
|
{months.map((m) => {
|
||||||
|
const count = monthCounts.get(m) || 0;
|
||||||
|
const active = selectedMonth === m;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
onClick={() => setSelectedMonth(m)}
|
||||||
|
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${
|
||||||
|
active
|
||||||
|
? 'bg-brand-600 text-white shadow-sm'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{monthShortLabel(m)} ({count})
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Formatos pills */}
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||||
|
<span className="text-xs text-gray-400 mr-1">Formato:</span>
|
||||||
|
{['all', 'carrusel', 'reel', 'post', 'story'].map((f) => {
|
||||||
|
const active = selectedFormat === f;
|
||||||
|
const label =
|
||||||
|
f === 'all'
|
||||||
|
? 'Todos los formatos'
|
||||||
|
: FORMAT_CONFIG[f]?.label || f;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
onClick={() => setSelectedFormat(f)}
|
||||||
|
className={`rounded-md px-2.5 py-0.5 text-xs font-medium transition-colors ${
|
||||||
|
active
|
||||||
|
? 'bg-gray-800 text-white'
|
||||||
|
: 'bg-white border border-gray-200 text-gray-600 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{showForm && isAgency ? (
|
{showForm && isAgency ? (
|
||||||
<Surface className="mb-6">
|
<Surface className="mb-6">
|
||||||
<form onSubmit={createItem} className="flex flex-wrap items-end gap-3">
|
<form onSubmit={createItem} className="flex flex-wrap items-end gap-3">
|
||||||
@@ -240,7 +377,7 @@ export default function CreativePipelineBoard() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white"
|
className="rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800"
|
||||||
>
|
>
|
||||||
Crear
|
Crear
|
||||||
</button>
|
</button>
|
||||||
@@ -249,18 +386,29 @@ export default function CreativePipelineBoard() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-sm text-gray-500">Cargando…</p>
|
<p className="text-sm text-gray-500">Cargando creatividades…</p>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<Surface>
|
<Surface>
|
||||||
<p className="text-sm text-gray-500">Todavía no hay piezas en el pipeline.</p>
|
<p className="text-sm text-gray-500">Todavía no hay piezas en el pipeline.</p>
|
||||||
</Surface>
|
</Surface>
|
||||||
|
) : filteredItems.length === 0 ? (
|
||||||
|
<Surface>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
No hay piezas con los filtros seleccionados (Mes: {selectedMonth}, Formato: {selectedFormat}).
|
||||||
|
</p>
|
||||||
|
</Surface>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{grouped.map(([month, monthItems]) => (
|
{grouped.map(([month, monthItems]) => (
|
||||||
<div key={month}>
|
<div key={month}>
|
||||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500">
|
<div className="mb-3 flex items-center justify-between border-b border-gray-200 pb-2">
|
||||||
|
<h2 className="text-sm font-bold uppercase tracking-wider text-gray-700">
|
||||||
{monthLabel(month === 'sin-mes' ? null : month)}
|
{monthLabel(month === 'sin-mes' ? null : month)}
|
||||||
</h2>
|
</h2>
|
||||||
|
<span className="text-xs text-gray-500 font-medium">
|
||||||
|
{monthItems.length} {monthItems.length === 1 ? 'pieza' : 'piezas'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
{monthItems.map((it) => (
|
{monthItems.map((it) => (
|
||||||
<PieceCard
|
<PieceCard
|
||||||
@@ -271,6 +419,7 @@ export default function CreativePipelineBoard() {
|
|||||||
onStatus={(s) => setStatus(it.id, s)}
|
onStatus={(s) => setStatus(it.id, s)}
|
||||||
onDelete={() => removeItem(it.id)}
|
onDelete={() => removeItem(it.id)}
|
||||||
onUpload={(file, slot, index) => uploadImage(it.id, file, slot, index)}
|
onUpload={(file, slot, index) => uploadImage(it.id, file, slot, index)}
|
||||||
|
onPreview={() => setPreviewItem(it)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -278,6 +427,17 @@ export default function CreativePipelineBoard() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Modal de Detalle / Preview */}
|
||||||
|
{previewItem && (
|
||||||
|
<DetailModal
|
||||||
|
item={previewItem}
|
||||||
|
isAgency={isAgency}
|
||||||
|
busy={busyId === previewItem.id}
|
||||||
|
onClose={() => setPreviewItem(null)}
|
||||||
|
onStatus={(s) => setStatus(previewItem.id, s)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -289,6 +449,7 @@ function PieceCard({
|
|||||||
onStatus,
|
onStatus,
|
||||||
onDelete,
|
onDelete,
|
||||||
onUpload,
|
onUpload,
|
||||||
|
onPreview,
|
||||||
}: {
|
}: {
|
||||||
item: PipelineItem;
|
item: PipelineItem;
|
||||||
isAgency: boolean;
|
isAgency: boolean;
|
||||||
@@ -296,26 +457,70 @@ function PieceCard({
|
|||||||
onStatus: (status: string) => void;
|
onStatus: (status: string) => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onUpload: (file: File, slot: 'cover' | 'slide', index: number) => void;
|
onUpload: (file: File, slot: 'cover' | 'slide', index: number) => void;
|
||||||
|
onPreview: () => void;
|
||||||
}) {
|
}) {
|
||||||
const cover = mediaUrl(item.cover_path);
|
const cover = mediaUrl(item.cover_path);
|
||||||
const slides = item.slide_paths || [];
|
const slides = item.slide_paths || [];
|
||||||
const isCarrusel = item.format === 'carrusel';
|
const isCarrusel = item.format === 'carrusel';
|
||||||
|
const formatInfo = FORMAT_CONFIG[item.format] || {
|
||||||
|
label: item.format,
|
||||||
|
badgeCls: 'bg-gray-100 text-gray-700 border-gray-200',
|
||||||
|
};
|
||||||
|
const igUrl = getInstagramUrl(item.notes);
|
||||||
|
|
||||||
const clientCanDecide = !isAgency && item.status === 'revision_interna';
|
const clientCanDecide = !isAgency && item.status === 'revision_interna';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Surface padded={false} className="overflow-hidden flex flex-col">
|
<Surface padded={false} className="overflow-hidden flex flex-col group transition-shadow hover:shadow-md">
|
||||||
<div className="relative aspect-[4/5] bg-gray-100">
|
{/* Portada / Imagen */}
|
||||||
|
<div className="relative aspect-[4/5] bg-gray-100 overflow-hidden cursor-pointer" onClick={onPreview}>
|
||||||
{cover ? (
|
{cover ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img src={cover} alt={item.title} className="h-full w-full object-cover" />
|
<img
|
||||||
|
src={cover}
|
||||||
|
alt={item.title}
|
||||||
|
className="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center text-gray-300">
|
<div className="flex h-full items-center justify-center text-gray-300">
|
||||||
<ImagePlus className="h-10 w-10" />
|
<ImagePlus className="h-10 w-10" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Badges superiores */}
|
||||||
|
<div className="absolute top-2 left-2 flex items-center gap-1.5 pointer-events-none">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider border shadow-sm ${formatInfo.badgeCls}`}
|
||||||
|
>
|
||||||
|
{isCarrusel && <Layers className="h-2.5 w-2.5" />}
|
||||||
|
{formatInfo.label}
|
||||||
|
</span>
|
||||||
|
{isCarrusel && slides.length > 0 && (
|
||||||
|
<span className="rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white shadow-sm">
|
||||||
|
{slides.length} slides
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Botón de vista rápida */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onPreview();
|
||||||
|
}}
|
||||||
|
className="absolute top-2 right-2 rounded-full bg-black/50 p-1.5 text-white hover:bg-black/75 transition-opacity"
|
||||||
|
title="Ver detalle"
|
||||||
|
>
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Botón subir imagen agencia */}
|
||||||
{isAgency ? (
|
{isAgency ? (
|
||||||
<label className="absolute bottom-2 right-2 cursor-pointer rounded-full bg-black/60 p-2 text-white hover:bg-black/80">
|
<label
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="absolute bottom-2 right-2 cursor-pointer rounded-full bg-black/60 p-2 text-white hover:bg-black/80"
|
||||||
|
>
|
||||||
<Upload className="h-3.5 w-3.5" />
|
<Upload className="h-3.5 w-3.5" />
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
@@ -330,21 +535,26 @@ function PieceCard({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tira de slides para carruseles */}
|
||||||
{isCarrusel ? (
|
{isCarrusel ? (
|
||||||
<div className="flex gap-1 overflow-x-auto p-2 bg-gray-50">
|
<div className="flex gap-1 overflow-x-auto p-2 bg-gray-50 border-t border-gray-100">
|
||||||
{Array.from({ length: 4 }).map((_, i) => {
|
{Array.from({ length: Math.min(Math.max(slides.length, 1), 4) }).map((_, i) => {
|
||||||
const url = mediaUrl(slides[i] || null);
|
const url = mediaUrl(slides[i] || null);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="relative h-14 w-11 shrink-0 overflow-hidden rounded bg-gray-200"
|
onClick={onPreview}
|
||||||
|
className="relative h-14 w-11 shrink-0 overflow-hidden rounded bg-gray-200 cursor-pointer border border-gray-200"
|
||||||
>
|
>
|
||||||
{url ? (
|
{url ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img src={url} alt={`slide ${i + 1}`} className="h-full w-full object-cover" />
|
<img src={url} alt={`slide ${i + 1}`} className="h-full w-full object-cover" />
|
||||||
) : null}
|
) : null}
|
||||||
{isAgency ? (
|
{isAgency ? (
|
||||||
<label className="absolute inset-0 flex cursor-pointer items-center justify-center bg-black/0 hover:bg-black/40 text-transparent hover:text-white text-[10px]">
|
<label
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="absolute inset-0 flex cursor-pointer items-center justify-center bg-black/0 hover:bg-black/40 text-transparent hover:text-white text-[10px]"
|
||||||
|
>
|
||||||
+
|
+
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
@@ -363,34 +573,57 @@ function PieceCard({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* Contenido de la tarjeta */}
|
||||||
<div className="flex flex-1 flex-col gap-2 p-3">
|
<div className="flex flex-1 flex-col gap-2 p-3">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<p className="text-sm font-medium text-gray-900 line-clamp-2">{item.title}</p>
|
<p
|
||||||
|
onClick={onPreview}
|
||||||
|
className="text-sm font-semibold text-gray-900 line-clamp-2 cursor-pointer hover:text-brand-600"
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</p>
|
||||||
{isAgency ? (
|
{isAgency ? (
|
||||||
<button
|
<button
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="shrink-0 text-gray-400 hover:text-red-500"
|
className="shrink-0 text-gray-400 hover:text-red-500"
|
||||||
|
title="Eliminar"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
<span
|
<span
|
||||||
className={`inline-flex w-fit rounded-full px-2 py-0.5 text-xs font-medium ${
|
className={`inline-flex w-fit rounded-full px-2 py-0.5 text-xs font-semibold border ${
|
||||||
STATUS_COLOR[item.status] || 'bg-gray-100 text-gray-700'
|
STATUS_COLOR[item.status] || 'bg-gray-100 text-gray-700 border-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{STATUS_LABEL[item.status] || item.status}
|
{STATUS_LABEL[item.status] || item.status}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{igUrl && (
|
||||||
|
<a
|
||||||
|
href={igUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 rounded-full bg-gradient-to-r from-pink-500 via-red-500 to-yellow-500 px-2 py-0.5 text-[10px] font-bold text-white shadow-sm hover:opacity-90"
|
||||||
|
title="Abrir publicación original en Instagram"
|
||||||
|
>
|
||||||
|
<Instagram className="h-2.5 w-2.5" /> IG
|
||||||
|
<ExternalLink className="h-2 w-2" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-auto pt-2">
|
<div className="mt-auto pt-2">
|
||||||
{isAgency ? (
|
{isAgency ? (
|
||||||
<select
|
<select
|
||||||
value={item.status}
|
value={item.status}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onChange={(e) => onStatus(e.target.value)}
|
onChange={(e) => onStatus(e.target.value)}
|
||||||
className="w-full rounded-lg border border-gray-200 px-2 py-1.5 text-xs"
|
className="w-full rounded-lg border border-gray-200 px-2 py-1.5 text-xs font-medium"
|
||||||
>
|
>
|
||||||
{AGENCY_STATUS_ORDER.map((s) => (
|
{AGENCY_STATUS_ORDER.map((s) => (
|
||||||
<option key={s} value={s}>
|
<option key={s} value={s}>
|
||||||
@@ -419,6 +652,8 @@ function PieceCard({
|
|||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-gray-400">
|
||||||
{item.status === 'aprobado'
|
{item.status === 'aprobado'
|
||||||
? 'Aprobada por ti'
|
? 'Aprobada por ti'
|
||||||
|
: item.status === 'publicado'
|
||||||
|
? 'Publicado en redes'
|
||||||
: 'Pendiente del equipo de 4Ventures'}
|
: 'Pendiente del equipo de 4Ventures'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -427,3 +662,215 @@ function PieceCard({
|
|||||||
</Surface>
|
</Surface>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DetailModal({
|
||||||
|
item,
|
||||||
|
isAgency,
|
||||||
|
busy,
|
||||||
|
onClose,
|
||||||
|
onStatus,
|
||||||
|
}: {
|
||||||
|
item: PipelineItem;
|
||||||
|
isAgency: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onStatus: (status: string) => void;
|
||||||
|
}) {
|
||||||
|
const slides = item.slide_paths || [];
|
||||||
|
const allImages = [item.cover_path, ...slides].filter(Boolean) as string[];
|
||||||
|
const [activeIdx, setActiveIdx] = useState(0);
|
||||||
|
|
||||||
|
const activePath = allImages[activeIdx] || item.cover_path;
|
||||||
|
const currentUrl = mediaUrl(activePath);
|
||||||
|
const igUrl = getInstagramUrl(item.notes);
|
||||||
|
const formatInfo = FORMAT_CONFIG[item.format] || {
|
||||||
|
label: item.format,
|
||||||
|
badgeCls: 'bg-gray-100 text-gray-700 border-gray-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
const clientCanDecide = !isAgency && item.status === 'revision_interna';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-4 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex max-h-[92vh] w-full max-w-4xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl md:flex-row"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Lado izquierdo: visor de imagen / slider */}
|
||||||
|
<div className="flex flex-1 flex-col bg-gray-900 p-4">
|
||||||
|
<div className="relative flex flex-1 items-center justify-center overflow-hidden rounded-lg bg-black/40 min-h-[300px]">
|
||||||
|
{currentUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={currentUrl}
|
||||||
|
alt={item.title}
|
||||||
|
className="max-h-[60vh] max-w-full object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-2 text-gray-500">
|
||||||
|
<ImagePlus className="h-12 w-12" />
|
||||||
|
<span className="text-xs">Sin imagen cargada</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{allImages.length > 1 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveIdx((prev) => (prev > 0 ? prev - 1 : allImages.length - 1))}
|
||||||
|
className="absolute left-2 top-1/2 -translate-y-1/2 rounded-full bg-black/60 p-1.5 text-white hover:bg-black/90"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveIdx((prev) => (prev < allImages.length - 1 ? prev + 1 : 0))}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full bg-black/60 p-1.5 text-white hover:bg-black/90"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-black/60 px-2 py-0.5 text-xs text-white">
|
||||||
|
{activeIdx + 1} / {allImages.length}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Miniaturas de slides */}
|
||||||
|
{allImages.length > 1 && (
|
||||||
|
<div className="mt-3 flex gap-2 overflow-x-auto py-1">
|
||||||
|
{allImages.map((path, idx) => {
|
||||||
|
const url = mediaUrl(path);
|
||||||
|
const isSelected = idx === activeIdx;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveIdx(idx)}
|
||||||
|
className={`relative h-16 w-12 shrink-0 overflow-hidden rounded border-2 transition-all ${
|
||||||
|
isSelected ? 'border-brand-500 scale-105' : 'border-transparent opacity-60 hover:opacity-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{url && (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={url} alt={`Slide ${idx + 1}`} className="h-full w-full object-cover" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lado derecho: información, caption y acciones */}
|
||||||
|
<div className="flex w-full flex-col justify-between p-6 md:w-96 border-t md:border-t-0 md:border-l border-gray-100 overflow-y-auto">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-bold uppercase tracking-wider border ${formatInfo.badgeCls}`}
|
||||||
|
>
|
||||||
|
{formatInfo.label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`inline-flex rounded-full px-2 py-0.5 text-xs font-semibold border ${
|
||||||
|
STATUS_COLOR[item.status] || 'bg-gray-100 text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{STATUS_LABEL[item.status] || item.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-full p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-bold text-gray-900 leading-snug">{item.title}</h3>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
{monthLabel(item.month)} {item.external_ref ? `· Ref: ${item.external_ref}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{igUrl && (
|
||||||
|
<a
|
||||||
|
href={igUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-2 rounded-xl bg-gradient-to-r from-purple-600 via-pink-600 to-amber-500 px-3 py-2 text-xs font-bold text-white shadow-sm hover:opacity-95"
|
||||||
|
>
|
||||||
|
<Instagram className="h-4 w-4" />
|
||||||
|
<span>Ver publicación original en Instagram</span>
|
||||||
|
<ExternalLink className="h-3 w-3 ml-auto" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.caption && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs font-semibold text-gray-700 uppercase tracking-wider">Texto / Copy</p>
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-xs text-gray-800 whitespace-pre-wrap leading-relaxed max-h-48 overflow-y-auto">
|
||||||
|
{item.caption}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 border-t border-gray-100 pt-4">
|
||||||
|
{isAgency ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-xs font-semibold text-gray-600">Cambiar estado (Agencia):</label>
|
||||||
|
<select
|
||||||
|
value={item.status}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => onStatus(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-xs font-medium"
|
||||||
|
>
|
||||||
|
{AGENCY_STATUS_ORDER.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{STATUS_LABEL[s]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : clientCanDecide ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold text-gray-600">Tu decisión como cliente:</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onStatus('aprobado')}
|
||||||
|
className="flex-1 rounded-lg bg-emerald-600 px-3 py-2 text-xs font-bold text-white hover:bg-emerald-700"
|
||||||
|
>
|
||||||
|
Aprobar pieza
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onStatus('rechazado')}
|
||||||
|
className="flex-1 rounded-lg bg-red-600 px-3 py-2 text-xs font-bold text-white hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Solicitar cambios
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-center text-xs text-gray-500">
|
||||||
|
{item.status === 'aprobado'
|
||||||
|
? '✓ Esta pieza fue aprobada por el cliente.'
|
||||||
|
: item.status === 'publicado'
|
||||||
|
? '✓ Esta pieza está publicada y archivada.'
|
||||||
|
: 'En proceso con el equipo de 4Ventures.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
X,
|
X,
|
||||||
|
ExternalLink,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
export type RrssPiece = {
|
export type RrssPiece = {
|
||||||
@@ -544,6 +545,17 @@ export default function RrssMonthView({
|
|||||||
<span className="text-[11px] text-gray-500 font-mono">
|
<span className="text-[11px] text-gray-500 font-mono">
|
||||||
{p.date} · {p.format}
|
{p.date} · {p.format}
|
||||||
</span>
|
</span>
|
||||||
|
{p.meta_line?.includes('http') && (
|
||||||
|
<a
|
||||||
|
href={p.meta_line.replace(/^Instagram:\s*/i, '').trim()}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="inline-flex items-center gap-1 rounded bg-pink-50 border border-pink-200 px-1.5 py-0.5 text-[10px] font-semibold text-pink-700 hover:bg-pink-100"
|
||||||
|
>
|
||||||
|
<Instagram className="w-2.5 h-2.5" /> Ver en IG
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
<DualBadge dual={dualMap[p.id]} />
|
<DualBadge dual={dualMap[p.id]} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-bold text-gray-900">{p.title}</p>
|
<p className="text-sm font-bold text-gray-900">{p.title}</p>
|
||||||
@@ -578,7 +590,18 @@ export default function RrssMonthView({
|
|||||||
<div className="p-5 overflow-y-auto space-y-3 text-sm">
|
<div className="p-5 overflow-y-auto space-y-3 text-sm">
|
||||||
<DualBadge dual={dualMap[selected.id]} />
|
<DualBadge dual={dualMap[selected.id]} />
|
||||||
{selected.meta_line && (
|
{selected.meta_line && (
|
||||||
|
selected.meta_line.includes('http') ? (
|
||||||
|
<a
|
||||||
|
href={selected.meta_line.replace(/^Instagram:\s*/i, '').trim()}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg bg-gradient-to-r from-purple-600 via-pink-600 to-amber-500 px-3 py-1.5 text-xs font-bold text-white shadow-sm hover:opacity-95"
|
||||||
|
>
|
||||||
|
<Instagram className="w-3.5 h-3.5" /> Abrir en Instagram <ExternalLink className="w-3 h-3 ml-auto" />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
<p className="text-xs text-gray-400">{selected.meta_line}</p>
|
<p className="text-xs text-gray-400">{selected.meta_line}</p>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-amber-200 text-sm">
|
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-amber-200 text-sm">
|
||||||
{selected.hook}
|
{selected.hook}
|
||||||
|
|||||||
Reference in New Issue
Block a user