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:
126
src/app/dashboard/kpis/canal/[channel]/page.tsx
Normal file
126
src/app/dashboard/kpis/canal/[channel]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user