feat: RRSS hub estilo Miia + Ad Tracker por cliente

Replica agua-miia/rrss (hub, archivo, octubre 2026 con 27 piezas) y añade Ad Tracker por tenant al estilo Brava (periodos, Meta+Google, CPL/clics, campañas).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-23 02:55:14 +02:00
parent a1a3c26c30
commit b4ee23874e
10 changed files with 2455 additions and 38 deletions

View File

@@ -0,0 +1,663 @@
'use client';
import React, { useMemo, useState } from 'react';
import {
AD_TRACKER_PRESETS,
RangePreset,
normalizePlatform,
resolveAdRange,
} from '@/lib/ad-tracker-range';
import { AdDailyRow, formatEur, formatNum } from '@/lib/metrics';
type PlatformKpis = {
spend: number;
impressions: number;
clicks: number;
conversions: number;
ads: number;
ctr: number | null;
cpc: number | null;
cpl: number | null;
};
type DailyPoint = {
date: string;
metaSpend: number;
googleSpend: number;
metaClicks: number;
googleClicks: number;
metaConv: number;
googleConv: number;
};
type CampaignRow = {
name: string;
spend: number;
impressions: number;
clicks: number;
conversions: number;
cpl: number | null;
};
type Tab = 'resumen' | 'meta' | 'google';
type ChartMode = 'combined' | 'meta' | 'google';
function rates(input: Omit<PlatformKpis, 'ctr' | 'cpc' | 'cpl'>): PlatformKpis {
const { spend, impressions, clicks, conversions, ads } = input;
return {
spend,
impressions,
clicks,
conversions,
ads,
ctr: impressions > 0 ? (100 * clicks) / impressions : null,
cpc: clicks > 0 ? spend / clicks : null,
cpl: conversions > 0 ? spend / conversions : null,
};
}
function convOf(r: AdDailyRow): number {
return Number(r.conversions ?? r.platform_leads ?? 0) || 0;
}
function campaignName(r: AdDailyRow): string {
return r.campaign_name || r.campaign || r.ad_name || 'Sin nombre';
}
function inRange(date: string | undefined, since: string, until: string) {
if (!date) return false;
const d = date.slice(0, 10);
return d >= since && d <= until;
}
function buildSeries(rows: AdDailyRow[]): DailyPoint[] {
const map = new Map<string, DailyPoint>();
for (const r of rows) {
const d = (r.date || '').slice(0, 10);
if (!d) continue;
const cur =
map.get(d) ||
({
date: d,
metaSpend: 0,
googleSpend: 0,
metaClicks: 0,
googleClicks: 0,
metaConv: 0,
googleConv: 0,
} satisfies DailyPoint);
const plat = normalizePlatform(r.platform);
const spend = Number(r.spend || 0);
const clicks = Number(r.clicks || 0);
const conv = convOf(r);
if (plat === 'meta') {
cur.metaSpend += spend;
cur.metaClicks += clicks;
cur.metaConv += conv;
} else if (plat === 'google') {
cur.googleSpend += spend;
cur.googleClicks += clicks;
cur.googleConv += conv;
} else {
// unknown → split weight to meta bucket for visibility
cur.metaSpend += spend;
cur.metaClicks += clicks;
cur.metaConv += conv;
}
map.set(d, cur);
}
return [...map.values()].sort((a, b) => a.date.localeCompare(b.date));
}
function aggregate(rows: AdDailyRow[]): PlatformKpis {
const spend = rows.reduce((a, r) => a + Number(r.spend || 0), 0);
const impressions = rows.reduce((a, r) => a + Number(r.impressions || 0), 0);
const clicks = rows.reduce((a, r) => a + Number(r.clicks || 0), 0);
const conversions = rows.reduce((a, r) => a + convOf(r), 0);
const ads = new Set(
rows.map((r) => campaignName(r)).filter((n) => n && n !== 'Sin nombre')
).size;
return rates({ spend, impressions, clicks, conversions, ads });
}
function byCampaign(rows: AdDailyRow[]): CampaignRow[] {
const map = new Map<string, CampaignRow>();
for (const r of rows) {
const name = campaignName(r);
const cur =
map.get(name) ||
({
name,
spend: 0,
impressions: 0,
clicks: 0,
conversions: 0,
cpl: null,
} satisfies CampaignRow);
cur.spend += Number(r.spend || 0);
cur.impressions += Number(r.impressions || 0);
cur.clicks += Number(r.clicks || 0);
cur.conversions += convOf(r);
map.set(name, cur);
}
return [...map.values()]
.map((c) => ({
...c,
cpl: c.conversions > 0 ? c.spend / c.conversions : null,
}))
.sort((a, b) => b.spend - a.spend);
}
function SparkBars({
data,
mode,
metric,
}: {
data: DailyPoint[];
mode: ChartMode;
metric: 'cpl' | 'spend';
}) {
if (!data.length) {
return (
<p className="text-sm text-gray-500 py-10 text-center">
Sin serie diaria para este rango
</p>
);
}
const values = data.map((d) => {
const spend =
mode === 'meta'
? d.metaSpend
: mode === 'google'
? d.googleSpend
: d.metaSpend + d.googleSpend;
const conv =
mode === 'meta'
? d.metaConv
: mode === 'google'
? d.googleConv
: d.metaConv + d.googleConv;
const clicks =
mode === 'meta'
? d.metaClicks
: mode === 'google'
? d.googleClicks
: d.metaClicks + d.googleClicks;
if (metric === 'cpl') {
return { bar: conv > 0 ? spend / conv : 0, line: conv, spend, clicks };
}
return { bar: spend, line: clicks, spend, clicks };
});
const maxBar = Math.max(...values.map((v) => v.bar), 1);
const maxLine = Math.max(...values.map((v) => v.line), 1);
return (
<div className="space-y-2">
<div className="flex items-end gap-1 h-40 px-1">
{values.map((v, i) => (
<div
key={data[i].date}
className="flex-1 flex flex-col items-center justify-end gap-1 min-w-0 h-full"
title={`${data[i].date}: ${
metric === 'cpl'
? `CPL ${v.bar ? formatEur(v.bar) : '—'} · ${formatNum(v.line)} conv`
: `${formatEur(v.bar)} · ${formatNum(v.line)} clics`
}`}
>
<div
className="w-full rounded-t bg-sky-500/70 relative"
style={{ height: `${Math.max(4, (100 * v.bar) / maxBar)}%` }}
>
<span
className="absolute -top-1 left-1/2 -translate-x-1/2 w-1.5 h-1.5 rounded-full bg-amber-300"
style={{
bottom: `${Math.max(0, (100 * v.line) / maxLine)}%`,
}}
/>
</div>
</div>
))}
</div>
<div className="flex justify-between text-[10px] text-gray-500 px-1">
<span>{data[0]?.date.slice(5)}</span>
<span>{data[data.length - 1]?.date.slice(5)}</span>
</div>
</div>
);
}
function KpiTile({
label,
value,
}: {
label: string;
value: string;
}) {
return (
<div className="rounded-xl border border-gray-800 bg-[#0D121F] px-3 py-3">
<p className="text-[10px] uppercase tracking-wider text-gray-500">{label}</p>
<p className="mt-1 text-xl font-bold text-white tabular-nums">{value}</p>
</div>
);
}
function CampaignTable({
title,
rows,
empty,
}: {
title: string;
rows: CampaignRow[];
empty: string;
}) {
return (
<section className="rounded-2xl border border-gray-800 bg-[#111827] overflow-hidden">
<div className="px-4 py-3 border-b border-gray-800">
<p className="text-[10px] uppercase tracking-wider text-gray-500">
Campañas activas
</p>
<h3 className="text-lg font-bold text-white">{title}</h3>
</div>
{rows.length === 0 ? (
<p className="px-4 py-8 text-sm text-gray-500">{empty}</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead className="text-[10px] uppercase tracking-wider text-gray-500 bg-black/20">
<tr>
<th className="px-4 py-2 font-medium">Campaña</th>
<th className="px-3 py-2 font-medium">Gasto</th>
<th className="px-3 py-2 font-medium">Clics</th>
<th className="px-3 py-2 font-medium">Conv.</th>
<th className="px-3 py-2 font-medium">CPL</th>
</tr>
</thead>
<tbody>
{rows.slice(0, 25).map((r) => (
<tr key={r.name} className="border-t border-gray-800/80">
<td className="px-4 py-2.5 text-gray-200 max-w-[220px] truncate">
{r.name}
</td>
<td className="px-3 py-2.5 text-white tabular-nums">
{formatEur(r.spend)}
</td>
<td className="px-3 py-2.5 text-gray-300 tabular-nums">
{formatNum(r.clicks)}
</td>
<td className="px-3 py-2.5 text-gray-300 tabular-nums">
{formatNum(r.conversions)}
</td>
<td className="px-3 py-2.5 text-gray-300 tabular-nums">
{r.cpl != null ? formatEur(r.cpl) : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}
export default function AdTrackerClient({
label,
tagline,
rows,
}: {
label: string;
tagline?: string;
rows: AdDailyRow[];
}) {
const [preset, setPreset] = useState<RangePreset>('last_7d');
const [tab, setTab] = useState<Tab>('resumen');
const [chartMode, setChartMode] = useState<ChartMode>('combined');
const range = useMemo(() => resolveAdRange(preset), [preset]);
const filtered = useMemo(
() => rows.filter((r) => inRange(r.date, range.since, range.until)),
[rows, range.since, range.until]
);
const metaRows = filtered.filter((r) => normalizePlatform(r.platform) === 'meta');
const googleRows = filtered.filter(
(r) => normalizePlatform(r.platform) === 'google'
);
// rows without platform count toward combined via meta bucket already in series
const otherRows = filtered.filter(
(r) => normalizePlatform(r.platform) === 'other'
);
const meta = aggregate([...metaRows, ...otherRows]);
const google = aggregate(googleRows);
const combined = rates({
spend: meta.spend + google.spend,
impressions: meta.impressions + google.impressions,
clicks: meta.clicks + google.clicks,
conversions: meta.conversions + google.conversions,
ads: meta.ads + google.ads,
});
const daily = buildSeries(filtered);
const metaCampaigns = byCampaign([...metaRows, ...otherRows]);
const googleCampaigns = byCampaign(googleRows);
const totalSpend = combined.spend || 1;
const metaShare = Math.min(100, (100 * meta.spend) / totalSpend);
const googleShare = Math.min(100, (100 * google.spend) / totalSpend);
const chips: { id: ChartMode; label: string }[] = [
{ id: 'combined', label: 'Conjunto' },
{ id: 'meta', label: 'Meta' },
{ id: 'google', label: 'Google' },
];
return (
<div className="space-y-6">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<p className="text-[11px] font-bold tracking-[0.16em] uppercase text-sky-400">
Ad tracker · {label}
</p>
<h2 className="text-2xl md:text-3xl font-extrabold text-white mt-1">
{tagline || 'Meta + Google Ads'}
</h2>
<p className="text-sm text-gray-400 mt-2 max-w-2xl">
Resumen agregado de Meta y Google Ads: gasto, clics, conversiones y
campañas activas en el periodo.
</p>
</div>
</div>
{/* Period presets */}
<div>
<p className="text-[10px] uppercase tracking-wider text-gray-500 mb-2">
Periodo
</p>
<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>
))}
<span className="px-3 py-1.5 rounded-lg text-xs font-medium border border-gray-700 text-gray-300">
{range.label}
</span>
</div>
</div>
{/* Tabs */}
<div className="flex flex-wrap gap-1.5">
{(
[
{ id: 'resumen', label: 'Resumen' },
{ id: 'meta', label: 'Meta Ads' },
{ id: 'google', label: 'Google Ads' },
] as const
).map((t) => (
<button
key={t.id}
type="button"
onClick={() => setTab(t.id)}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold ${
tab === t.id
? 'bg-white text-gray-900'
: 'bg-gray-800/70 text-gray-300'
}`}
>
{t.label}
</button>
))}
</div>
{tab === 'resumen' && (
<div className="space-y-6">
<section className="space-y-4">
<div>
<p className="text-[10px] uppercase tracking-wider text-gray-500">
Resumen de cuenta
</p>
<h3 className="text-xl font-bold text-white mt-1">
Meta + Google, juntos
</h3>
<p className="text-xs text-gray-500 mt-1 max-w-3xl">
Gasto, impresiones, clics y conversiones sumados. CTR, CPC y CPL
se recalculan sobre el total (no se suman). Ventana:{' '}
{range.since} → {range.until}.
</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<KpiTile label="Gasto" value={formatEur(combined.spend)} />
<KpiTile
label="Impresiones"
value={formatNum(combined.impressions)}
/>
<KpiTile label="Clics" value={formatNum(combined.clicks)} />
<KpiTile
label="Conversiones"
value={formatNum(combined.conversions)}
/>
<KpiTile
label="CTR"
value={
combined.ctr != null ? `${combined.ctr.toFixed(2)}%` : '—'
}
/>
<KpiTile
label="CPC"
value={combined.cpc != null ? formatEur(combined.cpc) : '—'}
/>
<KpiTile
label="CPL"
value={combined.cpl != null ? formatEur(combined.cpl) : '—'}
/>
<KpiTile label="Ads" value={formatNum(combined.ads)} />
</div>
<div className="h-2.5 rounded-full overflow-hidden border border-gray-800 bg-gray-900 flex">
<div
className="bg-[#7C66B8]"
style={{ width: `${metaShare}%` }}
title={`Meta ${metaShare.toFixed(0)}%`}
/>
<div
className="bg-sky-500"
style={{ width: `${googleShare}%` }}
title={`Google ${googleShare.toFixed(0)}%`}
/>
</div>
<div className="grid gap-3 md:grid-cols-2">
<button
type="button"
onClick={() => setTab('meta')}
className="rounded-xl border border-gray-800 bg-[#0D121F] p-4 text-left hover:border-[#7C66B8]/50"
>
<p className="text-[11px] uppercase tracking-wider text-[#a78bfa]">
Meta Ads
</p>
<p className="text-xl font-bold text-white mt-1">
{formatEur(meta.spend)}
</p>
<p className="text-xs text-gray-500 mt-1">
{formatNum(meta.ads)} ads · {formatNum(meta.clicks)} clics ·{' '}
{formatNum(meta.conversions)} conv. · Ver Meta →
</p>
</button>
<button
type="button"
onClick={() => setTab('google')}
className="rounded-xl border border-gray-800 bg-[#0D121F] p-4 text-left hover:border-sky-500/50"
>
<p className="text-[11px] uppercase tracking-wider text-sky-400">
Google Ads
</p>
<p className="text-xl font-bold text-white mt-1">
{formatEur(google.spend)}
</p>
<p className="text-xs text-gray-500 mt-1">
{formatNum(google.ads)} ads · {formatNum(google.clicks)} clics
· {formatNum(google.conversions)} conv. · Ver Google →
</p>
</button>
</div>
</section>
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-4 md:p-5">
<div className="flex flex-wrap items-start justify-between gap-3 mb-3">
<div>
<p className="text-[10px] uppercase tracking-wider text-gray-500">
Rendimiento diario
</p>
<h3 className="text-lg font-bold text-white">
Conversiones y CPL
</h3>
<p className="text-xs text-gray-500 mt-1">
Barras: coste por lead. Línea: conversiones. CPL{' '}
{combined.cpl != null ? formatEur(combined.cpl) : '—'}
</p>
</div>
<div className="flex flex-wrap gap-1.5">
{chips.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setChartMode(c.id)}
className={`px-3 py-1 rounded-full text-[11px] font-medium ${
chartMode === c.id
? 'bg-brand-500 text-white'
: 'border border-gray-700 text-gray-300'
}`}
>
{c.label}
</button>
))}
</div>
</div>
<SparkBars data={daily} mode={chartMode} metric="cpl" />
</section>
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-4 md:p-5">
<div className="flex flex-wrap items-start justify-between gap-3 mb-3">
<div>
<p className="text-[10px] uppercase tracking-wider text-gray-500">
Rendimiento diario
</p>
<h3 className="text-lg font-bold text-white">Coste y clics</h3>
<p className="text-xs text-gray-500 mt-1">
Barras: coste. Línea: clics. {formatEur(combined.spend)} ·{' '}
{formatNum(combined.clicks)} clics
</p>
</div>
<div className="flex flex-wrap gap-1.5">
{chips.map((c) => (
<button
key={`spend-${c.id}`}
type="button"
onClick={() => setChartMode(c.id)}
className={`px-3 py-1 rounded-full text-[11px] font-medium ${
chartMode === c.id
? 'bg-brand-500 text-white'
: 'border border-gray-700 text-gray-300'
}`}
>
{c.label}
</button>
))}
</div>
</div>
<SparkBars data={daily} mode={chartMode} metric="spend" />
</section>
<div className="grid gap-4 lg:grid-cols-2">
<CampaignTable
title="Meta Ads"
rows={metaCampaigns}
empty="Sin campañas Meta activas en este periodo"
/>
<CampaignTable
title="Google Ads"
rows={googleCampaigns}
empty="Sin campañas Google activas en este periodo"
/>
</div>
</div>
)}
{tab === 'meta' && (
<div className="space-y-6">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<KpiTile label="Gasto" value={formatEur(meta.spend)} />
<KpiTile label="Clics" value={formatNum(meta.clicks)} />
<KpiTile label="Leads" value={formatNum(meta.conversions)} />
<KpiTile
label="CPL"
value={meta.cpl != null ? formatEur(meta.cpl) : '—'}
/>
</div>
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-4">
<h3 className="text-lg font-bold text-white mb-3">
Conversiones y CPL · Meta
</h3>
<SparkBars data={daily} mode="meta" metric="cpl" />
</section>
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-4">
<h3 className="text-lg font-bold text-white mb-3">
Coste y clics · Meta
</h3>
<SparkBars data={daily} mode="meta" metric="spend" />
</section>
<CampaignTable
title="Meta Ads"
rows={metaCampaigns}
empty="Sin campañas Meta activas en este periodo"
/>
</div>
)}
{tab === 'google' && (
<div className="space-y-6">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<KpiTile label="Gasto" value={formatEur(google.spend)} />
<KpiTile label="Clics" value={formatNum(google.clicks)} />
<KpiTile label="Conv." value={formatNum(google.conversions)} />
<KpiTile
label="CPL"
value={google.cpl != null ? formatEur(google.cpl) : '—'}
/>
</div>
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-4">
<h3 className="text-lg font-bold text-white mb-3">
Conversiones y CPL · Google
</h3>
<SparkBars data={daily} mode="google" metric="cpl" />
</section>
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-4">
<h3 className="text-lg font-bold text-white mb-3">
Coste y clics · Google
</h3>
<SparkBars data={daily} mode="google" metric="spend" />
</section>
<CampaignTable
title="Google Ads"
rows={googleCampaigns}
empty="Sin campañas Google activas en este periodo"
/>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,630 @@
'use client';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import {
Check,
Copy,
Instagram,
Linkedin,
MessageSquare,
ShieldCheck,
X,
} from 'lucide-react';
export type RrssPiece = {
id: string;
date: string;
time?: string;
platform: string;
format?: string;
pillar?: string;
title: string;
hook?: string;
body?: string;
cta?: string;
hashtags?: string;
objective_metric?: string;
status?: string;
meta_line?: string;
kind?: string;
color?: string;
slides?: string[];
};
export type RrssMonthPlan = {
kind?: string;
month: string;
label: string;
hero: {
eyebrow?: string;
title: string;
lede: string;
chips?: string[];
};
reparto_posts?: {
pct: number;
color: string;
label: string;
detail: string;
objective: string;
}[];
reparto_stories?: {
pct: number;
color: string;
label: string;
detail: string;
objective: string;
}[];
legend?: { label: string; color: string; outline?: boolean }[];
pieces: RrssPiece[];
};
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 PILLAR_COLORS: Record<string, string> = {
educativo: '#2071cf',
serie: '#2071cf',
familia: '#E08E2E',
bienestar: '#E08E2E',
sostenibilidad: '#0F9D8F',
b2b: '#7C66B8',
hostelería: '#7C66B8',
prueba: '#8A93A6',
linkedin: '#7C66B8',
};
function colorFor(piece: RrssPiece): string {
if (piece.color) return piece.color;
if (piece.platform === 'linkedin') return '#7C66B8';
const hay = `${piece.pillar || ''} ${piece.meta_line || ''} ${piece.title}`.toLowerCase();
for (const [k, c] of Object.entries(PILLAR_COLORS)) {
if (hay.includes(k)) return c;
}
return '#2071cf';
}
function DualBadge({ dual }: { dual?: Dual }) {
if (!dual) {
return (
<span className="text-[10px] px-2 py-0.5 rounded-full border border-white/10 text-white/50">
Pendiente
</span>
);
}
if (dual.both) {
return (
<span className="inline-flex items-center gap-1 text-[10px] px-2 py-0.5 rounded-full bg-emerald-500/20 text-emerald-300 border border-emerald-500/30">
<ShieldCheck className="w-3 h-3" /> Doble OK
</span>
);
}
return (
<span className="text-[10px] px-2 py-0.5 rounded-full bg-amber-500/15 text-amber-200 border border-amber-500/25">
{dual.label}
</span>
);
}
export default function RrssMonthView({
plan,
tenantLabel,
canCollaborate = true,
initialPlatform = 'all',
}: {
plan: RrssMonthPlan;
tenantLabel: string;
canCollaborate?: boolean;
initialPlatform?: 'all' | string;
}) {
const pieces = plan.pieces || [];
const [platform, setPlatform] = useState<'all' | string>(initialPlatform);
const [selected, setSelected] = useState<RrssPiece | null>(null);
const [dualMap, setDualMap] = useState<Record<string, Dual>>({});
const [comments, setComments] = useState<Comment[]>([]);
const [commentText, setCommentText] = useState('');
const [party, setParty] = useState<'agency' | 'client'>('client');
const [busy, setBusy] = useState(false);
const [copied, setCopied] = useState(false);
const platforms = useMemo(() => {
const set = new Set(pieces.map((p) => p.platform));
return ['all', ...[...set].sort()];
}, [pieces]);
const filtered = pieces.filter(
(p) => platform === 'all' || p.platform === platform
);
const ids = pieces.map((p) => p.id);
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.join(',')]);
useEffect(() => {
refreshDual();
}, [refreshDual]);
useEffect(() => {
if (!selected || !canCollaborate) {
setComments([]);
return;
}
(async () => {
const r = await fetch(
`/api/calendar/collab?ids=${encodeURIComponent(selected.id)}&resourceId=${encodeURIComponent(selected.id)}`
);
const j = await r.json();
if (r.ok) {
setComments(j.comments || []);
if (j.dual?.[selected.id]) {
setDualMap((prev) => ({ ...prev, [selected.id]: j.dual[selected.id] }));
}
}
})();
}, [selected, canCollaborate]);
// Month calendar grid
const year = Number(plan.month.slice(0, 4));
const monthNum = Number(plan.month.slice(5, 7));
const first = new Date(Date.UTC(year, monthNum - 1, 1));
const daysInMonth = new Date(Date.UTC(year, monthNum, 0)).getUTCDate();
// Monday-first offset
const startPad = (first.getUTCDay() + 6) % 7;
const byDate = useMemo(() => {
const m = new Map<string, RrssPiece[]>();
for (const p of filtered) {
const k = p.date.slice(0, 10);
(m.get(k) || m.set(k, []).get(k)!).push(p);
}
return m;
}, [filtered]);
async function approve(action: 'approve' | 'revoke') {
if (!selected) return;
setBusy(true);
try {
const r = await fetch('/api/calendar/collab', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, postId: selected.id }),
});
const j = await r.json();
if (r.ok) setDualMap((prev) => ({ ...prev, [selected.id]: j.dual }));
} finally {
setBusy(false);
}
}
async function sendComment() {
if (!selected || !commentText.trim()) return;
setBusy(true);
try {
const r = await fetch('/api/calendar/collab', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'comment',
postId: selected.id,
body: commentText.trim(),
}),
});
const j = await r.json();
if (r.ok) {
setComments((prev) => [...prev, j.comment]);
setCommentText('');
}
} finally {
setBusy(false);
}
}
const myOk =
selected && dualMap[selected.id]
? party === 'agency'
? dualMap[selected.id].agency
: dualMap[selected.id].client
: false;
return (
<div className="space-y-6">
{/* Hub crumbs */}
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-gray-400">
<div>
<Link href="/dashboard/calendario" className="hover:text-white">
RRSS
</Link>
<span className="mx-1.5">/</span>
<Link href="/dashboard/calendario?view=archive" className="hover:text-white">
Archivo
</Link>
<span className="mx-1.5">/</span>
<span className="text-white">{plan.label}</span>
</div>
<span className="text-[11px] text-gray-500">
{tenantLabel} · privado · doble check
</span>
</div>
{/* 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">
<p className="text-[11px] font-bold tracking-[0.16em] uppercase text-sky-300/90">
{plan.hero.eyebrow || '4VENTURES · PLAN OPERATIVO'}
</p>
<h2 className="mt-2 text-2xl md:text-3xl font-extrabold text-white tracking-tight">
{plan.hero.title}
</h2>
<p className="mt-3 text-sm text-slate-300 max-w-2xl leading-relaxed">
{plan.hero.lede}
</p>
<div className="mt-4 flex flex-wrap gap-2">
{(plan.hero.chips || []).map((c) => (
<span
key={c}
className="text-[11px] px-3 py-1.5 rounded-full border border-white/15 bg-white/5 text-slate-200"
>
{c}
</span>
))}
</div>
</section>
{/* Network tabs */}
<div className="flex flex-wrap gap-1.5">
{platforms.map((p) => (
<button
key={p}
type="button"
onClick={() => setPlatform(p)}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold ${
platform === p
? 'bg-brand-500 text-white'
: 'bg-gray-800/70 text-gray-300'
}`}
>
{p === 'all'
? `Unificado (${pieces.length})`
: `${p} (${pieces.filter((x) => x.platform === p).length})`}
</button>
))}
</div>
{/* Reparto posts */}
{(plan.reparto_posts || []).length > 0 && (
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-5 md:p-6">
<h3 className="text-lg font-bold text-white">
1 · Reparto de los posts del mes
</h3>
<p className="text-xs text-gray-500 mt-1 mb-4">
Porcentaje del mes · objetivo de métrica a la derecha
</p>
<div className="space-y-3">
{(plan.reparto_posts || []).map((r) => (
<div
key={r.label}
className="grid grid-cols-[64px_1fr] md:grid-cols-[72px_220px_1fr_180px] gap-3 items-center"
>
<span
className="text-2xl font-extrabold"
style={{ color: r.color }}
>
{r.pct}%
</span>
<div>
<p className="text-sm font-semibold text-white">{r.label}</p>
<p className="text-[11px] text-gray-500">{r.detail}</p>
</div>
<div className="hidden md:block h-3 rounded-full bg-gray-900 overflow-hidden">
<div
className="h-full rounded-full"
style={{ width: `${r.pct}%`, background: r.color }}
/>
</div>
<p className="text-xs text-gray-300">
Objetivo: <strong>{r.objective}</strong>
</p>
</div>
))}
</div>
</section>
)}
{(plan.reparto_stories || []).length > 0 && (
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-5 md:p-6">
<h3 className="text-lg font-bold text-white">
2 · Reparto de las Stories del mes
</h3>
<div className="space-y-3 mt-4">
{(plan.reparto_stories || []).map((r) => (
<div
key={r.label}
className="grid grid-cols-[64px_1fr] md:grid-cols-[72px_220px_1fr_180px] gap-3 items-center"
>
<span
className="text-2xl font-extrabold"
style={{ color: r.color }}
>
{r.pct}%
</span>
<div>
<p className="text-sm font-semibold text-white">{r.label}</p>
<p className="text-[11px] text-gray-500">{r.detail}</p>
</div>
<div className="hidden md:block h-3 rounded-full bg-gray-900 overflow-hidden">
<div
className="h-full rounded-full"
style={{ width: `${r.pct}%`, background: r.color }}
/>
</div>
<p className="text-xs text-gray-300">
Objetivo: <strong>{r.objective}</strong>
</p>
</div>
))}
</div>
</section>
)}
{/* Legend */}
{(plan.legend || []).length > 0 && (
<div className="flex flex-wrap gap-2">
{(plan.legend || []).map((l) => (
<span
key={l.label}
className="text-[11px] px-2.5 py-1 rounded-lg font-semibold"
style={
l.outline
? {
color: l.color,
border: `1px solid ${l.color}`,
background: 'transparent',
}
: { background: l.color, color: '#fff' }
}
>
{l.label}
</span>
))}
</div>
)}
{/* Month grid */}
<section className="rounded-2xl border border-gray-800 bg-[#0D121F] p-4 md:p-5">
<h3 className="text-lg font-bold text-white mb-1">
El mes de un vistazo
</h3>
<p className="text-xs text-gray-500 mb-4">
Cada tarjeta de color es una publicación; clic para ver copy, aprobar y
comentar.
</p>
<div className="grid grid-cols-7 gap-1.5 mb-1.5">
{['L', 'M', 'X', 'J', 'V', 'S', 'D'].map((d) => (
<div
key={d}
className="text-center text-[11px] font-bold text-white bg-[#16204A] rounded-lg py-2"
>
{d}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-1.5">
{Array.from({ length: startPad }).map((_, i) => (
<div
key={`pad-${i}`}
className="min-h-[88px] rounded-lg border border-dashed border-gray-800/80 bg-transparent"
/>
))}
{Array.from({ length: daysInMonth }).map((_, i) => {
const day = i + 1;
const key = `${plan.month}-${String(day).padStart(2, '0')}`;
const dayPieces = byDate.get(key) || [];
return (
<div
key={key}
className="min-h-[88px] rounded-lg border border-gray-800 bg-[#111827] p-1.5"
>
<p className="text-[11px] font-bold text-gray-300 mb-1">{day}</p>
<div className="space-y-1">
{dayPieces.slice(0, 3).map((p) => (
<button
key={p.id}
type="button"
onClick={() => setSelected(p)}
className="w-full text-left rounded-md px-1.5 py-1 text-[9px] font-semibold leading-tight text-white"
style={{ background: colorFor(p) }}
title={p.title}
>
{p.platform === 'linkedin' ? 'LI' : 'IG'} ·{' '}
{p.title.replace(/^[^·]+·\s*/, '').slice(0, 42)}
</button>
))}
{dayPieces.length > 3 && (
<p className="text-[9px] text-gray-500">
+{dayPieces.length - 3}
</p>
)}
</div>
</div>
);
})}
</div>
</section>
{/* Piece by piece */}
<section className="space-y-3">
<h3 className="text-lg font-bold text-white">
Contenido completo, pieza a pieza
</h3>
{filtered.map((p) => (
<button
key={p.id}
type="button"
onClick={() => setSelected(p)}
className="w-full text-left rounded-xl border border-gray-800 bg-[#111827] p-4 hover:border-gray-700"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<div className="flex flex-wrap items-center gap-2 mb-1">
<span
className="text-[10px] font-bold px-2 py-0.5 rounded text-white"
style={{ background: colorFor(p) }}
>
{p.platform === 'linkedin' ? (
<span className="inline-flex items-center gap-1">
<Linkedin className="w-3 h-3" /> LinkedIn
</span>
) : (
<span className="inline-flex items-center gap-1">
<Instagram className="w-3 h-3" /> Instagram
</span>
)}
</span>
<span className="text-[11px] text-gray-500 font-mono">
{p.date} · {p.format}
</span>
<DualBadge dual={dualMap[p.id]} />
</div>
<p className="text-sm font-bold text-white">{p.title}</p>
{p.objective_metric && (
<p className="text-xs text-gray-400 mt-1">
Objetivo: {p.objective_metric}
</p>
)}
<p className="text-xs text-gray-500 mt-2 line-clamp-2">
{p.hook || p.body}
</p>
</div>
</div>
</button>
))}
</section>
{selected && (
<div className="fixed inset-0 z-50 bg-black/75 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-[#0D121F] border border-gray-800 rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
<div className="p-4 border-b border-gray-800 flex justify-between gap-3 bg-[#111827]">
<div>
<p className="text-sm font-bold text-white">{selected.title}</p>
<p className="text-[11px] text-gray-500 font-mono mt-0.5">
{selected.platform} · {selected.date} · {selected.format}
</p>
</div>
<button type="button" onClick={() => setSelected(null)}>
<X className="w-5 h-5 text-gray-400" />
</button>
</div>
<div className="p-5 overflow-y-auto space-y-3 text-sm">
<DualBadge dual={dualMap[selected.id]} />
{selected.meta_line && (
<p className="text-xs text-gray-400">{selected.meta_line}</p>
)}
<div className="rounded-xl border border-gray-800 bg-gray-900/60 p-3 text-amber-200 text-sm">
{selected.hook}
</div>
<div className="rounded-xl border border-gray-800 bg-gray-900/40 p-3 text-gray-200 whitespace-pre-wrap text-xs leading-relaxed">
{selected.body}
</div>
{selected.cta && (
<p className="text-xs text-brand-300">👉 {selected.cta}</p>
)}
{selected.hashtags && (
<p className="text-xs text-sky-400 font-mono">{selected.hashtags}</p>
)}
{canCollaborate && (
<div className="rounded-xl border border-gray-800 p-3 space-y-2">
<p className="text-xs font-semibold text-white inline-flex items-center gap-1.5">
<MessageSquare className="w-3.5 h-3.5 text-sky-400" />{' '}
Comentarios
</p>
<ul className="space-y-2 max-h-36 overflow-y-auto">
{comments.map((c) => (
<li
key={c.id}
className="text-xs border border-gray-800 rounded-lg px-2 py-1.5"
>
<p className="text-[10px] text-gray-500">
{c.author_party} · {c.author_email}
</p>
<p className="text-gray-200 whitespace-pre-wrap">
{c.body}
</p>
</li>
))}
</ul>
<div className="flex gap-2">
<input
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
className="flex-1 rounded-lg bg-black/40 border border-gray-700 px-3 py-2 text-xs text-white"
placeholder="Comentario…"
/>
<button
type="button"
disabled={busy || !commentText.trim()}
onClick={sendComment}
className="px-3 py-2 rounded-lg bg-sky-500/20 text-sky-300 text-xs border border-sky-500/30"
>
Enviar
</button>
</div>
</div>
)}
</div>
<div className="p-4 border-t border-gray-800 bg-[#111827] flex flex-wrap justify-between gap-2">
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(
`${selected.hook}\n\n${selected.body}\n\n${selected.cta}\n\n${selected.hashtags}`
);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}}
className="px-3 py-2 rounded-xl bg-gray-800 text-xs text-gray-200 inline-flex items-center gap-1.5"
>
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
Copiar copy
</button>
{canCollaborate && (
<button
type="button"
disabled={busy}
onClick={() => approve(myOk ? 'revoke' : 'approve')}
className="px-4 py-2 rounded-xl text-xs font-semibold bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 inline-flex items-center gap-1.5"
>
<ShieldCheck className="w-3.5 h-3.5" />
{myOk
? 'Quitar mi OK'
: `Aprobar (${party === 'agency' ? 'agencia' : 'cliente'})`}
</button>
)}
</div>
</div>
</div>
)}
</div>
);
}