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:
4Ventures
2026-09-23 02:38:44 +02:00
parent 5460d5bd04
commit a1a3c26c30
11 changed files with 1801 additions and 374 deletions

View File

@@ -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 (
<p className="text-sm text-gray-400">
Selecciona un cliente para ver el dash de canal.
</p>
);
}
let weekly: KpiWeeklyRow[] = [];
if (agency && ctx.viaView && ctx.tenantId) {
const tid = encodeURIComponent(ctx.tenantId);
const w = await restGet<KpiWeeklyRow[]>(
`/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 (
<div className="space-y-6">
<div>
<Link
href="/dashboard/kpis"
className="inline-flex items-center gap-1.5 text-xs text-gray-400 hover:text-white mb-3"
>
<ArrowLeft className="w-3.5 h-3.5" /> Volver a métricas
</Link>
<h2 className="text-2xl font-bold text-white capitalize">
Canal · {channel}
</h2>
<p className="text-sm text-gray-400 mt-1">
{ctx.label} · dash secundario (paid). CRM/GEO se añadirán aquí en v2.
</p>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{[
{ 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) => (
<div
key={s.label}
className="rounded-xl border border-gray-800 bg-[#111827] px-4 py-3"
>
<p className="text-[10px] uppercase tracking-wider text-gray-500">
{s.label}
</p>
<p className="text-xl font-bold text-white mt-1">{s.value}</p>
</div>
))}
</div>
<div className="rounded-xl border border-gray-800 bg-[#111827] p-5">
<h3 className="text-sm font-semibold text-white mb-3">
Semanas ({points.length})
</h3>
{points.length === 0 ? (
<p className="text-xs text-gray-500">Sin datos para este canal.</p>
) : (
<ul className="space-y-2">
{points.map((p) => (
<li
key={p.week}
className="flex flex-wrap items-center justify-between gap-2 text-xs border border-gray-800 rounded-lg px-3 py-2"
>
<span className="font-mono text-brand-400">{p.week}</span>
<span className="text-gray-300">
{formatEur(p.spend)} · {formatNum(p.leads)} leads · CPL{' '}
{p.cpl ? `${formatNum(p.cpl)} €` : '—'}
</span>
</li>
))}
</ul>
)}
</div>
</div>
);
}

View File

@@ -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 (
<MetricsDashboard
@@ -80,6 +82,8 @@ export default async function KpisPage() {
deltas={comparisons.deltas}
daily={dailyTotals}
sparse={sparse}
weeklyRows={weekly}
channelPoints={channelSeries.points}
/>
);
}