feat: upload creativos, PMax formats, portada IG/FB y seguimiento con notas
Permite subir media sin URL; previews Google PMax por ratio; calendario anual estilo Miia con filtros IG/FB y LinkedIn; seguimiento con selector de fechas, KPIs y cuadro de notas. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
92
scripts/dokploy_apply_creatives_upload_pmax.py
Normal file
92
scripts/dokploy_apply_creatives_upload_pmax.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Apply sql/creatives_upload_pmax.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" / "creatives_upload_pmax.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")
|
||||||
|
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/cc.b64 /tmp/cc.sql; echo READY", 1200)
|
||||||
|
for i, ch in enumerate(chunks):
|
||||||
|
op = ">" if i == 0 else ">>"
|
||||||
|
run(f"printf '%s' '{ch}' {op} /tmp/cc.b64", 400)
|
||||||
|
run(
|
||||||
|
"base64 -d /tmp/cc.b64 > /tmp/cc.sql && "
|
||||||
|
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/cc.sql; echo EXIT:$?",
|
||||||
|
14000,
|
||||||
|
)
|
||||||
|
text = page.evaluate(
|
||||||
|
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
|
||||||
|
)
|
||||||
|
browser.close()
|
||||||
|
ok = "EXIT:0" in text
|
||||||
|
print("SQL_OK", ok, flush=True)
|
||||||
|
print(text[-600:], flush=True)
|
||||||
|
return 0 if ok else 6
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
35
sql/creatives_upload_pmax.sql
Normal file
35
sql/creatives_upload_pmax.sql
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
-- Expand creatives.format for Google Performance Max asset ratios + upload fields
|
||||||
|
-- Idempotent.
|
||||||
|
|
||||||
|
alter table public.creatives drop constraint if exists creatives_format_check;
|
||||||
|
|
||||||
|
alter table public.creatives
|
||||||
|
add constraint creatives_format_check
|
||||||
|
check (
|
||||||
|
format is null
|
||||||
|
or format in (
|
||||||
|
'image',
|
||||||
|
'video',
|
||||||
|
'carousel',
|
||||||
|
'reel',
|
||||||
|
'story',
|
||||||
|
'rsa',
|
||||||
|
'pmax',
|
||||||
|
'pmax_landscape',
|
||||||
|
'pmax_square',
|
||||||
|
'pmax_portrait',
|
||||||
|
'pmax_vertical',
|
||||||
|
'pmax_logo',
|
||||||
|
'youtube',
|
||||||
|
'other'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.creatives
|
||||||
|
add column if not exists storage_path text;
|
||||||
|
|
||||||
|
alter table public.creatives
|
||||||
|
add column if not exists mime_type text;
|
||||||
|
|
||||||
|
comment on column public.creatives.storage_path is
|
||||||
|
'Path in tenant-assets bucket for uploaded creative media';
|
||||||
87
src/app/api/creatives/media/route.ts
Normal file
87
src/app/api/creatives/media/route.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import {
|
||||||
|
isAgencyRole,
|
||||||
|
serviceHeaders,
|
||||||
|
supabaseUrl,
|
||||||
|
userRole,
|
||||||
|
} from '@/lib/agency';
|
||||||
|
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||||
|
import { ASSETS_BUCKET, serviceSupabase } from '@/lib/assets';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/** GET ?id= — redirect to signed URL for creative media */
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const id = req.nextUrl.searchParams.get('id')?.trim() || '';
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: 'id requerido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = await resolveActiveTenant(user);
|
||||||
|
if (!ctx.tenantId || ctx.portfolioWide) {
|
||||||
|
return NextResponse.json({ error: 'Sin tenant' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const agency = isAgencyRole(userRole(user));
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
let storagePath: string | null = null;
|
||||||
|
|
||||||
|
if (agency && headers) {
|
||||||
|
const r = await fetch(
|
||||||
|
`${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(id)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}&select=storage_path,media_url`,
|
||||||
|
{ headers, cache: 'no-store' }
|
||||||
|
);
|
||||||
|
if (!r.ok) {
|
||||||
|
return NextResponse.json({ error: await r.text() }, { status: 502 });
|
||||||
|
}
|
||||||
|
const rows = await r.json();
|
||||||
|
storagePath = rows?.[0]?.storage_path || null;
|
||||||
|
const mediaUrl = rows?.[0]?.media_url as string | undefined;
|
||||||
|
if (!storagePath && mediaUrl && !mediaUrl.startsWith('/api/creatives/media')) {
|
||||||
|
return NextResponse.redirect(mediaUrl);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('creatives')
|
||||||
|
.select('storage_path,media_url')
|
||||||
|
.eq('id', id)
|
||||||
|
.maybeSingle();
|
||||||
|
storagePath = data?.storage_path || null;
|
||||||
|
if (
|
||||||
|
!storagePath &&
|
||||||
|
data?.media_url &&
|
||||||
|
!String(data.media_url).startsWith('/api/creatives/media')
|
||||||
|
) {
|
||||||
|
return NextResponse.redirect(data.media_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!storagePath) {
|
||||||
|
return NextResponse.json({ error: 'Sin media' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const svc = serviceSupabase();
|
||||||
|
if (!svc) {
|
||||||
|
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const signed = await svc.storage
|
||||||
|
.from(ASSETS_BUCKET)
|
||||||
|
.createSignedUrl(storagePath, 300);
|
||||||
|
if (signed.error || !signed.data?.signedUrl) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: signed.error?.message || 'No signed URL' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.redirect(signed.data.signedUrl);
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from '@/lib/agency';
|
} from '@/lib/agency';
|
||||||
|
|
||||||
const SELECT =
|
const SELECT =
|
||||||
'id,title,status,channel,placement,format,headline,primary_text,description,cta,media_url,brand_name,landing_url,mime_type,bytes,created_at,updated_at';
|
'id,title,status,channel,placement,format,headline,primary_text,description,cta,media_url,brand_name,landing_url,storage_path,mime_type,bytes,created_at,updated_at';
|
||||||
|
|
||||||
const CHANNELS = new Set([
|
const CHANNELS = new Set([
|
||||||
'meta_ads',
|
'meta_ads',
|
||||||
@@ -31,6 +31,12 @@ const FORMATS = new Set([
|
|||||||
'story',
|
'story',
|
||||||
'rsa',
|
'rsa',
|
||||||
'pmax',
|
'pmax',
|
||||||
|
'pmax_landscape',
|
||||||
|
'pmax_square',
|
||||||
|
'pmax_portrait',
|
||||||
|
'pmax_vertical',
|
||||||
|
'pmax_logo',
|
||||||
|
'youtube',
|
||||||
'other',
|
'other',
|
||||||
]);
|
]);
|
||||||
const ALLOWED_STATUS = new Set(['draft', 'in_review', 'approved', 'rejected']);
|
const ALLOWED_STATUS = new Set(['draft', 'in_review', 'approved', 'rejected']);
|
||||||
|
|||||||
197
src/app/api/creatives/upload/route.ts
Normal file
197
src/app/api/creatives/upload/route.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import {
|
||||||
|
isAgencyRole,
|
||||||
|
serviceHeaders,
|
||||||
|
supabaseUrl,
|
||||||
|
userRole,
|
||||||
|
} from '@/lib/agency';
|
||||||
|
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||||
|
import {
|
||||||
|
ASSETS_BUCKET,
|
||||||
|
MAX_ASSET_BYTES,
|
||||||
|
ensureAssetsBucket,
|
||||||
|
sanitizeFileName,
|
||||||
|
serviceSupabase,
|
||||||
|
} from '@/lib/assets';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
const ALLOWED_MIME = new Set([
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/webp',
|
||||||
|
'image/gif',
|
||||||
|
'video/mp4',
|
||||||
|
'video/webm',
|
||||||
|
'video/quicktime',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST multipart: upload creative media and attach to a creative row.
|
||||||
|
* fields: file, creativeId? (optional — creates draft if missing), title?
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = await resolveActiveTenant(user);
|
||||||
|
if (!ctx.tenantId || ctx.portfolioWide) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Selecciona un cliente en Contexto' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = await req.formData();
|
||||||
|
const file = form.get('file');
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
return NextResponse.json({ error: 'file requerido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (file.size <= 0 || file.size > MAX_ASSET_BYTES) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Archivo vacío o > ${MAX_ASSET_BYTES / (1024 * 1024)} MB` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const mime = file.type || 'application/octet-stream';
|
||||||
|
if (!ALLOWED_MIME.has(mime) && !mime.startsWith('image/') && !mime.startsWith('video/')) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Solo imagen o vídeo (jpg/png/webp/gif/mp4/webm)' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucketOk = await ensureAssetsBucket();
|
||||||
|
if (!bucketOk.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: bucketOk.error || 'No se pudo asegurar el bucket' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const svc = serviceSupabase();
|
||||||
|
if (!svc) {
|
||||||
|
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let creativeId =
|
||||||
|
typeof form.get('creativeId') === 'string'
|
||||||
|
? String(form.get('creativeId')).trim()
|
||||||
|
: '';
|
||||||
|
const title =
|
||||||
|
typeof form.get('title') === 'string'
|
||||||
|
? String(form.get('title')).trim()
|
||||||
|
: file.name.replace(/\.[^.]+$/, '') || 'Creativo subido';
|
||||||
|
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
const agency = isAgencyRole(userRole(user));
|
||||||
|
|
||||||
|
if (!creativeId) {
|
||||||
|
const row = {
|
||||||
|
tenant_id: ctx.tenantId,
|
||||||
|
title: title.slice(0, 300),
|
||||||
|
status: 'draft',
|
||||||
|
channel: 'meta_ads',
|
||||||
|
format: mime.startsWith('video/') ? 'video' : 'image',
|
||||||
|
created_by: user.id,
|
||||||
|
};
|
||||||
|
if (agency && headers) {
|
||||||
|
const r = await fetch(`${supabaseUrl()}/rest/v1/creatives`, {
|
||||||
|
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 data = await r.json();
|
||||||
|
creativeId = Array.isArray(data) ? data[0]?.id : data?.id;
|
||||||
|
} else {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('creatives')
|
||||||
|
.insert(row)
|
||||||
|
.select('id')
|
||||||
|
.single();
|
||||||
|
if (error || !data) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error?.message || 'No se pudo crear' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
creativeId = data.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!creativeId) {
|
||||||
|
return NextResponse.json({ error: 'creativeId inválido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeName = sanitizeFileName(file.name || 'creative.bin');
|
||||||
|
const storagePath = `${ctx.tenantId}/creatives/${creativeId}/${randomUUID()}-${safeName}`;
|
||||||
|
const buf = Buffer.from(await file.arrayBuffer());
|
||||||
|
|
||||||
|
const up = await svc.storage.from(ASSETS_BUCKET).upload(storagePath, buf, {
|
||||||
|
contentType: mime,
|
||||||
|
upsert: false,
|
||||||
|
});
|
||||||
|
if (up.error) {
|
||||||
|
return NextResponse.json({ error: up.error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaUrl = `/api/creatives/media?id=${encodeURIComponent(creativeId)}`;
|
||||||
|
const patch = {
|
||||||
|
storage_path: storagePath,
|
||||||
|
mime_type: mime,
|
||||||
|
bytes: file.size,
|
||||||
|
media_url: mediaUrl,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (agency && headers) {
|
||||||
|
const r = await fetch(
|
||||||
|
`${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(creativeId)}`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'return=representation',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!r.ok) {
|
||||||
|
await svc.storage.from(ASSETS_BUCKET).remove([storagePath]);
|
||||||
|
return NextResponse.json({ error: await r.text() }, { status: 500 });
|
||||||
|
}
|
||||||
|
const data = await r.json();
|
||||||
|
return NextResponse.json({
|
||||||
|
creative: Array.isArray(data) ? data[0] : data,
|
||||||
|
media_url: mediaUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('creatives')
|
||||||
|
.update(patch)
|
||||||
|
.eq('id', creativeId)
|
||||||
|
.select('*')
|
||||||
|
.single();
|
||||||
|
if (error) {
|
||||||
|
await svc.storage.from(ASSETS_BUCKET).remove([storagePath]);
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ creative: data, media_url: mediaUrl });
|
||||||
|
}
|
||||||
@@ -1,12 +1,20 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
|
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
|
||||||
import { isAgencyRole, userRole } from '@/lib/agency';
|
import {
|
||||||
|
isAgencyRole,
|
||||||
|
serviceHeaders,
|
||||||
|
supabaseUrl,
|
||||||
|
userRole,
|
||||||
|
} from '@/lib/agency';
|
||||||
import {
|
import {
|
||||||
AdDailyRow,
|
AdDailyRow,
|
||||||
KpiWeeklyRow,
|
KpiWeeklyRow,
|
||||||
buildChannelSeries,
|
buildChannelSeries,
|
||||||
|
sumDaily,
|
||||||
|
sumWeekly,
|
||||||
} from '@/lib/metrics';
|
} from '@/lib/metrics';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -21,9 +29,10 @@ export type SeguimientoEntry = {
|
|||||||
metrics?: Record<string, unknown>;
|
metrics?: Record<string, unknown>;
|
||||||
occurred_at?: string | null;
|
occurred_at?: string | null;
|
||||||
synced_at?: string;
|
synced_at?: string;
|
||||||
|
source?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** GET seguimiento timeline + channel evolution for active tenant. */
|
/** GET seguimiento timeline + metrics series for active tenant. */
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
const {
|
const {
|
||||||
@@ -41,8 +50,10 @@ export async function GET() {
|
|||||||
label: ctx.label,
|
label: ctx.label,
|
||||||
portfolioWide: true,
|
portfolioWide: true,
|
||||||
entries: [],
|
entries: [],
|
||||||
|
weekly: [],
|
||||||
|
daily: [],
|
||||||
channelSeries: { weeks: [], channels: [], points: [] },
|
channelSeries: { weeks: [], channels: [], points: [] },
|
||||||
hint: 'Elige un cliente en el selector para ver su seguimiento y evolución de canales.',
|
hint: 'Elige un cliente en el selector para ver su seguimiento y evolución.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,13 +70,13 @@ export async function GET() {
|
|||||||
const tid = encodeURIComponent(tenantId);
|
const tid = encodeURIComponent(tenantId);
|
||||||
const [e, w, d] = await Promise.all([
|
const [e, w, d] = await Promise.all([
|
||||||
restGet<SeguimientoEntry[]>(
|
restGet<SeguimientoEntry[]>(
|
||||||
`/rest/v1/seguimiento_entries?tenant_id=eq.${tid}&select=*&order=occurred_at.desc.nullslast&limit=80`
|
`/rest/v1/seguimiento_entries?tenant_id=eq.${tid}&select=*&order=occurred_at.desc.nullslast&limit=120`
|
||||||
),
|
),
|
||||||
restGet<KpiWeeklyRow[]>(
|
restGet<KpiWeeklyRow[]>(
|
||||||
`/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.asc`
|
`/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.asc`
|
||||||
),
|
),
|
||||||
restGet<AdDailyRow[]>(
|
restGet<AdDailyRow[]>(
|
||||||
`/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.asc&limit=400`
|
`/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.asc&limit=800`
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
entries = e.data || [];
|
entries = e.data || [];
|
||||||
@@ -77,13 +88,13 @@ export async function GET() {
|
|||||||
.from('seguimiento_entries')
|
.from('seguimiento_entries')
|
||||||
.select('*')
|
.select('*')
|
||||||
.order('occurred_at', { ascending: false, nullsFirst: false })
|
.order('occurred_at', { ascending: false, nullsFirst: false })
|
||||||
.limit(80),
|
.limit(120),
|
||||||
supabase.from('kpi_weekly').select('*').order('week', { ascending: true }),
|
supabase.from('kpi_weekly').select('*').order('week', { ascending: true }),
|
||||||
supabase
|
supabase
|
||||||
.from('ad_daily')
|
.from('ad_daily')
|
||||||
.select('*')
|
.select('*')
|
||||||
.order('date', { ascending: true })
|
.order('date', { ascending: true })
|
||||||
.limit(400),
|
.limit(800),
|
||||||
]);
|
]);
|
||||||
entries = (e as SeguimientoEntry[]) || [];
|
entries = (e as SeguimientoEntry[]) || [];
|
||||||
weekly = (w as KpiWeeklyRow[]) || [];
|
weekly = (w as KpiWeeklyRow[]) || [];
|
||||||
@@ -91,17 +102,118 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const channelSeries = buildChannelSeries(weekly);
|
const channelSeries = buildChannelSeries(weekly);
|
||||||
|
const totalsWeekly = sumWeekly(weekly);
|
||||||
|
const totalsDaily = sumDaily(daily);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
label: ctx.label,
|
label: ctx.label,
|
||||||
portfolioWide: false,
|
portfolioWide: false,
|
||||||
tenantId,
|
tenantId,
|
||||||
entries,
|
entries,
|
||||||
|
weekly,
|
||||||
|
daily,
|
||||||
channelSeries,
|
channelSeries,
|
||||||
|
totals: {
|
||||||
|
spend: totalsWeekly.spend || totalsDaily.spend,
|
||||||
|
leads: totalsWeekly.leads || totalsDaily.leads,
|
||||||
|
cpl: totalsWeekly.cpl || (totalsDaily.leads > 0 ? totalsDaily.spend / totalsDaily.leads : 0),
|
||||||
|
cac: totalsWeekly.cac,
|
||||||
|
revenue: totalsWeekly.revenue,
|
||||||
|
roas: totalsWeekly.roas,
|
||||||
|
impressions: totalsDaily.impressions,
|
||||||
|
clicks: totalsDaily.clicks,
|
||||||
|
},
|
||||||
dailySummary: {
|
dailySummary: {
|
||||||
days: daily.length,
|
days: daily.length,
|
||||||
spend: daily.reduce((a, r) => a + Number(r.spend || 0), 0),
|
spend: totalsDaily.spend,
|
||||||
leads: daily.reduce((a, r) => a + Number(r.platform_leads || 0), 0),
|
leads: totalsDaily.leads,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST — add a manual note / comentario to seguimiento.
|
||||||
|
* { title?, body, occurred_at? }
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = await resolveActiveTenant(user);
|
||||||
|
if (!ctx.tenantId || ctx.portfolioWide) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Selecciona un cliente en Contexto' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
|
||||||
|
const text =
|
||||||
|
typeof body.body === 'string'
|
||||||
|
? body.body.trim()
|
||||||
|
: typeof body.note === 'string'
|
||||||
|
? body.note.trim()
|
||||||
|
: '';
|
||||||
|
if (!text) {
|
||||||
|
return NextResponse.json({ error: 'Texto de la nota requerido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const title =
|
||||||
|
typeof body.title === 'string' && body.title.trim()
|
||||||
|
? body.title.trim().slice(0, 200)
|
||||||
|
: `Nota · ${new Date().toLocaleDateString('es-ES')}`;
|
||||||
|
const occurredAt =
|
||||||
|
typeof body.occurred_at === 'string' && body.occurred_at
|
||||||
|
? body.occurred_at
|
||||||
|
: new Date().toISOString();
|
||||||
|
|
||||||
|
const row = {
|
||||||
|
tenant_id: ctx.tenantId,
|
||||||
|
source: 'manual',
|
||||||
|
entry_type: 'note',
|
||||||
|
external_id: `manual-${randomUUID()}`,
|
||||||
|
title,
|
||||||
|
summary: text.slice(0, 280),
|
||||||
|
body_md: text,
|
||||||
|
occurred_at: occurredAt,
|
||||||
|
synced_at: new Date().toISOString(),
|
||||||
|
metrics: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const agency = isAgencyRole(userRole(user));
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
|
||||||
|
if (agency && headers) {
|
||||||
|
const r = await fetch(`${supabaseUrl()}/rest/v1/seguimiento_entries`, {
|
||||||
|
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 data = await r.json();
|
||||||
|
return NextResponse.json(
|
||||||
|
{ entry: Array.isArray(data) ? data[0] : data },
|
||||||
|
{ status: 201 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('seguimiento_entries')
|
||||||
|
.insert(row)
|
||||||
|
.select('*')
|
||||||
|
.single();
|
||||||
|
if (error) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ entry: data }, { status: 201 });
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { isAgencyRole, userRole } from '@/lib/agency';
|
|||||||
import RrssMonthView, { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView';
|
import RrssMonthView, { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView';
|
||||||
import CalendarClient from './CalendarClient';
|
import CalendarClient from './CalendarClient';
|
||||||
import PublishRrssButton from './PublishRrssButton';
|
import PublishRrssButton from './PublishRrssButton';
|
||||||
|
import RrssArchiveYear from '@/components/RrssArchiveYear';
|
||||||
import octPlan from '@/data/rrss-octubre-2026.json';
|
import octPlan from '@/data/rrss-octubre-2026.json';
|
||||||
import { Instagram, Linkedin, CalendarDays } from 'lucide-react';
|
import { Instagram, Linkedin, CalendarDays } from 'lucide-react';
|
||||||
|
|
||||||
@@ -88,11 +89,12 @@ export default async function CalendarioPage({
|
|||||||
|
|
||||||
const monthParam = sp.month || plans[0]?.month;
|
const monthParam = sp.month || plans[0]?.month;
|
||||||
const active = plans.find((p) => p.month === monthParam) || plans[0];
|
const active = plans.find((p) => p.month === monthParam) || plans[0];
|
||||||
const view = sp.view || 'hub';
|
// Portada = archivo anual estilo /rrss/ig-fb/
|
||||||
|
const view = sp.view || 'archive';
|
||||||
const initialPlatform =
|
const initialPlatform =
|
||||||
sp.tab === 'linkedin'
|
sp.tab === 'linkedin'
|
||||||
? 'linkedin'
|
? 'linkedin'
|
||||||
: sp.tab === 'instagram'
|
: sp.tab === 'instagram' || sp.tab === 'ig-fb'
|
||||||
? 'instagram'
|
? 'instagram'
|
||||||
: 'all';
|
: 'all';
|
||||||
|
|
||||||
@@ -133,49 +135,14 @@ export default async function CalendarioPage({
|
|||||||
|
|
||||||
// Archive of months (like /rrss/ig-fb/)
|
// Archive of months (like /rrss/ig-fb/)
|
||||||
if (view === 'archive') {
|
if (view === 'archive') {
|
||||||
|
const year = Number((active?.month || '2026-10').slice(0, 4)) || 2026;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<RrssArchiveYear
|
||||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
year={year}
|
||||||
<div>
|
plans={plans}
|
||||||
<Link
|
tenantLabel={ctx.label}
|
||||||
href="/dashboard/calendario?view=hub"
|
agencyPublish={agency ? <PublishRrssButton /> : null}
|
||||||
className="text-xs text-gray-400 hover:text-white"
|
/>
|
||||||
>
|
|
||||||
← Hub RRSS
|
|
||||||
</Link>
|
|
||||||
<h2 className="text-2xl font-extrabold text-white mt-2">
|
|
||||||
Calendario IG / FB
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-gray-400 mt-1">
|
|
||||||
Archivo mensual renovable · {ctx.label}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{plans.map((p) => (
|
|
||||||
<Link
|
|
||||||
key={p.month}
|
|
||||||
href={`/dashboard/calendario?view=month&month=${p.month}`}
|
|
||||||
className="rounded-2xl border border-gray-800 bg-[#111827] overflow-hidden hover:border-brand-500/40 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="bg-gradient-to-r from-[#16204A] to-[#2071CF] px-4 py-3 flex justify-between items-center">
|
|
||||||
<span className="text-xs font-extrabold tracking-wider text-white uppercase">
|
|
||||||
{p.label}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-white/15 text-white">
|
|
||||||
Live
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="p-4">
|
|
||||||
<p className="text-xs text-gray-400">
|
|
||||||
{(p.pieces || []).length} piezas · clic para abrir el mes
|
|
||||||
completo (reparto + calendario + copy)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ export type CreativeFormat =
|
|||||||
| 'story'
|
| 'story'
|
||||||
| 'rsa'
|
| 'rsa'
|
||||||
| 'pmax'
|
| 'pmax'
|
||||||
|
| 'pmax_landscape'
|
||||||
|
| 'pmax_square'
|
||||||
|
| 'pmax_portrait'
|
||||||
|
| 'pmax_vertical'
|
||||||
|
| 'pmax_logo'
|
||||||
|
| 'youtube'
|
||||||
| 'other';
|
| 'other';
|
||||||
|
|
||||||
export type CreativePreviewData = {
|
export type CreativePreviewData = {
|
||||||
@@ -350,13 +356,45 @@ function GoogleSearchPreview({ data }: { data: CreativePreviewData }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GoogleDisplayPreview({ data }: { data: CreativePreviewData }) {
|
function GoogleDisplayPreview({
|
||||||
|
data,
|
||||||
|
aspect = 'landscape',
|
||||||
|
}: {
|
||||||
|
data: CreativePreviewData;
|
||||||
|
aspect?: 'landscape' | 'square' | 'portrait' | 'vertical' | 'logo';
|
||||||
|
}) {
|
||||||
|
const ratio =
|
||||||
|
aspect === 'square'
|
||||||
|
? 'aspect-square'
|
||||||
|
: aspect === 'portrait'
|
||||||
|
? 'aspect-[4/5]'
|
||||||
|
: aspect === 'vertical'
|
||||||
|
? 'aspect-[9/16]'
|
||||||
|
: aspect === 'logo'
|
||||||
|
? 'aspect-square max-w-[140px] mx-auto'
|
||||||
|
: 'aspect-[1.91/1]';
|
||||||
|
const label =
|
||||||
|
aspect === 'square'
|
||||||
|
? '1:1 · cuadrado'
|
||||||
|
: aspect === 'portrait'
|
||||||
|
? '4:5 · portrait'
|
||||||
|
: aspect === 'vertical'
|
||||||
|
? '9:16 · vertical'
|
||||||
|
: aspect === 'logo'
|
||||||
|
? 'Logo'
|
||||||
|
: '1.91:1 · landscape';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-gray-700 bg-[#111827] overflow-hidden">
|
<div className="rounded-2xl border border-gray-700 bg-[#111827] overflow-hidden">
|
||||||
<p className="text-[10px] font-bold uppercase tracking-wider text-sky-400 px-3 py-2 border-b border-gray-800">
|
<p className="text-[10px] font-bold uppercase tracking-wider text-sky-400 px-3 py-2 border-b border-gray-800">
|
||||||
Google Ads · display / PMax
|
Google Ads · Performance Max · {label}
|
||||||
</p>
|
</p>
|
||||||
<MediaBlock data={data} />
|
<div className={`${ratio} overflow-hidden bg-gray-900`}>
|
||||||
|
<MediaBlock
|
||||||
|
data={data}
|
||||||
|
tall={aspect === 'vertical' || aspect === 'portrait'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="p-3 bg-white text-gray-900">
|
<div className="p-3 bg-white text-gray-900">
|
||||||
<p className="text-sm font-bold line-clamp-2">
|
<p className="text-sm font-bold line-clamp-2">
|
||||||
{data.headline || data.title}
|
{data.headline || data.title}
|
||||||
@@ -370,6 +408,30 @@ function GoogleDisplayPreview({ data }: { data: CreativePreviewData }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function YouTubePreview({ data }: { data: CreativePreviewData }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-gray-700 bg-[#111827] overflow-hidden">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-wider text-red-400 px-3 py-2 border-b border-gray-800">
|
||||||
|
Google Ads · YouTube
|
||||||
|
</p>
|
||||||
|
<div className="aspect-video relative bg-black">
|
||||||
|
<MediaBlock data={data} tall />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
|
<Play className="w-12 h-12 text-white/80" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-white text-gray-900">
|
||||||
|
<p className="text-sm font-bold line-clamp-2">
|
||||||
|
{data.headline || data.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-gray-600 line-clamp-2 mt-1">
|
||||||
|
{data.description || data.primary_text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Renders a placement mock like Ad Tracker phone / Google RSA cards. */
|
/** Renders a placement mock like Ad Tracker phone / Google RSA cards. */
|
||||||
export default function CreativePlacementPreview({
|
export default function CreativePlacementPreview({
|
||||||
data,
|
data,
|
||||||
@@ -384,13 +446,31 @@ export default function CreativePlacementPreview({
|
|||||||
|
|
||||||
let body: React.ReactNode;
|
let body: React.ReactNode;
|
||||||
|
|
||||||
if (channel === 'google_ads' || format === 'rsa') {
|
if (format === 'youtube') {
|
||||||
body =
|
body = <YouTubePreview data={data} />;
|
||||||
format === 'pmax' || placement === 'display' ? (
|
} else if (
|
||||||
<GoogleDisplayPreview data={data} />
|
format === 'pmax_landscape' ||
|
||||||
) : (
|
format === 'pmax_square' ||
|
||||||
<GoogleSearchPreview data={data} />
|
format === 'pmax_portrait' ||
|
||||||
);
|
format === 'pmax_vertical' ||
|
||||||
|
format === 'pmax_logo' ||
|
||||||
|
format === 'pmax' ||
|
||||||
|
(channel === 'google_ads' &&
|
||||||
|
(placement === 'display' || format.startsWith('pmax')))
|
||||||
|
) {
|
||||||
|
const aspect =
|
||||||
|
format === 'pmax_square' || format === 'pmax_logo'
|
||||||
|
? format === 'pmax_logo'
|
||||||
|
? 'logo'
|
||||||
|
: 'square'
|
||||||
|
: format === 'pmax_portrait'
|
||||||
|
? 'portrait'
|
||||||
|
: format === 'pmax_vertical'
|
||||||
|
? 'vertical'
|
||||||
|
: 'landscape';
|
||||||
|
body = <GoogleDisplayPreview data={data} aspect={aspect} />;
|
||||||
|
} else if (channel === 'google_ads' || format === 'rsa') {
|
||||||
|
body = <GoogleSearchPreview data={data} />;
|
||||||
} else if (
|
} else if (
|
||||||
channel === 'stories' ||
|
channel === 'stories' ||
|
||||||
placement === 'stories' ||
|
placement === 'stories' ||
|
||||||
@@ -443,8 +523,14 @@ export const FORMAT_OPTIONS: { id: CreativeFormat; label: string }[] = [
|
|||||||
{ id: 'carousel', label: 'Carrusel' },
|
{ id: 'carousel', label: 'Carrusel' },
|
||||||
{ id: 'reel', label: 'Reel' },
|
{ id: 'reel', label: 'Reel' },
|
||||||
{ id: 'story', label: 'Story' },
|
{ id: 'story', label: 'Story' },
|
||||||
{ id: 'rsa', label: 'RSA (búsqueda)' },
|
{ id: 'rsa', label: 'Google RSA (búsqueda)' },
|
||||||
{ id: 'pmax', label: 'PMax / Display' },
|
{ id: 'pmax', label: 'PMax (genérico)' },
|
||||||
|
{ id: 'pmax_landscape', label: 'PMax landscape 1.91:1' },
|
||||||
|
{ id: 'pmax_square', label: 'PMax cuadrado 1:1' },
|
||||||
|
{ id: 'pmax_portrait', label: 'PMax portrait 4:5' },
|
||||||
|
{ id: 'pmax_vertical', label: 'PMax vertical 9:16' },
|
||||||
|
{ id: 'pmax_logo', label: 'PMax logo' },
|
||||||
|
{ id: 'youtube', label: 'YouTube / vídeo in-stream' },
|
||||||
{ id: 'other', label: 'Otro' },
|
{ id: 'other', label: 'Otro' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -454,5 +540,9 @@ export const PLACEMENT_OPTIONS = [
|
|||||||
{ id: 'reels', label: 'Reels' },
|
{ id: 'reels', label: 'Reels' },
|
||||||
{ id: 'search', label: 'Búsqueda' },
|
{ id: 'search', label: 'Búsqueda' },
|
||||||
{ id: 'display', label: 'Display' },
|
{ id: 'display', label: 'Display' },
|
||||||
|
{ id: 'youtube', label: 'YouTube' },
|
||||||
|
{ id: 'discover', label: 'Discover' },
|
||||||
|
{ id: 'gmail', label: 'Gmail' },
|
||||||
|
{ id: 'maps', label: 'Maps' },
|
||||||
{ id: 'other', label: 'Otro' },
|
{ id: 'other', label: 'Otro' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
Eye,
|
Eye,
|
||||||
|
Upload,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import CreativePlacementPreview, {
|
import CreativePlacementPreview, {
|
||||||
CHANNEL_OPTIONS,
|
CHANNEL_OPTIONS,
|
||||||
@@ -74,6 +75,8 @@ export default function CreativesPipeline() {
|
|||||||
const [selected, setSelected] = useState<Creative | null>(null);
|
const [selected, setSelected] = useState<Creative | null>(null);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [filter, setFilter] = useState<'all' | string>('all');
|
const [filter, setFilter] = useState<'all' | string>('all');
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [uploadMsg, setUploadMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -105,6 +108,47 @@ export default function CreativesPipeline() {
|
|||||||
return creatives.filter((c) => c.status === filter);
|
return creatives.filter((c) => c.status === filter);
|
||||||
}, [creatives, filter]);
|
}, [creatives, filter]);
|
||||||
|
|
||||||
|
async function uploadFile(
|
||||||
|
file: File,
|
||||||
|
creativeId?: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
setUploading(true);
|
||||||
|
setUploadMsg(null);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
if (creativeId) fd.append('creativeId', creativeId);
|
||||||
|
if (draft.title) fd.append('title', draft.title);
|
||||||
|
const res = await fetch('/api/creatives/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setErr(data.error ?? 'Error al subir');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const url = data.media_url || data.creative?.media_url || null;
|
||||||
|
if (url) {
|
||||||
|
setDraft((d) => ({
|
||||||
|
...d,
|
||||||
|
media_url: url,
|
||||||
|
title: d.title || data.creative?.title || d.title,
|
||||||
|
format:
|
||||||
|
file.type.startsWith('video/') && !d.format.startsWith('pmax')
|
||||||
|
? 'video'
|
||||||
|
: d.format,
|
||||||
|
}));
|
||||||
|
setUploadMsg('Archivo subido · preview actualizada');
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
return data.creative?.id || creativeId || null;
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function create(e: FormEvent) {
|
async function create(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setErr(null);
|
setErr(null);
|
||||||
@@ -358,7 +402,7 @@ export default function CreativesPipeline() {
|
|||||||
setDraft((d) => ({ ...d, media_url: e.target.value }))
|
setDraft((d) => ({ ...d, media_url: e.target.value }))
|
||||||
}
|
}
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
placeholder="https://… o /media/…"
|
placeholder="https://… o /api/creatives/media?id=…"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Landing">
|
<Field label="Landing">
|
||||||
@@ -372,6 +416,32 @@ export default function CreativesPipeline() {
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="rounded-xl border border-dashed border-violet-500/40 bg-violet-500/5 p-4">
|
||||||
|
<p className="text-[11px] uppercase tracking-wider text-violet-300 font-semibold mb-2 flex items-center gap-1.5">
|
||||||
|
<Upload className="w-3.5 h-3.5" /> Subir creativo (sin URL)
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*,video/mp4,video/webm,video/quicktime"
|
||||||
|
disabled={uploading}
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) void uploadFile(f);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
className="block w-full text-xs text-gray-300 file:mr-3 file:rounded-lg file:border-0 file:bg-violet-500/20 file:px-3 file:py-1.5 file:text-violet-200 file:text-xs file:font-semibold"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-gray-500 mt-2">
|
||||||
|
JPG, PNG, WebP, GIF o MP4/WebM. Se guarda en el storage del
|
||||||
|
tenant y alimenta la preview.
|
||||||
|
</p>
|
||||||
|
{uploading ? (
|
||||||
|
<p className="text-xs text-violet-300 mt-2">Subiendo…</p>
|
||||||
|
) : null}
|
||||||
|
{uploadMsg ? (
|
||||||
|
<p className="text-xs text-emerald-400 mt-2">{uploadMsg}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<div className="flex gap-2 pt-1">
|
<div className="flex gap-2 pt-1">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -638,6 +708,33 @@ export default function CreativesPipeline() {
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="rounded-xl border border-dashed border-violet-500/30 p-3">
|
||||||
|
<p className="text-[10px] uppercase text-violet-300 mb-2">
|
||||||
|
Reemplazar archivo
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*,video/mp4,video/webm"
|
||||||
|
disabled={uploading || busyId === selected.id}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (!f || !selected) return;
|
||||||
|
const id = await uploadFile(f, selected.id);
|
||||||
|
if (id) {
|
||||||
|
setSelected((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
media_url: `/api/creatives/media?id=${encodeURIComponent(prev.id)}&t=${Date.now()}`,
|
||||||
|
}
|
||||||
|
: prev
|
||||||
|
);
|
||||||
|
}
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
className="block w-full text-xs text-gray-300 file:mr-3 file:rounded-lg file:border-0 file:bg-violet-500/20 file:px-3 file:py-1.5 file:text-violet-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 border-t border-gray-800 bg-[#111827] flex flex-wrap justify-between gap-2">
|
<div className="p-4 border-t border-gray-800 bg-[#111827] flex flex-wrap justify-between gap-2">
|
||||||
|
|||||||
218
src/components/RrssArchiveYear.tsx
Normal file
218
src/components/RrssArchiveYear.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView';
|
||||||
|
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
'Enero',
|
||||||
|
'Febrero',
|
||||||
|
'Marzo',
|
||||||
|
'Abril',
|
||||||
|
'Mayo',
|
||||||
|
'Junio',
|
||||||
|
'Julio',
|
||||||
|
'Agosto',
|
||||||
|
'Septiembre',
|
||||||
|
'Octubre',
|
||||||
|
'Noviembre',
|
||||||
|
'Diciembre',
|
||||||
|
];
|
||||||
|
|
||||||
|
function colorFor(piece: RrssPiece): string {
|
||||||
|
if (piece.color) return piece.color;
|
||||||
|
if (piece.platform === 'linkedin') return '#7C66B8';
|
||||||
|
if ((piece.format || '').toLowerCase().includes('stor')) return '#374151';
|
||||||
|
const hay = `${piece.pillar || ''} ${piece.meta_line || ''} ${piece.title}`.toLowerCase();
|
||||||
|
if (hay.includes('familia') || hay.includes('bienestar')) return '#E08E2E';
|
||||||
|
if (hay.includes('sostenib')) return '#0F9D8F';
|
||||||
|
if (hay.includes('b2b') || hay.includes('hosteler')) return '#7C66B8';
|
||||||
|
if (hay.includes('prueba') || hay.includes('reseña')) return '#8A93A6';
|
||||||
|
return '#2071cf';
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniMonth({
|
||||||
|
year,
|
||||||
|
monthIndex,
|
||||||
|
plan,
|
||||||
|
}: {
|
||||||
|
year: number;
|
||||||
|
monthIndex: number; // 0-11
|
||||||
|
plan?: RrssMonthPlan;
|
||||||
|
}) {
|
||||||
|
const monthKey = `${year}-${String(monthIndex + 1).padStart(2, '0')}`;
|
||||||
|
const first = new Date(Date.UTC(year, monthIndex, 1));
|
||||||
|
const daysInMonth = new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
|
||||||
|
const startPad = (first.getUTCDay() + 6) % 7;
|
||||||
|
const live = Boolean(plan);
|
||||||
|
const byDate = new Map<string, RrssPiece[]>();
|
||||||
|
for (const p of plan?.pieces || []) {
|
||||||
|
const k = p.date.slice(0, 10);
|
||||||
|
(byDate.get(k) || byDate.set(k, []).get(k)!).push(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerCls = live
|
||||||
|
? 'flex items-center justify-between gap-2 bg-gradient-to-r from-[#1a2a63] to-[#2071CF] text-white px-3.5 py-2.5'
|
||||||
|
: 'flex items-center justify-between gap-2 bg-[#9aa6c0] text-white px-3.5 py-2.5';
|
||||||
|
|
||||||
|
const body = (
|
||||||
|
<>
|
||||||
|
<div className={headerCls}>
|
||||||
|
<span className="text-[13px] font-extrabold tracking-wider uppercase">
|
||||||
|
{MONTH_NAMES[monthIndex]}
|
||||||
|
</span>
|
||||||
|
<em className="not-italic text-[10px] font-bold tracking-wider uppercase px-2 py-1 rounded-full bg-white/20">
|
||||||
|
{live ? 'Publicado' : 'Pronto'}
|
||||||
|
</em>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-0.5 px-2.5 pt-3">
|
||||||
|
{['L', 'M', 'X', 'J', 'V', 'S', 'D'].map((d, i) => (
|
||||||
|
<span
|
||||||
|
key={d}
|
||||||
|
className={`text-center text-[10px] font-bold ${
|
||||||
|
i >= 5 ? 'text-sky-400' : 'text-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{d}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-0.5 px-2.5 pb-2.5 pt-1">
|
||||||
|
{Array.from({ length: startPad }).map((_, i) => (
|
||||||
|
<span key={`e-${i}`} className="aspect-square invisible" />
|
||||||
|
))}
|
||||||
|
{Array.from({ length: daysInMonth }).map((_, i) => {
|
||||||
|
const day = i + 1;
|
||||||
|
const key = `${monthKey}-${String(day).padStart(2, '0')}`;
|
||||||
|
const hits = byDate.get(key) || [];
|
||||||
|
const weekend =
|
||||||
|
new Date(Date.UTC(year, monthIndex, day)).getUTCDay() % 6 === 0;
|
||||||
|
const bg = hits[0] ? colorFor(hits[0]) : undefined;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={key}
|
||||||
|
title={hits.map((h) => h.title).join(' · ') || undefined}
|
||||||
|
className={`aspect-square flex items-center justify-center text-[11px] font-bold rounded-full ${
|
||||||
|
weekend && !bg ? 'text-sky-400' : 'text-gray-300'
|
||||||
|
}`}
|
||||||
|
style={
|
||||||
|
bg
|
||||||
|
? {
|
||||||
|
background: bg,
|
||||||
|
color: '#fff',
|
||||||
|
boxShadow: 'inset 0 0 0 1px rgba(255,255,255,.35)',
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="mt-auto px-3.5 py-3 text-xs border-t border-gray-800 text-gray-500">
|
||||||
|
{live
|
||||||
|
? `${plan!.pieces.length} piezas · abrir mes`
|
||||||
|
: 'Pendiente de publicar'}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const className = `rounded-2xl border overflow-hidden flex flex-col min-h-full transition-all ${
|
||||||
|
live
|
||||||
|
? 'border-gray-700 bg-[#111827] hover:border-sky-500/50 hover:-translate-y-0.5 shadow-lg shadow-sky-900/10'
|
||||||
|
: 'border-gray-800/80 bg-[#0D121F] opacity-80'
|
||||||
|
}`;
|
||||||
|
|
||||||
|
if (live) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/calendario?view=month&month=${monthKey}`}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} aria-disabled>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Year archive cover like /agua-miia/rrss/ig-fb/ */
|
||||||
|
export default function RrssArchiveYear({
|
||||||
|
year = 2026,
|
||||||
|
plans,
|
||||||
|
tenantLabel,
|
||||||
|
agencyPublish,
|
||||||
|
}: {
|
||||||
|
year?: number;
|
||||||
|
plans: RrssMonthPlan[];
|
||||||
|
tenantLabel: string;
|
||||||
|
agencyPublish?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const byMonth = new Map(plans.map((p) => [p.month, p]));
|
||||||
|
const legend = plans[0]?.legend || [
|
||||||
|
{ label: 'Educativo / serie', color: '#2071cf' },
|
||||||
|
{ label: 'Familia / bienestar', color: '#E08E2E' },
|
||||||
|
{ label: 'Sostenibilidad', color: '#0F9D8F' },
|
||||||
|
{ label: 'B2B / LinkedIn', color: '#7C66B8' },
|
||||||
|
{ label: 'Stories', color: '#374151' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-7">
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/calendario?view=hub"
|
||||||
|
className="text-xs text-gray-400 hover:text-white"
|
||||||
|
>
|
||||||
|
← Hub RRSS
|
||||||
|
</Link>
|
||||||
|
<h2 className="text-3xl font-extrabold text-white tracking-tight mt-2">
|
||||||
|
Calendario IG / FB {year}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-400 mt-2 max-w-xl leading-relaxed">
|
||||||
|
Archivo mensual de contenidos. Los días redondeados y coloreados
|
||||||
|
marcan el tipo de publicación (misma paleta que en cada mes).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full border border-gray-700 bg-[#111827] px-4 py-2.5 text-sm font-bold text-white">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-sky-400" />
|
||||||
|
Año {year} · {tenantLabel} × 4Ventures
|
||||||
|
</div>
|
||||||
|
{agencyPublish}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{Array.from({ length: 12 }).map((_, i) => {
|
||||||
|
const key = `${year}-${String(i + 1).padStart(2, '0')}`;
|
||||||
|
return (
|
||||||
|
<MiniMonth
|
||||||
|
key={key}
|
||||||
|
year={year}
|
||||||
|
monthIndex={i}
|
||||||
|
plan={byMonth.get(key)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-4 text-xs text-gray-400">
|
||||||
|
{legend.map((l) => (
|
||||||
|
<span key={l.label} className="inline-flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{ background: l.color }}
|
||||||
|
/>
|
||||||
|
{l.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -145,9 +145,13 @@ export default function RrssMonthView({
|
|||||||
return ['all', ...[...set].sort()];
|
return ['all', ...[...set].sort()];
|
||||||
}, [pieces]);
|
}, [pieces]);
|
||||||
|
|
||||||
const filtered = pieces.filter(
|
const filtered = pieces.filter((p) => {
|
||||||
(p) => platform === 'all' || p.platform === platform
|
if (platform === 'all') return true;
|
||||||
);
|
if (platform === 'instagram') {
|
||||||
|
return p.platform === 'instagram' || p.platform === 'facebook';
|
||||||
|
}
|
||||||
|
return p.platform === platform;
|
||||||
|
});
|
||||||
|
|
||||||
const ids = pieces.map((p) => p.id);
|
const ids = pieces.map((p) => p.id);
|
||||||
|
|
||||||
@@ -250,9 +254,9 @@ export default function RrssMonthView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Hub crumbs */}
|
{/* Hub crumbs + network filters */}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-gray-400">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div className="text-xs text-gray-400">
|
||||||
<Link href="/dashboard/calendario" className="hover:text-white">
|
<Link href="/dashboard/calendario" className="hover:text-white">
|
||||||
RRSS
|
RRSS
|
||||||
</Link>
|
</Link>
|
||||||
@@ -268,6 +272,42 @@ export default function RrssMonthView({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPlatform('all')}
|
||||||
|
className={`inline-flex items-center justify-center min-h-[44px] px-5 rounded-xl text-sm font-bold ${
|
||||||
|
platform === 'all'
|
||||||
|
? 'bg-white text-gray-900'
|
||||||
|
: 'bg-gray-800/80 text-gray-300 hover:bg-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Todas
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPlatform('instagram')}
|
||||||
|
className={`inline-flex items-center justify-center min-h-[44px] px-5 rounded-xl text-sm font-bold ${
|
||||||
|
platform === 'instagram' || platform === 'facebook'
|
||||||
|
? 'bg-[#16204A] text-white ring-2 ring-sky-400/40'
|
||||||
|
: 'bg-[#16204A]/80 text-white/90 hover:bg-[#1a2a63]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Instagram className="w-4 h-4 mr-2" /> IG / FB
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPlatform('linkedin')}
|
||||||
|
className={`inline-flex items-center justify-center min-h-[44px] px-5 rounded-xl text-sm font-bold ${
|
||||||
|
platform === 'linkedin'
|
||||||
|
? 'bg-[#2071CF] text-white ring-2 ring-sky-300/50'
|
||||||
|
: 'bg-[#2071CF]/80 text-white/90 hover:bg-[#1a5fb8]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Linkedin className="w-4 h-4 mr-2" /> LinkedIn
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
<section className="relative overflow-hidden rounded-2xl border border-gray-800 bg-gradient-to-br from-[#16204A] via-[#111827] to-[#0B0F19] p-6 md:p-8">
|
<section className="relative overflow-hidden rounded-2xl border border-gray-800 bg-gradient-to-br from-[#16204A] via-[#111827] to-[#0B0F19] p-6 md:p-8">
|
||||||
<p className="text-[11px] font-bold tracking-[0.16em] uppercase text-sky-300/90">
|
<p className="text-[11px] font-bold tracking-[0.16em] uppercase text-sky-300/90">
|
||||||
@@ -291,7 +331,7 @@ export default function RrssMonthView({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Network tabs */}
|
{/* Network tabs (secondary) */}
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{platforms.map((p) => (
|
{platforms.map((p) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -7,12 +7,28 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
MessageSquareText,
|
MessageSquareText,
|
||||||
Radio,
|
Radio,
|
||||||
|
Send,
|
||||||
|
CalendarRange,
|
||||||
|
DollarSign,
|
||||||
|
Users,
|
||||||
|
MousePointerClick,
|
||||||
|
Target,
|
||||||
|
TrendingUp,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
|
AdDailyRow,
|
||||||
ChannelWeekPoint,
|
ChannelWeekPoint,
|
||||||
|
KpiWeeklyRow,
|
||||||
formatEur,
|
formatEur,
|
||||||
formatNum,
|
formatNum,
|
||||||
|
sumDaily,
|
||||||
|
sumWeekly,
|
||||||
} from '@/lib/metrics';
|
} from '@/lib/metrics';
|
||||||
|
import {
|
||||||
|
AD_TRACKER_PRESETS,
|
||||||
|
RangePreset,
|
||||||
|
resolveAdRange,
|
||||||
|
} from '@/lib/ad-tracker-range';
|
||||||
|
|
||||||
type Entry = {
|
type Entry = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -28,6 +44,7 @@ type Entry = {
|
|||||||
periodo?: string;
|
periodo?: string;
|
||||||
};
|
};
|
||||||
occurred_at?: string | null;
|
occurred_at?: string | null;
|
||||||
|
source?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Payload = {
|
type Payload = {
|
||||||
@@ -35,6 +52,8 @@ type Payload = {
|
|||||||
portfolioWide?: boolean;
|
portfolioWide?: boolean;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
entries: Entry[];
|
entries: Entry[];
|
||||||
|
weekly?: KpiWeeklyRow[];
|
||||||
|
daily?: AdDailyRow[];
|
||||||
channelSeries: {
|
channelSeries: {
|
||||||
weeks: string[];
|
weeks: string[];
|
||||||
channels: string[];
|
channels: string[];
|
||||||
@@ -66,7 +85,13 @@ function typeTone(t: string) {
|
|||||||
if (t === 'meeting') return 'bg-amber-500/15 text-amber-300 border-amber-500/30';
|
if (t === 'meeting') return 'bg-amber-500/15 text-amber-300 border-amber-500/30';
|
||||||
if (t === 'kpis') return 'bg-sky-500/15 text-sky-300 border-sky-500/30';
|
if (t === 'kpis') return 'bg-sky-500/15 text-sky-300 border-sky-500/30';
|
||||||
if (t === 'topic') return 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30';
|
if (t === 'topic') return 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30';
|
||||||
return 'bg-gray-500/15 text-gray-300 border-gray-500/30';
|
return 'bg-violet-500/15 text-violet-300 border-violet-500/30';
|
||||||
|
}
|
||||||
|
|
||||||
|
function inRange(isoDate: string | undefined | null, since: string, until: string) {
|
||||||
|
if (!isoDate) return false;
|
||||||
|
const d = isoDate.slice(0, 10);
|
||||||
|
return d >= since && d <= until;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChannelEvolutionChart({
|
function ChannelEvolutionChart({
|
||||||
@@ -113,7 +138,6 @@ function ChannelEvolutionChart({
|
|||||||
}, [channels, activeChannels, weeks, points, metric]);
|
}, [channels, activeChannels, weeks, points, metric]);
|
||||||
|
|
||||||
const maxY = Math.max(1, ...series.flatMap((s) => s.vals));
|
const maxY = Math.max(1, ...series.flatMap((s) => s.vals));
|
||||||
|
|
||||||
const xAt = (i: number) =>
|
const xAt = (i: number) =>
|
||||||
pad.l + (weeks.length <= 1 ? innerW / 2 : (i / (weeks.length - 1)) * innerW);
|
pad.l + (weeks.length <= 1 ? innerW / 2 : (i / (weeks.length - 1)) * innerW);
|
||||||
const yAt = (v: number) => pad.t + innerH - (v / maxY) * innerH;
|
const yAt = (v: number) => pad.t + innerH - (v / maxY) * innerH;
|
||||||
@@ -121,7 +145,7 @@ function ChannelEvolutionChart({
|
|||||||
if (!weeks.length || !series.length) {
|
if (!weeks.length || !series.length) {
|
||||||
return (
|
return (
|
||||||
<div className="h-[260px] flex items-center justify-center text-sm text-gray-500 border border-dashed border-gray-800 rounded-xl">
|
<div className="h-[260px] flex items-center justify-center text-sm text-gray-500 border border-dashed border-gray-800 rounded-xl">
|
||||||
Aún no hay series de canal en kpi_weekly para este cliente.
|
Sin serie en el rango seleccionado.
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -154,14 +178,11 @@ function ChannelEvolutionChart({
|
|||||||
className="fill-gray-500"
|
className="fill-gray-500"
|
||||||
fontSize={10}
|
fontSize={10}
|
||||||
>
|
>
|
||||||
{metric === 'spend' || metric === 'cpl'
|
{formatNum(val)}
|
||||||
? formatNum(val)
|
|
||||||
: formatNum(val)}
|
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{series.map((s) => {
|
{series.map((s) => {
|
||||||
const d = s.vals
|
const d = s.vals
|
||||||
.map((v, i) => `${i === 0 ? 'M' : 'L'} ${xAt(i)} ${yAt(v)}`)
|
.map((v, i) => `${i === 0 ? 'M' : 'L'} ${xAt(i)} ${yAt(v)}`)
|
||||||
@@ -174,7 +195,11 @@ function ChannelEvolutionChart({
|
|||||||
key={`${s.channel}-${weeks[i]}`}
|
key={`${s.channel}-${weeks[i]}`}
|
||||||
cx={xAt(i)}
|
cx={xAt(i)}
|
||||||
cy={yAt(v)}
|
cy={yAt(v)}
|
||||||
r={hover?.week === weeks[i] && hover?.channel === s.channel ? 5 : 3.5}
|
r={
|
||||||
|
hover?.week === weeks[i] && hover?.channel === s.channel
|
||||||
|
? 5
|
||||||
|
: 3.5
|
||||||
|
}
|
||||||
fill={s.color}
|
fill={s.color}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onMouseEnter={() =>
|
onMouseEnter={() =>
|
||||||
@@ -192,7 +217,6 @@ function ChannelEvolutionChart({
|
|||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{weeks.map((w, i) => (
|
{weeks.map((w, i) => (
|
||||||
<text
|
<text
|
||||||
key={w}
|
key={w}
|
||||||
@@ -206,7 +230,6 @@ function ChannelEvolutionChart({
|
|||||||
</text>
|
</text>
|
||||||
))}
|
))}
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{hover && (
|
{hover && (
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute z-10 rounded-lg border border-gray-700 bg-[#0D121F] px-2.5 py-1.5 text-[11px] text-white shadow-xl"
|
className="pointer-events-none absolute z-10 rounded-lg border border-gray-700 bg-[#0D121F] px-2.5 py-1.5 text-[11px] text-white shadow-xl"
|
||||||
@@ -238,35 +261,75 @@ export default function SeguimientoBoard() {
|
|||||||
const [activeChannels, setActiveChannels] = useState<Set<string>>(new Set());
|
const [activeChannels, setActiveChannels] = useState<Set<string>>(new Set());
|
||||||
const [openId, setOpenId] = useState<string | null>(null);
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
|
const [preset, setPreset] = useState<RangePreset>('last_30d');
|
||||||
|
const [noteTitle, setNoteTitle] = useState('');
|
||||||
|
const [noteBody, setNoteBody] = useState('');
|
||||||
|
const [noteBusy, setNoteBusy] = useState(false);
|
||||||
|
const [noteMsg, setNoteMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/seguimiento', { cache: 'no-store' });
|
||||||
|
const j = await r.json();
|
||||||
|
if (!r.ok) throw new Error(j.error || 'Error cargando seguimiento');
|
||||||
|
setData(j);
|
||||||
|
setActiveChannels(new Set(j.channelSeries?.channels || []));
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
void load();
|
||||||
(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/seguimiento', { cache: 'no-store' });
|
|
||||||
const j = await r.json();
|
|
||||||
if (!r.ok) throw new Error(j.error || 'Error cargando seguimiento');
|
|
||||||
if (!cancelled) {
|
|
||||||
setData(j);
|
|
||||||
setActiveChannels(new Set(j.channelSeries?.channels || []));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (!cancelled) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Error');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) setLoading(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const range = useMemo(() => resolveAdRange(preset), [preset]);
|
||||||
|
|
||||||
|
const rangedWeekly = useMemo(() => {
|
||||||
|
const rows = data?.weekly || [];
|
||||||
|
return rows.filter((r) => inRange(r.week, range.since, range.until));
|
||||||
|
}, [data?.weekly, range.since, range.until]);
|
||||||
|
|
||||||
|
const rangedDaily = useMemo(() => {
|
||||||
|
const rows = data?.daily || [];
|
||||||
|
return rows.filter((r) => inRange(r.date, range.since, range.until));
|
||||||
|
}, [data?.daily, range.since, range.until]);
|
||||||
|
|
||||||
|
const periodTotals = useMemo(() => {
|
||||||
|
const w = sumWeekly(rangedWeekly);
|
||||||
|
const d = sumDaily(rangedDaily);
|
||||||
|
const spend = w.spend || d.spend;
|
||||||
|
const leads = w.leads || d.leads;
|
||||||
|
return {
|
||||||
|
spend,
|
||||||
|
leads,
|
||||||
|
cpl: leads > 0 ? spend / leads : w.cpl,
|
||||||
|
cac: w.cac,
|
||||||
|
revenue: w.revenue,
|
||||||
|
roas: w.roas,
|
||||||
|
impressions: d.impressions,
|
||||||
|
clicks: d.clicks,
|
||||||
|
};
|
||||||
|
}, [rangedWeekly, rangedDaily]);
|
||||||
|
|
||||||
|
const rangedChannel = useMemo(() => {
|
||||||
|
const points = (data?.channelSeries.points || []).filter((p) =>
|
||||||
|
inRange(p.week, range.since, range.until)
|
||||||
|
);
|
||||||
|
const weeks = [...new Set(points.map((p) => p.week))].sort();
|
||||||
|
const channels = data?.channelSeries.channels || [];
|
||||||
|
return { weeks, channels, points };
|
||||||
|
}, [data?.channelSeries, range.since, range.until]);
|
||||||
|
|
||||||
const filteredEntries = useMemo(() => {
|
const filteredEntries = useMemo(() => {
|
||||||
const list = data?.entries || [];
|
const list = (data?.entries || []).filter((e) => {
|
||||||
|
if (!e.occurred_at) return true;
|
||||||
|
return inRange(e.occurred_at, range.since, range.until);
|
||||||
|
});
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
if (!q) return list;
|
if (!q) return list;
|
||||||
return list.filter(
|
return list.filter(
|
||||||
@@ -275,7 +338,7 @@ export default function SeguimientoBoard() {
|
|||||||
(e.summary || '').toLowerCase().includes(q) ||
|
(e.summary || '').toLowerCase().includes(q) ||
|
||||||
(e.body_md || '').toLowerCase().includes(q)
|
(e.body_md || '').toLowerCase().includes(q)
|
||||||
);
|
);
|
||||||
}, [data, query]);
|
}, [data, query, range.since, range.until]);
|
||||||
|
|
||||||
function toggleChannel(ch: string) {
|
function toggleChannel(ch: string) {
|
||||||
setActiveChannels((prev) => {
|
setActiveChannels((prev) => {
|
||||||
@@ -286,6 +349,33 @@ export default function SeguimientoBoard() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveNote() {
|
||||||
|
if (!noteBody.trim()) return;
|
||||||
|
setNoteBusy(true);
|
||||||
|
setNoteMsg(null);
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/seguimiento', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: noteTitle.trim() || undefined,
|
||||||
|
body: noteBody.trim(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const j = await r.json();
|
||||||
|
if (!r.ok) {
|
||||||
|
setNoteMsg(j.error || 'No se pudo guardar');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setNoteTitle('');
|
||||||
|
setNoteBody('');
|
||||||
|
setNoteMsg('Nota añadida al seguimiento');
|
||||||
|
await load();
|
||||||
|
} finally {
|
||||||
|
setNoteBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-400 py-16 justify-center">
|
<div className="flex items-center gap-2 text-sm text-gray-400 py-16 justify-center">
|
||||||
@@ -308,34 +398,106 @@ export default function SeguimientoBoard() {
|
|||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-gray-800 bg-[#111827] p-8 text-center">
|
<div className="rounded-xl border border-gray-800 bg-[#111827] p-8 text-center">
|
||||||
<Radio className="w-8 h-8 text-brand-500 mx-auto mb-3" />
|
<Radio className="w-8 h-8 text-brand-500 mx-auto mb-3" />
|
||||||
<h2 className="text-lg font-semibold text-white">Seguimiento por cliente</h2>
|
<h2 className="text-lg font-semibold text-white">Seguimiento</h2>
|
||||||
<p className="text-sm text-gray-400 mt-2 max-w-md mx-auto">
|
<p className="text-sm text-gray-400 mt-2 max-w-md mx-auto">
|
||||||
{data.hint ||
|
{data.hint ||
|
||||||
'Selecciona un cliente en el encabezado para ver la evolución reunión a reunión y por canales.'}
|
'Selecciona un cliente en el encabezado para ver la evolución y las notas.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { channelSeries, dailySummary } = data;
|
const cards = [
|
||||||
|
{
|
||||||
|
label: 'Inversión',
|
||||||
|
value: formatEur(periodTotals.spend),
|
||||||
|
icon: <DollarSign className="w-4 h-4 text-brand-500" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Leads',
|
||||||
|
value: formatNum(periodTotals.leads),
|
||||||
|
icon: <Users className="w-4 h-4 text-blue-400" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'CPL',
|
||||||
|
value: periodTotals.cpl ? `${formatNum(periodTotals.cpl)} €` : '—',
|
||||||
|
icon: <MousePointerClick className="w-4 h-4 text-violet-400" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'CAC',
|
||||||
|
value: periodTotals.cac ? `${formatNum(periodTotals.cac)} €` : '—',
|
||||||
|
icon: <Target className="w-4 h-4 text-amber-400" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Revenue',
|
||||||
|
value: formatEur(periodTotals.revenue),
|
||||||
|
icon: <TrendingUp className="w-4 h-4 text-emerald-400" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'ROAS',
|
||||||
|
value: periodTotals.roas ? `${formatNum(periodTotals.roas)}x` : '—',
|
||||||
|
icon: <TrendingUp className="w-4 h-4 text-emerald-300" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-3">
|
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight text-white">
|
<p className="text-[11px] font-bold tracking-[0.16em] uppercase text-sky-400">
|
||||||
Seguimiento & evolución
|
Seguimiento
|
||||||
|
</p>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight text-white mt-1">
|
||||||
|
Evolución de métricas
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-400 mt-1">
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
{data.label} · timeline Superhuman + series de canal (kpi_weekly)
|
{data.label} · mismas KPIs que Métricas, filtrables por periodo +
|
||||||
|
notas del equipo
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{dailySummary && dailySummary.days > 0 && (
|
</div>
|
||||||
<div className="text-[11px] text-gray-500 bg-gray-900/50 border border-gray-800 rounded-lg px-3 py-2">
|
|
||||||
Ads recientes · {formatEur(dailySummary.spend)} ·{' '}
|
{/* Date selector */}
|
||||||
{formatNum(dailySummary.leads)} leads · {dailySummary.days} días
|
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-4 space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||||
|
<CalendarRange className="w-3.5 h-3.5 text-sky-400" />
|
||||||
|
Periodo
|
||||||
|
<span className="text-gray-500">· {range.label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{AD_TRACKER_PRESETS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPreset(p.id)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-semibold ${
|
||||||
|
preset === p.id
|
||||||
|
? 'bg-brand-500 text-white'
|
||||||
|
: 'bg-gray-800/70 text-gray-300 hover:bg-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* KPI cards (Métricas-like) */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3">
|
||||||
|
{cards.map((c) => (
|
||||||
|
<div
|
||||||
|
key={c.label}
|
||||||
|
className="rounded-xl border border-gray-800 bg-[#0D121F] px-3 py-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-gray-500">
|
||||||
|
{c.label}
|
||||||
|
</p>
|
||||||
|
{c.icon}
|
||||||
|
</div>
|
||||||
|
<p className="text-xl font-bold text-white tabular-nums">{c.value}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Channel evolution */}
|
{/* Channel evolution */}
|
||||||
@@ -370,7 +532,7 @@ export default function SeguimientoBoard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{channelSeries.channels.map((ch, i) => {
|
{rangedChannel.channels.map((ch, i) => {
|
||||||
const on = activeChannels.has(ch);
|
const on = activeChannels.has(ch);
|
||||||
const color = CHANNEL_COLORS[i % CHANNEL_COLORS.length];
|
const color = CHANNEL_COLORS[i % CHANNEL_COLORS.length];
|
||||||
return (
|
return (
|
||||||
@@ -395,20 +557,58 @@ export default function SeguimientoBoard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ChannelEvolutionChart
|
<ChannelEvolutionChart
|
||||||
weeks={channelSeries.weeks}
|
weeks={rangedChannel.weeks}
|
||||||
channels={channelSeries.channels}
|
channels={rangedChannel.channels}
|
||||||
points={channelSeries.points}
|
points={rangedChannel.points}
|
||||||
metric={metric}
|
metric={metric}
|
||||||
activeChannels={activeChannels}
|
activeChannels={activeChannels}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Notes box */}
|
||||||
|
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-5 space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-white font-medium">
|
||||||
|
<MessageSquareText className="w-4 h-4 text-violet-400" />
|
||||||
|
Comentarios y notas
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Añade observaciones del periodo (reunión, hipótesis, bloqueos,
|
||||||
|
siguientes pasos…). Quedan en el timeline de seguimiento.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
value={noteTitle}
|
||||||
|
onChange={(e) => setNoteTitle(e.target.value)}
|
||||||
|
placeholder="Título (opcional)"
|
||||||
|
className="w-full rounded-lg bg-black/30 border border-gray-800 px-3 py-2 text-sm text-white placeholder:text-gray-600"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
value={noteBody}
|
||||||
|
onChange={(e) => setNoteBody(e.target.value)}
|
||||||
|
placeholder="Escribe aquí comentarios, notas o cualquier cosa relevante…"
|
||||||
|
className="w-full min-h-[120px] rounded-lg bg-black/30 border border-gray-800 px-3 py-2 text-sm text-white placeholder:text-gray-600"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={noteBusy || !noteBody.trim()}
|
||||||
|
onClick={saveNote}
|
||||||
|
className="inline-flex items-center gap-2 rounded-xl bg-violet-500/20 border border-violet-500/40 text-violet-200 px-4 py-2 text-sm font-semibold disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Send className="w-3.5 h-3.5" />
|
||||||
|
{noteBusy ? 'Guardando…' : 'Añadir al seguimiento'}
|
||||||
|
</button>
|
||||||
|
{noteMsg ? (
|
||||||
|
<p className="text-xs text-emerald-400">{noteMsg}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Timeline */}
|
{/* Timeline */}
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-2 text-white font-medium">
|
<div className="flex items-center gap-2 text-white font-medium">
|
||||||
<MessageSquareText className="w-4 h-4 text-emerald-400" />
|
<Radio className="w-4 h-4 text-emerald-400" />
|
||||||
Timeline reunión a reunión
|
Timeline seguimiento
|
||||||
<span className="text-xs text-gray-500 font-normal">
|
<span className="text-xs text-gray-500 font-normal">
|
||||||
({filteredEntries.length})
|
({filteredEntries.length})
|
||||||
</span>
|
</span>
|
||||||
@@ -416,15 +616,15 @@ export default function SeguimientoBoard() {
|
|||||||
<input
|
<input
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Filtrar temas, CPL, Meta…"
|
placeholder="Filtrar temas, CPL, notas…"
|
||||||
className="w-full sm:w-64 rounded-lg bg-black/30 border border-gray-800 px-3 py-1.5 text-sm text-white placeholder:text-gray-600 focus:outline-none focus:border-brand-500/50"
|
className="w-full sm:w-64 rounded-lg bg-black/30 border border-gray-800 px-3 py-1.5 text-sm text-white placeholder:text-gray-600 focus:outline-none focus:border-brand-500/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filteredEntries.length === 0 ? (
|
{filteredEntries.length === 0 ? (
|
||||||
<div className="rounded-xl border border-dashed border-gray-800 px-4 py-10 text-center text-sm text-gray-500">
|
<div className="rounded-xl border border-dashed border-gray-800 px-4 py-10 text-center text-sm text-gray-500">
|
||||||
Sin entradas aún. Desde Agencia → sincroniza Superhuman Docs para este
|
Sin entradas en este periodo. Añade una nota arriba o sincroniza
|
||||||
cliente (páginas de Seguimiento; nunca contraseñas).
|
Superhuman Docs desde Agencia.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ol className="relative border-l border-gray-800 ml-3 space-y-4">
|
<ol className="relative border-l border-gray-800 ml-3 space-y-4">
|
||||||
@@ -455,7 +655,9 @@ export default function SeguimientoBoard() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] text-gray-500">{when}</span>
|
<span className="text-[11px] text-gray-500">{when}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-semibold text-white">{e.title}</p>
|
<p className="text-sm font-semibold text-white">
|
||||||
|
{e.title}
|
||||||
|
</p>
|
||||||
{e.summary && (
|
{e.summary && (
|
||||||
<p className="text-xs text-gray-400 mt-1 line-clamp-2">
|
<p className="text-xs text-gray-400 mt-1 line-clamp-2">
|
||||||
{e.summary}
|
{e.summary}
|
||||||
@@ -480,25 +682,27 @@ export default function SeguimientoBoard() {
|
|||||||
{e.body_md}
|
{e.body_md}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
{open && e.metrics && (e.metrics.ventas || e.metrics.cpl) && (
|
{open &&
|
||||||
<div className="mt-2 flex flex-wrap gap-2 text-[11px]">
|
e.metrics &&
|
||||||
{e.metrics.ventas != null && (
|
(e.metrics.ventas || e.metrics.cpl) && (
|
||||||
<span className="px-2 py-0.5 rounded bg-emerald-500/10 text-emerald-300">
|
<div className="mt-2 flex flex-wrap gap-2 text-[11px]">
|
||||||
Ventas {e.metrics.ventas}
|
{e.metrics.ventas != null && (
|
||||||
</span>
|
<span className="px-2 py-0.5 rounded bg-emerald-500/10 text-emerald-300">
|
||||||
)}
|
Ventas {e.metrics.ventas}
|
||||||
{e.metrics.cpl != null && (
|
</span>
|
||||||
<span className="px-2 py-0.5 rounded bg-sky-500/10 text-sky-300">
|
)}
|
||||||
CPL ≈ {formatNum(e.metrics.cpl)} €
|
{e.metrics.cpl != null && (
|
||||||
</span>
|
<span className="px-2 py-0.5 rounded bg-sky-500/10 text-sky-300">
|
||||||
)}
|
CPL ≈ {formatNum(e.metrics.cpl)} €
|
||||||
{e.metrics.cac != null && (
|
</span>
|
||||||
<span className="px-2 py-0.5 rounded bg-amber-500/10 text-amber-300">
|
)}
|
||||||
CAC ≈ {formatNum(e.metrics.cac)} €
|
{e.metrics.cac != null && (
|
||||||
</span>
|
<span className="px-2 py-0.5 rounded bg-amber-500/10 text-amber-300">
|
||||||
)}
|
CAC ≈ {formatNum(e.metrics.cac)} €
|
||||||
</div>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user