diff --git a/scripts/dokploy_apply_content_collab.py b/scripts/dokploy_apply_content_collab.py new file mode 100644 index 0000000..381dbb9 --- /dev/null +++ b/scripts/dokploy_apply_content_collab.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Apply sql/content_collab.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" / "content_collab.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()) diff --git a/sql/content_collab.sql b/sql/content_collab.sql new file mode 100644 index 0000000..fbece7a --- /dev/null +++ b/sql/content_collab.sql @@ -0,0 +1,180 @@ +-- Dual approval (agency + client) + threaded comments for calendar posts & assets +-- Idempotent. Private portal only (no public publish). + +-- --------------------------------------------------------------------------- +-- Calendar posts (normalized; private per tenant) +-- --------------------------------------------------------------------------- +create table if not exists public.calendar_posts ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references public.tenants(id) on delete cascade, + external_key text, + month text, + date date not null, + time_slot text, + platform text not null + check (platform in ( + 'instagram', 'facebook', 'linkedin', 'tiktok', 'youtube', 'x', 'other' + )), + format text, + pillar text, + title text not null, + hook text, + body text, + cta text, + hashtags text, + objective_metric text, + status text not null default 'draft' + check (status in ('draft', 'in_review', 'scheduled', 'published', 'archived')), + agency_approved_at timestamptz, + agency_approved_by uuid references auth.users(id), + client_approved_at timestamptz, + client_approved_by uuid references auth.users(id), + meta jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (tenant_id, external_key) +); + +create index if not exists calendar_posts_tenant_month_idx + on public.calendar_posts (tenant_id, month, date); +create index if not exists calendar_posts_tenant_platform_idx + on public.calendar_posts (tenant_id, platform, date); + +alter table public.calendar_posts enable row level security; + +drop policy if exists calendar_posts_tenant_select on public.calendar_posts; +drop policy if exists calendar_posts_tenant_write on public.calendar_posts; + +create policy calendar_posts_tenant_select on public.calendar_posts + for select using ( + tenant_id::text = coalesce( + auth.jwt() -> 'app_metadata' ->> 'tenant_id', + auth.jwt() -> 'user_metadata' ->> 'tenant_id' + ) + ); + +create policy calendar_posts_tenant_write on public.calendar_posts + for all using ( + tenant_id::text = coalesce( + auth.jwt() -> 'app_metadata' ->> 'tenant_id', + auth.jwt() -> 'user_metadata' ->> 'tenant_id' + ) + ) + with check ( + tenant_id::text = coalesce( + auth.jwt() -> 'app_metadata' ->> 'tenant_id', + auth.jwt() -> 'user_metadata' ->> 'tenant_id' + ) + ); + +-- --------------------------------------------------------------------------- +-- Shared comments (calendar_post | asset) +-- --------------------------------------------------------------------------- +create table if not exists public.content_comments ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references public.tenants(id) on delete cascade, + resource_type text not null check (resource_type in ('calendar_post', 'asset')), + resource_id text not null, + body text not null, + author_id uuid references auth.users(id), + author_email text, + author_party text not null check (author_party in ('agency', 'client')), + created_at timestamptz not null default now() +); + +create index if not exists content_comments_resource_idx + on public.content_comments (tenant_id, resource_type, resource_id, created_at); + +alter table public.content_comments enable row level security; + +drop policy if exists content_comments_tenant_select on public.content_comments; +drop policy if exists content_comments_tenant_insert on public.content_comments; + +create policy content_comments_tenant_select on public.content_comments + for select using ( + tenant_id::text = coalesce( + auth.jwt() -> 'app_metadata' ->> 'tenant_id', + auth.jwt() -> 'user_metadata' ->> 'tenant_id' + ) + ); + +create policy content_comments_tenant_insert on public.content_comments + for insert with check ( + tenant_id::text = coalesce( + auth.jwt() -> 'app_metadata' ->> 'tenant_id', + auth.jwt() -> 'user_metadata' ->> 'tenant_id' + ) + ); + +-- --------------------------------------------------------------------------- +-- Dual approval events (calendar_post | asset) +-- --------------------------------------------------------------------------- +create table if not exists public.content_approvals ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references public.tenants(id) on delete cascade, + resource_type text not null check (resource_type in ('calendar_post', 'asset')), + resource_id text not null, + party text not null check (party in ('agency', 'client')), + approved boolean not null default true, + note text, + actor_id uuid references auth.users(id), + actor_email text, + created_at timestamptz not null default now(), + unique (tenant_id, resource_type, resource_id, party) +); + +alter table public.content_approvals enable row level security; + +drop policy if exists content_approvals_tenant_select on public.content_approvals; +drop policy if exists content_approvals_tenant_write on public.content_approvals; + +create policy content_approvals_tenant_select on public.content_approvals + for select using ( + tenant_id::text = coalesce( + auth.jwt() -> 'app_metadata' ->> 'tenant_id', + auth.jwt() -> 'user_metadata' ->> 'tenant_id' + ) + ); + +create policy content_approvals_tenant_write on public.content_approvals + for all using ( + tenant_id::text = coalesce( + auth.jwt() -> 'app_metadata' ->> 'tenant_id', + auth.jwt() -> 'user_metadata' ->> 'tenant_id' + ) + ) + with check ( + tenant_id::text = coalesce( + auth.jwt() -> 'app_metadata' ->> 'tenant_id', + auth.jwt() -> 'user_metadata' ->> 'tenant_id' + ) + ); + +-- --------------------------------------------------------------------------- +-- Assets: dual validation columns +-- --------------------------------------------------------------------------- +alter table public.asset_files + add column if not exists review_status text not null default 'in_review'; + +alter table public.asset_files + drop constraint if exists asset_files_review_status_check; + +alter table public.asset_files + add constraint asset_files_review_status_check + check (review_status in ('draft', 'in_review', 'validated', 'archived')); + +alter table public.asset_files + add column if not exists agency_approved_at timestamptz; +alter table public.asset_files + add column if not exists agency_approved_by uuid references auth.users(id); +alter table public.asset_files + add column if not exists client_approved_at timestamptz; +alter table public.asset_files + add column if not exists client_approved_by uuid references auth.users(id); + +comment on table public.calendar_posts is + 'Private RRSS calendar per tenant. Dual agency/client approval. No public publish.'; +comment on table public.content_comments is + 'Comments on calendar posts or assets (agency + client).'; +comment on table public.content_approvals is + 'Dual-check approvals: agency and client must both approve for full OK.'; diff --git a/src/app/api/assets/collab/route.ts b/src/app/api/assets/collab/route.ts new file mode 100644 index 0000000..ddf442f --- /dev/null +++ b/src/app/api/assets/collab/route.ts @@ -0,0 +1,135 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { resolveActiveTenant } from '@/lib/tenant-context'; +import { + dualStatus, + insertComment, + listApprovals, + listComments, + partyFromUser, + upsertApproval, +} from '@/lib/content-collab'; +import { serviceHeaders, supabaseUrl } from '@/lib/agency'; + +export const dynamic = 'force-dynamic'; + +async function gate() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) }; + } + const ctx = await resolveActiveTenant(user); + if (!ctx.tenantId || ctx.portfolioWide) { + return { + error: NextResponse.json({ error: 'Selecciona un cliente' }, { status: 400 }), + }; + } + return { user, ctx, party: partyFromUser(user) }; +} + +export async function GET(req: NextRequest) { + const g = await gate(); + if ('error' in g && g.error) return g.error; + const { ctx, party } = g as { + ctx: { tenantId: string }; + party: 'agency' | 'client'; + }; + const ids = (req.nextUrl.searchParams.get('ids') || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + const resourceId = req.nextUrl.searchParams.get('resourceId'); + const approvals = await listApprovals(ctx.tenantId, 'asset', ids); + const dual: Record> = {}; + for (const id of ids) dual[id] = dualStatus(approvals[id] || []); + let comments = null; + if (resourceId) { + comments = await listComments(ctx.tenantId, 'asset', resourceId); + } + return NextResponse.json({ dual, comments, party }); +} + +export async function POST(req: NextRequest) { + const g = await gate(); + if ('error' in g && g.error) return g.error; + const { user, ctx, party } = g as { + user: { id: string; email?: string }; + ctx: { tenantId: string }; + party: 'agency' | 'client'; + }; + const body = await req.json().catch(() => ({})); + const assetId = String(body.assetId || ''); + if (!assetId) { + return NextResponse.json({ error: 'assetId requerido' }, { status: 400 }); + } + + if (body.action === 'approve' || body.action === 'revoke') { + await upsertApproval({ + tenantId: ctx.tenantId, + resourceType: 'asset', + resourceId: assetId, + party, + approved: body.action === 'approve', + actorId: user.id, + actorEmail: user.email || null, + }); + const approvals = await listApprovals(ctx.tenantId, 'asset', [assetId]); + const dual = dualStatus(approvals[assetId] || []); + + // Mirror to asset_files columns when both OK + const headers = serviceHeaders(); + if (headers) { + const patch: Record = { + updated_at: new Date().toISOString(), + }; + if (party === 'agency') { + patch.agency_approved_at = + body.action === 'approve' ? new Date().toISOString() : null; + patch.agency_approved_by = body.action === 'approve' ? user.id : null; + } else { + patch.client_approved_at = + body.action === 'approve' ? new Date().toISOString() : null; + patch.client_approved_by = body.action === 'approve' ? user.id : null; + } + if (dual.both) patch.review_status = 'validated'; + else patch.review_status = 'in_review'; + + await fetch( + `${supabaseUrl()}/rest/v1/asset_files?id=eq.${encodeURIComponent(assetId)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}`, + { + method: 'PATCH', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=minimal', + }, + body: JSON.stringify(patch), + } + ); + } + + return NextResponse.json({ ok: true, dual, party }); + } + + if (body.action === 'comment') { + const text = String(body.body || '').trim(); + if (!text) { + return NextResponse.json({ error: 'Comentario vacío' }, { status: 400 }); + } + const row = await insertComment({ + tenantId: ctx.tenantId, + resourceType: 'asset', + resourceId: assetId, + body: text.slice(0, 4000), + authorId: user.id, + authorEmail: user.email || null, + authorParty: party, + }); + return NextResponse.json({ ok: true, comment: row }); + } + + return NextResponse.json({ error: 'action inválida' }, { status: 400 }); +} diff --git a/src/app/api/calendar/collab/route.ts b/src/app/api/calendar/collab/route.ts new file mode 100644 index 0000000..86e4000 --- /dev/null +++ b/src/app/api/calendar/collab/route.ts @@ -0,0 +1,127 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { resolveActiveTenant } from '@/lib/tenant-context'; +import { + dualStatus, + insertComment, + listApprovals, + listComments, + partyFromUser, + upsertApproval, +} from '@/lib/content-collab'; +import { isAgencyRole, userRole } from '@/lib/agency'; + +export const dynamic = 'force-dynamic'; + +async function gate() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) }; + } + const ctx = await resolveActiveTenant(user); + if (!ctx.tenantId || ctx.portfolioWide) { + return { + error: NextResponse.json( + { error: 'Selecciona un cliente para colaborar en el calendario.' }, + { status: 400 } + ), + }; + } + return { user, ctx, party: partyFromUser(user) }; +} + +/** GET ?ids=a,b,c — approvals map; &resourceId=x — also comments */ +export async function GET(req: NextRequest) { + const g = await gate(); + if ('error' in g && g.error) return g.error; + const { ctx } = g as Awaited> & { + ctx: { tenantId: string }; + }; + + const ids = (req.nextUrl.searchParams.get('ids') || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + const resourceId = req.nextUrl.searchParams.get('resourceId'); + + const approvals = await listApprovals(ctx.tenantId!, 'calendar_post', ids); + const dual: Record> = {}; + for (const id of ids) { + dual[id] = dualStatus(approvals[id] || []); + } + + let comments = null; + if (resourceId) { + comments = await listComments(ctx.tenantId!, 'calendar_post', resourceId); + } + + return NextResponse.json({ + approvals, + dual, + comments, + party: (g as { party: string }).party, + isAgency: isAgencyRole(userRole((g as { user: unknown }).user as never)), + }); +} + +/** + * POST actions: + * { action: 'approve' | 'revoke', postId, note? } + * { action: 'comment', postId, body } + */ +export async function POST(req: NextRequest) { + const g = await gate(); + if ('error' in g && g.error) return g.error; + const { user, ctx, party } = g as { + user: { id: string; email?: string }; + ctx: { tenantId: string }; + party: 'agency' | 'client'; + }; + + const body = await req.json().catch(() => ({})); + const postId = String(body.postId || ''); + if (!postId) { + return NextResponse.json({ error: 'postId requerido' }, { status: 400 }); + } + + if (body.action === 'approve' || body.action === 'revoke') { + await upsertApproval({ + tenantId: ctx.tenantId, + resourceType: 'calendar_post', + resourceId: postId, + party, + approved: body.action === 'approve', + actorId: user.id, + actorEmail: user.email || null, + note: body.note ? String(body.note) : null, + }); + const approvals = await listApprovals(ctx.tenantId, 'calendar_post', [postId]); + return NextResponse.json({ + ok: true, + dual: dualStatus(approvals[postId] || []), + party, + }); + } + + if (body.action === 'comment') { + const text = String(body.body || '').trim(); + if (!text) { + return NextResponse.json({ error: 'Comentario vacío' }, { status: 400 }); + } + const row = await insertComment({ + tenantId: ctx.tenantId, + resourceType: 'calendar_post', + resourceId: postId, + body: text.slice(0, 4000), + authorId: user.id, + authorEmail: user.email || null, + authorParty: party, + }); + return NextResponse.json({ ok: true, comment: row }); + } + + return NextResponse.json({ error: 'action inválida' }, { status: 400 }); +} diff --git a/src/app/dashboard/calendario/CalendarClient.tsx b/src/app/dashboard/calendario/CalendarClient.tsx index 023f765..89938c6 100644 --- a/src/app/dashboard/calendario/CalendarClient.tsx +++ b/src/app/dashboard/calendario/CalendarClient.tsx @@ -1,29 +1,26 @@ 'use client'; -import React, { useState } from 'react'; -import { - CalendarDays, - Instagram, - Linkedin, - Facebook, - CheckCircle2, - Clock, - FileText, - Layers, - Tag, - ExternalLink, - X, - Copy, +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + CalendarDays, + Instagram, + Linkedin, + Facebook, + CheckCircle2, + Copy, Check, Eye, - Sparkles + X, + MessageSquare, + ShieldCheck, + Music2, } from 'lucide-react'; -interface Post { +export type CalendarPost = { id: string; date: string; time: string; - platform: 'instagram' | 'linkedin' | 'facebook'; + platform: string; format: string; pillar: string; title: string; @@ -31,378 +28,494 @@ interface Post { body: string; cta: string; hashtags: string; - status: 'approved' | 'scheduled' | 'draft'; + status: 'approved' | 'scheduled' | 'draft' | string; + objective_metric?: string; +}; + +type Dual = { + agency: boolean; + client: boolean; + both: boolean; + label: string; +}; + +type Comment = { + id: string; + body: string; + author_email?: string | null; + author_party: 'agency' | 'client'; + created_at: string; +}; + +const PLATFORM_META: Record< + string, + { label: string; color: string; Icon: React.ComponentType<{ className?: string }> } +> = { + instagram: { + label: 'Instagram', + color: 'from-pink-500 to-purple-600', + Icon: Instagram, + }, + linkedin: { label: 'LinkedIn', color: 'bg-[#0A66C2]', Icon: Linkedin }, + facebook: { label: 'Facebook', color: 'bg-[#1877F2]', Icon: Facebook }, + tiktok: { label: 'TikTok', color: 'bg-gray-100 text-black', Icon: Music2 }, +}; + +function platformMeta(p: string) { + return ( + PLATFORM_META[p] || { + label: p.charAt(0).toUpperCase() + p.slice(1), + color: 'bg-gray-700', + Icon: CalendarDays, + } + ); } -interface CalendarClientProps { - initialPosts: Post[]; +function DualBadge({ dual }: { dual?: Dual }) { + if (!dual) { + return ( + + Sin checks + + ); + } + if (dual.both) { + return ( + + Doble OK + + ); + } + return ( + + {dual.label} + + ); +} + +export default function CalendarClient({ + initialPosts, + calendarTitle, + canCollaborate = true, +}: { + initialPosts: CalendarPost[]; calendarTitle: string; -} + canCollaborate?: boolean; +}) { + const platforms = useMemo(() => { + const set = new Set(initialPosts.map((p) => p.platform).filter(Boolean)); + return ['all', ...[...set].sort()]; + }, [initialPosts]); -export default function CalendarClient({ initialPosts, calendarTitle }: CalendarClientProps) { - const [platformFilter, setPlatformFilter] = useState<'all' | 'instagram' | 'linkedin' | 'facebook'>('all'); - const [statusFilter, setStatusFilter] = useState<'all' | 'approved' | 'scheduled' | 'draft'>('all'); - const [selectedPost, setSelectedPost] = useState(null); + const [platformFilter, setPlatformFilter] = useState('all'); + const [statusFilter, setStatusFilter] = useState<'all' | 'approved' | 'scheduled' | 'draft'>( + 'all' + ); + const [view, setView] = useState<'month' | 'list'>('month'); + const [selectedPost, setSelectedPost] = useState(null); const [copied, setCopied] = useState(false); + const [dualMap, setDualMap] = useState>({}); + const [comments, setComments] = useState([]); + const [commentText, setCommentText] = useState(''); + const [party, setParty] = useState<'agency' | 'client'>('client'); + const [busy, setBusy] = useState(false); + const [collabErr, setCollabErr] = useState(null); + + const ids = useMemo(() => initialPosts.map((p) => p.id).filter(Boolean), [initialPosts]); + + const refreshDual = useCallback(async () => { + if (!canCollaborate || !ids.length) return; + const r = await fetch(`/api/calendar/collab?ids=${ids.map(encodeURIComponent).join(',')}`); + const j = await r.json(); + if (r.ok) { + setDualMap(j.dual || {}); + if (j.party) setParty(j.party); + } + }, [canCollaborate, ids]); + + useEffect(() => { + refreshDual(); + }, [refreshDual]); + + useEffect(() => { + if (!selectedPost || !canCollaborate) { + setComments([]); + return; + } + (async () => { + const r = await fetch( + `/api/calendar/collab?ids=${encodeURIComponent(selectedPost.id)}&resourceId=${encodeURIComponent(selectedPost.id)}` + ); + const j = await r.json(); + if (r.ok) { + setComments(j.comments || []); + if (j.dual?.[selectedPost.id]) { + setDualMap((prev) => ({ ...prev, [selectedPost.id]: j.dual[selectedPost.id] })); + } + } + })(); + }, [selectedPost, canCollaborate]); - // Filter posts const filteredPosts = initialPosts.filter((p) => { const matchPlatform = platformFilter === 'all' || p.platform === platformFilter; const matchStatus = statusFilter === 'all' || p.status === statusFilter; return matchPlatform && matchStatus; }); - // KPI counters const totalPosts = initialPosts.length; - const instagramCount = initialPosts.filter((p) => p.platform === 'instagram').length; - const linkedinCount = initialPosts.filter((p) => p.platform === 'linkedin').length; - const facebookCount = initialPosts.filter((p) => p.platform === 'facebook').length; - const approvedCount = initialPosts.filter((p) => p.status === 'approved').length; + const approvedCount = initialPosts.filter( + (p) => dualMap[p.id]?.both || p.status === 'approved' + ).length; - const copyFullCopy = (post: Post) => { + const byDay = useMemo(() => { + const map = new Map(); + for (const p of filteredPosts) { + const key = (p.date || '').slice(0, 10); + if (!key) continue; + const list = map.get(key) || []; + list.push(p); + map.set(key, list); + } + return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0])); + }, [filteredPosts]); + + const copyFullCopy = (post: CalendarPost) => { const text = `${post.hook}\n\n${post.body}\n\n${post.cta}\n\n${post.hashtags}`; navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 2000); }; - const getPlatformBadge = (platform: string) => { - switch (platform) { - case 'instagram': - return ( - - Instagram - - ); - case 'linkedin': - return ( - - LinkedIn - - ); - case 'facebook': - return ( - - Facebook - - ); - default: - return null; + async function approve(action: 'approve' | 'revoke') { + if (!selectedPost) return; + setBusy(true); + setCollabErr(null); + try { + const r = await fetch('/api/calendar/collab', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action, postId: selectedPost.id }), + }); + const j = await r.json(); + if (!r.ok) throw new Error(j.error || 'Error'); + setDualMap((prev) => ({ ...prev, [selectedPost.id]: j.dual })); + } catch (e) { + setCollabErr(e instanceof Error ? e.message : 'Error'); + } finally { + setBusy(false); } - }; + } - const getStatusBadge = (status: string) => { - switch (status) { - case 'approved': - return ( - - Aprobado - - ); - case 'scheduled': - return ( - - Programado - - ); - default: - return ( - - En Revisión - - ); + async function sendComment() { + if (!selectedPost || !commentText.trim()) return; + setBusy(true); + setCollabErr(null); + try { + const r = await fetch('/api/calendar/collab', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'comment', + postId: selectedPost.id, + body: commentText.trim(), + }), + }); + const j = await r.json(); + if (!r.ok) throw new Error(j.error || 'Error'); + setComments((prev) => [...prev, j.comment]); + setCommentText(''); + } catch (e) { + setCollabErr(e instanceof Error ? e.message : 'Error'); + } finally { + setBusy(false); } - }; + } + + const myApproved = + selectedPost && dualMap[selectedPost.id] + ? party === 'agency' + ? dualMap[selectedPost.id].agency + : dualMap[selectedPost.id].client + : false; return (
- {/* KPI Top Cards */} -
-
-
- Total Posts - -
-

{totalPosts}

-

Plan del mes

-
- -
-
- Instagram - -
-

{instagramCount}

-

Carruseles & Reels

-
- -
-
- LinkedIn - -
-

{linkedinCount}

-

Artículos & Casos B2B

-
- -
-
- Facebook - -
-

{facebookCount}

-

Comunidad & Ads

-
- -
-
- Listos / Aprobados - -
-

{approvedCount}

-

{totalPosts > 0 ? Math.round((approvedCount / totalPosts) * 100) : 0}% validado

+
+

+ {calendarTitle} · privado (sin publicación pública) · doble check + agencia/cliente +

+
+ {( + [ + ['month', 'Mes'], + ['list', 'Lista'], + ] as const + ).map(([k, label]) => ( + + ))}
- {/* Filter Bar */} -
- {/* Platform Tabs */} -
- - - - +
+
+
+ Total piezas + +
+

{totalPosts}

+ {platforms + .filter((p) => p !== 'all') + .slice(0, 2) + .map((p) => { + const meta = platformMeta(p); + const n = initialPosts.filter((x) => x.platform === p).length; + return ( +
+
+ {meta.label} + +
+

{n}

+
+ ); + })} +
+
+ Doble OK + +
+

{approvedCount}

+

+ {totalPosts > 0 ? Math.round((approvedCount / totalPosts) * 100) : 0}% +

+
+
- {/* Status Dropdown Filter */} + {/* Unified + per-network tabs */} +
+
+ {platforms.map((p) => { + const count = + p === 'all' + ? totalPosts + : initialPosts.filter((x) => x.platform === p).length; + const meta = p === 'all' ? null : platformMeta(p); + const active = platformFilter === p; + return ( + + ); + })} +
Estado:
- {/* Posts Grid */} -
- {filteredPosts.length === 0 ? ( -
- -

No hay publicaciones con este filtro.

-

Prueba a seleccionar «Todos los canales» o «Todos los estados».

-
- ) : ( - filteredPosts.map((post) => ( -
-
- {/* Header: Platform & Status */} -
- {getPlatformBadge(post.platform)} - {getStatusBadge(post.status)} -
- - {/* Date & Time */} -
- - {new Date(post.date).toLocaleDateString('es-ES', { weekday: 'short', day: 'numeric', month: 'short' })} · {post.time}h -
- - {/* Title */} -

- {post.title} -

- - {/* Pillars & Format Badges */} -
- - {post.format} - - - {post.pillar} - -
- - {/* Hook preview */} -
- «{post.hook}» -
- - {/* Truncated Body */} -

- {post.body} + {view === 'month' ? ( +

+ {byDay.length === 0 ? ( + + ) : ( + byDay.map(([day, posts]) => ( +
+

+ {new Date(day).toLocaleDateString('es-ES', { + weekday: 'long', + day: 'numeric', + month: 'long', + })}

+
+ {posts.map((post) => ( + setSelectedPost(post)} + onCopy={() => copyFullCopy(post)} + /> + ))} +
+ )) + )} +
+ ) : ( +
+ {filteredPosts.length === 0 ? ( + + ) : ( + filteredPosts.map((post) => ( + setSelectedPost(post)} + onCopy={() => copyFullCopy(post)} + /> + )) + )} +
+ )} - {/* Actions Footer */} -
- - - -
-
- )) - )} -
- - {/* Detail Modal */} {selectedPost && (
-
- {/* Modal Header */} +
-
- {getPlatformBadge(selectedPost.platform)} -
-

{selectedPost.title}

-

- Programado para: {new Date(selectedPost.date).toLocaleDateString('es-ES', { weekday: 'long', day: 'numeric', month: 'long' })} a las {selectedPost.time}h -

-
+
+

{selectedPost.title}

+

+ {selectedPost.platform} · {selectedPost.date} · {selectedPost.time}h +

- {/* Modal Body */}
-
-
- Pilar: - - {selectedPost.pillar} - - Formato: - - {selectedPost.format} - -
- {getStatusBadge(selectedPost.status)} +
+ + + Tú: {party === 'agency' ? 'Agencia' : 'Cliente'} +
- {/* Hook */} -
- -
- {selectedPost.hook} -
-
+ + {selectedPost.hook} + + {selectedPost.body} + + {selectedPost.cta} + + + {selectedPost.hashtags} + - {/* Body */} -
- -
- {selectedPost.body} + {canCollaborate && ( +
+
+ + Comentarios +
+
    + {comments.length === 0 && ( +
  • Sin comentarios aún.
  • + )} + {comments.map((c) => ( +
  • +
    + + {c.author_party === 'agency' ? 'Agencia' : 'Cliente'} + {c.author_email ? ` · ${c.author_email}` : ''} + + + {new Date(c.created_at).toLocaleString('es-ES', { + day: '2-digit', + month: 'short', + hour: '2-digit', + minute: '2-digit', + })} + +
    +

    {c.body}

    +
  • + ))} +
+
+ setCommentText(e.target.value)} + placeholder="Escribe un comentario…" + className="flex-1 rounded-lg bg-gray-900 border border-gray-700 px-3 py-2 text-xs text-white placeholder:text-gray-600 focus:outline-none focus:border-brand-500/50" + /> + +
+ {collabErr && ( +

{collabErr}

+ )}
-
- - {/* CTA */} -
- -
- 👉 {selectedPost.cta} -
-
- - {/* Hashtags */} -
- -
- {selectedPost.hashtags} -
-
+ )}
- {/* Modal Footer */} -
- Generado por Agente #07 Contenido -
+
+ + {canCollaborate && ( - -
+ )}
@@ -410,3 +523,89 @@ export default function CalendarClient({ initialPosts, calendarTitle }: Calendar
); } + +function EmptyState() { + return ( +
+ +

Sin piezas con este filtro.

+
+ ); +} + +function Block({ + label, + children, + className = '', +}: { + label: string; + children: React.ReactNode; + className?: string; +}) { + return ( +
+ +
+ {children} +
+
+ ); +} + +function PostCard({ + post, + dual, + onOpen, + onCopy, +}: { + post: CalendarPost; + dual?: Dual; + onOpen: () => void; + onCopy: () => void; +}) { + const meta = platformMeta(post.platform); + return ( +
+
+
+ + {meta.label} + + +
+

+ {post.time ? `${post.time}h` : '—'} · {post.format} +

+

{post.title}

+

«{post.hook}»

+
+
+ + +
+
+ ); +} diff --git a/src/app/dashboard/calendario/page.tsx b/src/app/dashboard/calendario/page.tsx index 7b2e488..2d3ed08 100644 --- a/src/app/dashboard/calendario/page.tsx +++ b/src/app/dashboard/calendario/page.tsx @@ -59,9 +59,9 @@ export default async function CalendarioPage() { Calendario editorial — {ctx.label}

- Plan de publicaciones (IG / FB / LinkedIn) guardado en{' '} - documents con type=calendar, generado por - agentes / ops y ligado al tenant. No es el calendario de Google Ads. + Plan RRSS privado por cliente: vista unificada + pestaña por red. + Cliente y agencia pueden leer, comentar y dar OK (doble check). Sin + publicación pública.

diff --git a/src/app/dashboard/kpis/canal/[channel]/page.tsx b/src/app/dashboard/kpis/canal/[channel]/page.tsx new file mode 100644 index 0000000..3c5ea89 --- /dev/null +++ b/src/app/dashboard/kpis/canal/[channel]/page.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import Link from 'next/link'; +import { createClient } from '@/lib/supabase/server'; +import { redirect } from 'next/navigation'; +import { resolveActiveTenant, restGet } from '@/lib/tenant-context'; +import { isAgencyRole, userRole } from '@/lib/agency'; +import { + KpiWeeklyRow, + buildChannelSeries, + formatEur, + formatNum, +} from '@/lib/metrics'; +import { ArrowLeft } from 'lucide-react'; + +export const dynamic = 'force-dynamic'; + +export default async function CanalDashPage({ + params, +}: { + params: Promise<{ channel: string }>; +}) { + const { channel: raw } = await params; + const channel = decodeURIComponent(raw).toLowerCase(); + + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) redirect('/login'); + + const ctx = await resolveActiveTenant(user); + const agency = isAgencyRole(userRole(user)); + + if (ctx.portfolioWide) { + return ( +

+ Selecciona un cliente para ver el dash de canal. +

+ ); + } + + let weekly: KpiWeeklyRow[] = []; + if (agency && ctx.viaView && ctx.tenantId) { + const tid = encodeURIComponent(ctx.tenantId); + const w = await restGet( + `/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.asc` + ); + weekly = w.data || []; + } else { + const { data } = await supabase + .from('kpi_weekly') + .select('*') + .order('week', { ascending: true }); + weekly = (data as KpiWeeklyRow[]) || []; + } + + const series = buildChannelSeries(weekly); + const points = series.points.filter((p) => p.channel === channel); + const spend = points.reduce((a, p) => a + p.spend, 0); + const leads = points.reduce((a, p) => a + p.leads, 0); + const revenue = points.reduce((a, p) => a + p.revenue, 0); + const cpl = leads > 0 ? spend / leads : 0; + const roas = spend > 0 ? revenue / spend : 0; + + return ( +
+
+ + Volver a métricas + +

+ Canal · {channel} +

+

+ {ctx.label} · dash secundario (paid). CRM/GEO se añadirán aquí en v2. +

+
+ +
+ {[ + { label: 'Inversión', value: formatEur(spend) }, + { label: 'Leads', value: formatNum(leads) }, + { label: 'CPL', value: cpl ? `${formatNum(cpl)} €` : '—' }, + { label: 'ROAS', value: roas ? `${formatNum(roas)}x` : '—' }, + ].map((s) => ( +
+

+ {s.label} +

+

{s.value}

+
+ ))} +
+ +
+

+ Semanas ({points.length}) +

+ {points.length === 0 ? ( +

Sin datos para este canal.

+ ) : ( +
    + {points.map((p) => ( +
  • + {p.week} + + {formatEur(p.spend)} · {formatNum(p.leads)} leads · CPL{' '} + {p.cpl ? `${formatNum(p.cpl)} €` : '—'} + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/src/app/dashboard/kpis/page.tsx b/src/app/dashboard/kpis/page.tsx index accfb11..d3706b5 100644 --- a/src/app/dashboard/kpis/page.tsx +++ b/src/app/dashboard/kpis/page.tsx @@ -6,6 +6,7 @@ import { resolveActiveTenant, restGet } from '@/lib/tenant-context'; import { AdDailyRow, KpiWeeklyRow, + buildChannelSeries, buildComparisons, sumDaily, sumWeekly, @@ -70,6 +71,7 @@ export default async function KpisPage() { : sumWeekly(weekly); // fallback: all available weeks if current month empty const dailyTotals = sumDaily(daily); const sparse = weekly.length < 4; + const channelSeries = buildChannelSeries(weekly); return ( ); } diff --git a/src/components/AssetsLibrary.tsx b/src/components/AssetsLibrary.tsx index 60b7837..dd42e0d 100644 --- a/src/components/AssetsLibrary.tsx +++ b/src/components/AssetsLibrary.tsx @@ -23,6 +23,13 @@ type Asset = { created_at?: string; }; +type Dual = { + agency: boolean; + client: boolean; + both: boolean; + label: string; +}; + type DriveFile = { id: string; name: string; @@ -39,6 +46,32 @@ function formatBytes(n?: number | null) { return `${(n / (1024 * 1024)).toFixed(1)} MB`; } +function AssetImagePreview({ assetId, alt }: { assetId: string; alt: string }) { + const [url, setUrl] = useState(null); + useEffect(() => { + let cancelled = false; + (async () => { + const r = await fetch(`/api/assets/download?id=${encodeURIComponent(assetId)}`); + const j = await r.json(); + if (!cancelled && r.ok && j.url) setUrl(j.url); + })(); + return () => { + cancelled = true; + }; + }, [assetId]); + if (!url) { + return

Cargando preview…

; + } + return ( + // eslint-disable-next-line @next/next/no-img-element + {alt} + ); +} + function iconHint(mime?: string | null) { if (!mime) return 'FILE'; if (mime.startsWith('image/')) return 'IMG'; @@ -61,12 +94,30 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string }) const [uploading, setUploading] = useState(false); const [err, setErr] = useState(null); const [drag, setDrag] = useState(false); + const [dualMap, setDualMap] = useState>({}); + const [detailId, setDetailId] = useState(null); + const [comments, setComments] = useState< + { id: string; body: string; author_party: string; author_email?: string; created_at: string }[] + >([]); + const [commentText, setCommentText] = useState(''); + const [party, setParty] = useState<'agency' | 'client'>('client'); + const [collabBusy, setCollabBusy] = useState(false); const loadPortal = useCallback(async () => { const res = await fetch('/api/assets'); const data = await res.json(); - setAssets(data.assets || []); + const list: Asset[] = data.assets || []; + setAssets(list); if (data.warning) setWarning(data.warning); + if (list.length) { + const ids = list.map((a) => a.id).join(','); + const c = await fetch(`/api/assets/collab?ids=${ids}`); + const cj = await c.json(); + if (c.ok) { + setDualMap(cj.dual || {}); + if (cj.party) setParty(cj.party); + } + } }, []); const loadDrive = useCallback(async () => { @@ -362,9 +413,34 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string }) : ''}

- - Validado - + +
+
+ {(() => { + const a = assets.find((x) => x.id === detailId); + const isImg = a?.mime_type?.startsWith('image/'); + return isImg ? ( + + ) : ( +

+ Preview disponible para imágenes. Usa descargar para el resto. +

+ ); + })()} +
    + {comments.length === 0 && ( +
  • Sin comentarios.
  • + )} + {comments.map((c) => ( +
  • +

    + {c.author_party === 'agency' ? 'Agencia' : 'Cliente'} + {c.author_email ? ` · ${c.author_email}` : ''} +

    +

    {c.body}

    +
  • + ))} +
+
+ setCommentText(e.target.value)} + placeholder="Comentario…" + className="flex-1 rounded-lg bg-gray-900 border border-gray-700 px-3 py-2 text-xs text-white" + /> + +
+
+
+ +
+
+
+ )}
); } diff --git a/src/components/MetricsDashboard.tsx b/src/components/MetricsDashboard.tsx index b33f679..c6edd99 100644 --- a/src/components/MetricsDashboard.tsx +++ b/src/components/MetricsDashboard.tsx @@ -1,4 +1,7 @@ -import React from 'react'; +'use client'; + +import React, { useMemo, useState } from 'react'; +import Link from 'next/link'; import { ArrowDownRight, ArrowUpRight, @@ -9,13 +12,17 @@ import { Users, MousePointerClick, Eye, + CalendarRange, } from 'lucide-react'; import { + ChannelWeekPoint, Delta, MetricTotals, formatEur, formatNum, formatPct, + sumWeekly, + KpiWeeklyRow, } from '@/lib/metrics'; type Card = { @@ -24,16 +31,12 @@ type Card = { hint: string; format: (v: number) => string; icon: React.ReactNode; - invert?: boolean; // lower is better + invert?: boolean; }; -function DeltaTriple({ - delta, - invert, -}: { - delta: Delta; - invert?: boolean; -}) { +type RangeKey = '7d' | '30d' | 'month' | 'all'; + +function DeltaTriple({ delta, invert }: { delta: Delta; invert?: boolean }) { const cells: { label: string; v: number | null }[] = [ { label: 'WoW', v: delta.wow }, { label: 'MoM', v: delta.mom }, @@ -43,11 +46,7 @@ function DeltaTriple({
{cells.map((c) => { const good = - c.v === null - ? null - : invert - ? c.v < 0 - : c.v > 0; + c.v === null ? null : invert ? c.v < 0 : c.v > 0; const color = c.v === null ? 'text-gray-500' @@ -83,6 +82,29 @@ function DeltaTriple({ ); } +function filterWeeklyByRange( + rows: KpiWeeklyRow[], + range: RangeKey +): KpiWeeklyRow[] { + if (range === 'all') return rows; + const now = new Date(); + let from: Date; + if (range === '7d') { + from = new Date(now); + from.setUTCDate(from.getUTCDate() - 7); + } else if (range === '30d') { + from = new Date(now); + from.setUTCDate(from.getUTCDate() - 30); + } else { + from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); + } + return rows.filter((r) => { + if (!r.week) return false; + const d = new Date(r.week); + return !Number.isNaN(d.getTime()) && d >= from; + }); +} + export default function MetricsDashboard({ label, portfolioWide, @@ -91,6 +113,8 @@ export default function MetricsDashboard({ deltas, daily, sparse, + weeklyRows = [], + channelPoints = [], }: { label: string; portfolioWide: boolean; @@ -104,26 +128,85 @@ export default function MetricsDashboard({ clicks: number; }; sparse: boolean; + weeklyRows?: KpiWeeklyRow[]; + channelPoints?: ChannelWeekPoint[]; }) { + const [range, setRange] = useState('month'); + + const ranged = useMemo( + () => filterWeeklyByRange(weeklyRows, range), + [weeklyRows, range] + ); + const periodTotals = useMemo( + () => (ranged.length ? sumWeekly(ranged) : month), + [ranged, month] + ); + + const channelRows = useMemo(() => { + const map = new Map< + string, + { spend: number; leads: number; deals: number; revenue: number } + >(); + const src = + range === 'all' + ? channelPoints + : channelPoints.filter((p) => { + const d = new Date(p.week); + if (Number.isNaN(d.getTime())) return false; + if (range === 'month') { + const now = new Date(); + return ( + d.getUTCFullYear() === now.getUTCFullYear() && + d.getUTCMonth() === now.getUTCMonth() + ); + } + const now = new Date(); + const from = new Date(now); + from.setUTCDate(from.getUTCDate() - (range === '7d' ? 7 : 30)); + return d >= from; + }); + for (const p of src) { + const cur = map.get(p.channel) || { + spend: 0, + leads: 0, + deals: 0, + revenue: 0, + }; + cur.spend += p.spend; + cur.leads += p.leads; + cur.deals += p.deals; + cur.revenue += p.revenue; + map.set(p.channel, cur); + } + return [...map.entries()] + .map(([channel, v]) => ({ + channel, + ...v, + cpl: v.leads > 0 ? v.spend / v.leads : 0, + roas: v.spend > 0 ? v.revenue / v.spend : 0, + })) + .sort((a, b) => b.spend - a.spend); + }, [channelPoints, range]); + const cards: Card[] = [ { key: 'spend', label: 'Inversión ads', - hint: 'Mes en curso (kpi_weekly)', + hint: 'Paid del periodo', format: formatEur, icon: , }, { key: 'leads', label: 'Leads', - hint: 'Cohorte · mes', + hint: 'Captación periodo', format: formatNum, icon: , }, { key: 'cpl', label: 'CPL', - hint: 'Coste por lead · mes', + hint: 'Coste por lead', format: (v) => `${formatNum(v)} €`, icon: , invert: true, @@ -131,7 +214,7 @@ export default function MetricsDashboard({ { key: 'cac', label: 'CAC cohorte', - hint: 'Spend / clientes ganados', + hint: 'Paid / cierres (parcial v1)', format: (v) => `${formatNum(v)} €`, icon: , invert: true, @@ -157,28 +240,81 @@ export default function MetricsDashboard({

- Dashboard de métricas + North Star · métricas

{label} {portfolioWide - ? ' · agrega todos los tenants (vista agencia)' - : ' · zoom al cliente del selector'} - . Comparativas WoW / MoM / YoY sobre kpi_weekly. + ? ' · portafolio' + : ' · zoom cliente'} + . Paid unificado ahora; CRM/GEO en siguientes fases.

-
- Semana actual spend {formatEur(week.spend)} · leads {formatNum(week.leads)} +
+ +
+ {( + [ + ['7d', '7d'], + ['30d', '30d'], + ['month', 'Mes'], + ['all', 'Todo'], + ] as [RangeKey, string][] + ).map(([k, lbl]) => ( + + ))} +
{sparse && (

- Histórico limitado en warehouse: si WoW/MoM/YoY salen «—», falta - pipeline Windsor/n8n o semanas previas / año anterior. + Histórico limitado: si faltan deltas o canales, el warehouse aún no + tiene semanas suficientes.

)} + {/* North Star strip */} +
+ {[ + { label: 'Inversión periodo', value: formatEur(periodTotals.spend) }, + { label: 'Leads', value: formatNum(periodTotals.leads) }, + { + label: 'CAC (paid)', + value: periodTotals.cac + ? `${formatNum(periodTotals.cac)} €` + : '—', + }, + { + label: 'ROAS cohorte', + value: periodTotals.roas + ? `${formatNum(periodTotals.roas)}x` + : '—', + }, + ].map((s) => ( +
+

+ {s.label} +

+

{s.value}

+
+ ))} +
+
{cards.map((c) => (

- {c.format(month[c.key])} + {c.format(periodTotals[c.key])}

{c.hint}

@@ -200,41 +336,112 @@ export default function MetricsDashboard({ ))}
+ {/* Channel matrix */} +
+
+

+ Matriz por canal +

+

+ Clic → dash secundario del canal +

+
+ {channelRows.length === 0 ? ( +

+ Sin series de canal en el periodo. Cuando Windsor cargue + `kpi_weekly.channel`, aparecerán aquí. +

+ ) : ( +
+ + + + + + + + + + + + + {channelRows.map((row) => ( + + + + + + + + + ))} + +
CanalInversiónLeadsCPLRevenueROAS
+ + {row.channel} + + + {formatEur(row.spend)} + + {formatNum(row.leads)} + + {row.cpl ? `${formatNum(row.cpl)} €` : '—'} + + {formatEur(row.revenue)} + + {row.roas ? `${formatNum(row.roas)}x` : '—'} +
+
+ )} +
+ {['GEO', 'CRM close %', 'LTV'].map((soon) => ( +
+ {soon} · fase 2 (stub) +
+ ))} +
+
+
- Ads · 30d (ad_daily) + + Ads · ventana diaria +
-

- {formatEur(daily.spend)} -

+

{formatEur(daily.spend)}

- {formatNum(daily.leads)} leads plataforma ·{' '} - {formatNum(daily.impressions)} impr. · {formatNum(daily.clicks)}{' '} - clicks + {formatNum(daily.leads)} leads · {formatNum(daily.impressions)} impr. + · {formatNum(daily.clicks)} clicks +

+

+ Semana actual: {formatEur(week.spend)} · {formatNum(week.leads)}{' '} + leads

- Cómo leer las deltas + Cómo leer

  • - WoW: semana actual vs - anterior + El selector de fechas filtra inversión/leads del periodo; WoW/MoM/YoY + siguen siendo comparativas de calendario.
  • - MoM: mes actual vs mes - previo -
  • -
  • - YoY: mismo mes vs hace - un año -
  • -
  • - En CPL/CAC, bajar es bueno (flecha verde si el % es negativo) + CAC unificado con fee/tools y cierre CRM llega en v2.
  • +
  • GEO / LTV aparecen como stubs hasta tener pipeline de datos.
diff --git a/src/lib/content-collab.ts b/src/lib/content-collab.ts new file mode 100644 index 0000000..2a1c79e --- /dev/null +++ b/src/lib/content-collab.ts @@ -0,0 +1,152 @@ +/** Dual agency/client approval + comments for calendar posts and assets. */ + +import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency'; + +export type ContentResourceType = 'calendar_post' | 'asset'; +export type Party = 'agency' | 'client'; + +export type ApprovalRow = { + party: Party; + approved: boolean; + actor_email?: string | null; + created_at?: string; + note?: string | null; +}; + +export type CommentRow = { + id: string; + body: string; + author_email?: string | null; + author_party: Party; + created_at: string; +}; + +export function partyFromUser(user: { + app_metadata?: Record; + user_metadata?: Record; +} | null): Party { + return isAgencyRole(userRole(user)) ? 'agency' : 'client'; +} + +export function dualStatus(approvals: ApprovalRow[]): { + agency: boolean; + client: boolean; + both: boolean; + label: string; +} { + const agency = approvals.some((a) => a.party === 'agency' && a.approved); + const client = approvals.some((a) => a.party === 'client' && a.approved); + const both = agency && client; + let label = 'Pendiente'; + if (both) label = 'Doble OK'; + else if (agency && !client) label = 'OK agencia · falta cliente'; + else if (client && !agency) label = 'OK cliente · falta agencia'; + return { agency, client, both, label }; +} + +export async function listApprovals( + tenantId: string, + resourceType: ContentResourceType, + resourceIds: string[] +): Promise> { + const headers = serviceHeaders(); + if (!headers || !resourceIds.length) return {}; + const ids = resourceIds.map(encodeURIComponent).join(','); + const url = + `${supabaseUrl()}/rest/v1/content_approvals?tenant_id=eq.${encodeURIComponent(tenantId)}` + + `&resource_type=eq.${resourceType}&resource_id=in.(${ids})&select=*`; + const r = await fetch(url, { headers, cache: 'no-store' }); + if (!r.ok) return {}; + const rows = (await r.json()) as (ApprovalRow & { resource_id: string })[]; + const map: Record = {}; + for (const row of rows) { + (map[row.resource_id] ||= []).push(row); + } + return map; +} + +export async function listComments( + tenantId: string, + resourceType: ContentResourceType, + resourceId: string +): Promise { + const headers = serviceHeaders(); + if (!headers) return []; + const url = + `${supabaseUrl()}/rest/v1/content_comments?tenant_id=eq.${encodeURIComponent(tenantId)}` + + `&resource_type=eq.${resourceType}&resource_id=eq.${encodeURIComponent(resourceId)}` + + `&select=*&order=created_at.asc`; + const r = await fetch(url, { headers, cache: 'no-store' }); + if (!r.ok) return []; + return (await r.json()) as CommentRow[]; +} + +export async function upsertApproval(opts: { + tenantId: string; + resourceType: ContentResourceType; + resourceId: string; + party: Party; + approved: boolean; + actorId?: string | null; + actorEmail?: string | null; + note?: string | null; +}): Promise { + const headers = serviceHeaders(); + if (!headers) throw new Error('no service role'); + const body = { + tenant_id: opts.tenantId, + resource_type: opts.resourceType, + resource_id: opts.resourceId, + party: opts.party, + approved: opts.approved, + actor_id: opts.actorId || null, + actor_email: opts.actorEmail || null, + note: opts.note || null, + }; + const r = await fetch( + `${supabaseUrl()}/rest/v1/content_approvals?on_conflict=tenant_id,resource_type,resource_id,party`, + { + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'resolution=merge-duplicates,return=minimal', + }, + body: JSON.stringify(body), + } + ); + if (!r.ok) throw new Error(await r.text()); +} + +export async function insertComment(opts: { + tenantId: string; + resourceType: ContentResourceType; + resourceId: string; + body: string; + authorId?: string | null; + authorEmail?: string | null; + authorParty: Party; +}): Promise { + const headers = serviceHeaders(); + if (!headers) throw new Error('no service role'); + const r = await fetch(`${supabaseUrl()}/rest/v1/content_comments`, { + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=representation', + }, + body: JSON.stringify({ + tenant_id: opts.tenantId, + resource_type: opts.resourceType, + resource_id: opts.resourceId, + body: opts.body, + author_id: opts.authorId || null, + author_email: opts.authorEmail || null, + author_party: opts.authorParty, + }), + }); + if (!r.ok) throw new Error(await r.text()); + const rows = await r.json(); + return Array.isArray(rows) ? rows[0] : rows; +}