feat: dual agency/client approval on calendar and assets
Private RRSS calendar with unified + per-network tabs, comments, and double-check OK. Assets get review drawer with preview/comments. KPIs gain date presets, North Star strip, channel matrix and per-channel dash. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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({
|
||||
<div className="mt-3 grid grid-cols-3 gap-1.5">
|
||||
{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<RangeKey>('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: <DollarSign className="w-4 h-4 text-brand-500" />,
|
||||
},
|
||||
{
|
||||
key: 'leads',
|
||||
label: 'Leads',
|
||||
hint: 'Cohorte · mes',
|
||||
hint: 'Captación periodo',
|
||||
format: formatNum,
|
||||
icon: <Users className="w-4 h-4 text-blue-400" />,
|
||||
},
|
||||
{
|
||||
key: 'cpl',
|
||||
label: 'CPL',
|
||||
hint: 'Coste por lead · mes',
|
||||
hint: 'Coste por lead',
|
||||
format: (v) => `${formatNum(v)} €`,
|
||||
icon: <MousePointerClick className="w-4 h-4 text-violet-400" />,
|
||||
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: <Target className="w-4 h-4 text-amber-400" />,
|
||||
invert: true,
|
||||
@@ -157,28 +240,81 @@ export default function MetricsDashboard({
|
||||
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight text-white">
|
||||
Dashboard de métricas
|
||||
North Star · métricas
|
||||
</h2>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
{label}
|
||||
{portfolioWide
|
||||
? ' · agrega todos los tenants (vista agencia)'
|
||||
: ' · zoom al cliente del selector'}
|
||||
. Comparativas WoW / MoM / YoY sobre <code>kpi_weekly</code>.
|
||||
? ' · portafolio'
|
||||
: ' · zoom cliente'}
|
||||
. Paid unificado ahora; CRM/GEO en siguientes fases.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-[11px] text-gray-500 bg-gray-900/50 border border-gray-800 rounded-lg px-3 py-2">
|
||||
Semana actual spend {formatEur(week.spend)} · leads {formatNum(week.leads)}
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarRange className="w-4 h-4 text-gray-500" />
|
||||
<div className="flex gap-1 rounded-lg border border-gray-800 p-0.5 bg-black/20">
|
||||
{(
|
||||
[
|
||||
['7d', '7d'],
|
||||
['30d', '30d'],
|
||||
['month', 'Mes'],
|
||||
['all', 'Todo'],
|
||||
] as [RangeKey, string][]
|
||||
).map(([k, lbl]) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
onClick={() => setRange(k)}
|
||||
className={`px-2.5 py-1 text-xs rounded-md ${
|
||||
range === k
|
||||
? 'bg-brand-500/20 text-brand-300'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{lbl}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sparse && (
|
||||
<p className="text-xs text-amber-400/90 border border-amber-500/20 rounded-lg px-3 py-2">
|
||||
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.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* North Star strip */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ 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) => (
|
||||
<div
|
||||
key={s.label}
|
||||
className="rounded-xl border border-brand-500/20 bg-brand-500/5 px-4 py-3"
|
||||
>
|
||||
<p className="text-[10px] uppercase tracking-wider text-brand-400/80">
|
||||
{s.label}
|
||||
</p>
|
||||
<p className="text-xl font-bold text-white mt-1">{s.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{cards.map((c) => (
|
||||
<div
|
||||
@@ -192,7 +328,7 @@ export default function MetricsDashboard({
|
||||
{c.icon}
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">
|
||||
{c.format(month[c.key])}
|
||||
{c.format(periodTotals[c.key])}
|
||||
</p>
|
||||
<p className="text-[11px] text-gray-500 mt-1">{c.hint}</p>
|
||||
<DeltaTriple delta={deltas[c.key]} invert={c.invert} />
|
||||
@@ -200,41 +336,112 @@ export default function MetricsDashboard({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Channel matrix */}
|
||||
<section className="bg-[#111827] border border-gray-800 rounded-xl p-5 space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-white">
|
||||
Matriz por canal
|
||||
</h3>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
Clic → dash secundario del canal
|
||||
</p>
|
||||
</div>
|
||||
{channelRows.length === 0 ? (
|
||||
<p className="text-xs text-gray-500 py-6 text-center">
|
||||
Sin series de canal en el periodo. Cuando Windsor cargue
|
||||
`kpi_weekly.channel`, aparecerán aquí.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="text-gray-500 border-b border-gray-800">
|
||||
<tr>
|
||||
<th className="py-2 pr-3 font-medium">Canal</th>
|
||||
<th className="py-2 pr-3 font-medium">Inversión</th>
|
||||
<th className="py-2 pr-3 font-medium">Leads</th>
|
||||
<th className="py-2 pr-3 font-medium">CPL</th>
|
||||
<th className="py-2 pr-3 font-medium">Revenue</th>
|
||||
<th className="py-2 font-medium">ROAS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{channelRows.map((row) => (
|
||||
<tr
|
||||
key={row.channel}
|
||||
className="border-b border-gray-900 hover:bg-white/[0.02]"
|
||||
>
|
||||
<td className="py-2.5 pr-3">
|
||||
<Link
|
||||
href={`/dashboard/kpis/canal/${encodeURIComponent(row.channel)}`}
|
||||
className="text-brand-400 hover:text-brand-300 font-semibold capitalize"
|
||||
>
|
||||
{row.channel}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-200">
|
||||
{formatEur(row.spend)}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-200">
|
||||
{formatNum(row.leads)}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-200">
|
||||
{row.cpl ? `${formatNum(row.cpl)} €` : '—'}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-200">
|
||||
{formatEur(row.revenue)}
|
||||
</td>
|
||||
<td className="py-2.5 text-gray-200">
|
||||
{row.roas ? `${formatNum(row.roas)}x` : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 pt-2">
|
||||
{['GEO', 'CRM close %', 'LTV'].map((soon) => (
|
||||
<div
|
||||
key={soon}
|
||||
className="rounded-lg border border-dashed border-gray-800 px-3 py-2 text-[11px] text-gray-500"
|
||||
>
|
||||
{soon} · fase 2 (stub)
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-[#111827] border border-gray-800 rounded-xl p-5">
|
||||
<div className="flex items-center justify-between text-gray-400 mb-2">
|
||||
<span className="text-xs uppercase tracking-wider">Ads · 30d (ad_daily)</span>
|
||||
<span className="text-xs uppercase tracking-wider">
|
||||
Ads · ventana diaria
|
||||
</span>
|
||||
<Eye className="w-4 h-4 text-sky-400" />
|
||||
</div>
|
||||
<p className="text-lg font-bold text-white">
|
||||
{formatEur(daily.spend)}
|
||||
</p>
|
||||
<p className="text-lg font-bold text-white">{formatEur(daily.spend)}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{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
|
||||
</p>
|
||||
<p className="text-[11px] text-gray-600 mt-2">
|
||||
Semana actual: {formatEur(week.spend)} · {formatNum(week.leads)}{' '}
|
||||
leads
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-[#111827] border border-gray-800 rounded-xl p-5 md:col-span-2">
|
||||
<p className="text-xs uppercase tracking-wider text-gray-500 mb-2">
|
||||
Cómo leer las deltas
|
||||
Cómo leer
|
||||
</p>
|
||||
<ul className="text-xs text-gray-400 space-y-1 list-disc list-inside">
|
||||
<li>
|
||||
<strong className="text-gray-300">WoW</strong>: semana actual vs
|
||||
anterior
|
||||
El selector de fechas filtra inversión/leads del periodo; WoW/MoM/YoY
|
||||
siguen siendo comparativas de calendario.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-gray-300">MoM</strong>: mes actual vs mes
|
||||
previo
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-gray-300">YoY</strong>: mismo mes vs hace
|
||||
un año
|
||||
</li>
|
||||
<li>
|
||||
En CPL/CAC, bajar es bueno (flecha verde si el % es negativo)
|
||||
CAC unificado con fee/tools y cierre CRM llega en v2.
|
||||
</li>
|
||||
<li>GEO / LTV aparecen como stubs hasta tener pipeline de datos.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user