feat(portal): connect estrategia to live targets RPC and enrich chat assistant with multi-RPC routing
This commit is contained in:
@@ -3,7 +3,97 @@ import { NextRequest } from 'next/server';
|
|||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es';
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es';
|
||||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
|
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
|
||||||
|
const tenantId = process.env.DEFAULT_TENANT_ID || '0b3c5fc0-6901-4caf-b26a-b6a0201d8541';
|
||||||
|
|
||||||
|
let userMessage = '';
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
userMessage = (body.message || body.prompt || '').toLowerCase();
|
||||||
|
} catch {
|
||||||
|
// empty body fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
apikey: serviceKey,
|
||||||
|
Authorization: `Bearer ${serviceKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Intención: Objetivos vs Real / Estrategia
|
||||||
|
if (userMessage.includes('objetivo') || userMessage.includes('meta') || userMessage.includes('factura') || userMessage.includes('estrategia')) {
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`;
|
||||||
|
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/targets_vs_actual`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ p_month: currentMonth })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
const t = data[0] || {};
|
||||||
|
const revAct = Number(t.revenue_actual || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 });
|
||||||
|
const revTar = Number(t.revenue_target || 0).toLocaleString('es-ES');
|
||||||
|
const text = `🎯 **Estado de Objetivos Mensuales (Mes ${currentMonth}):**\n\n` +
|
||||||
|
`- **Facturación:** ${revAct} € de ${revTar} € (${t.revenue_achievement_pct || 0}% de la meta)\n` +
|
||||||
|
`- **Leads Captados:** ${t.leads_actual || 0} de ${t.leads_target || 0} objetivo\n` +
|
||||||
|
`- **Clientes Ganados:** ${t.clients_actual || 0} de ${t.clients_target || 0} objetivo\n\n` +
|
||||||
|
`*Fuente: RPC targets_vs_actual (Supabase PostgreSQL)*`;
|
||||||
|
return Response.json([{ text }]);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error in targets_vs_actual:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Intención: Embudo / Funnel / Conversión
|
||||||
|
if (userMessage.includes('funnel') || userMessage.includes('embudo') || userMessage.includes('tasa') || userMessage.includes('ticket')) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/lead_funnel`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ p_tenant_id: tenantId, p_days: 30 })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const f = await res.json();
|
||||||
|
const text = `🔄 **Embudo de Conversión Comercial (Últimos 30 días):**\n\n` +
|
||||||
|
`- **Leads Registrados:** ${f.total_leads || 0}\n` +
|
||||||
|
`- **Leads Contactados:** ${f.contacted_leads || 0}\n` +
|
||||||
|
`- **Deals en Pipeline:** ${f.deals_in_pipeline || 0}\n` +
|
||||||
|
`- **Deals Ganados:** ${f.deals_won || 0}\n` +
|
||||||
|
`- **Facturación Cerrada:** ${Number(f.revenue_won || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €\n` +
|
||||||
|
`- **Ticket Medio:** ${Number(f.avg_ticket || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €\n` +
|
||||||
|
`- **Tasa de Cierre Lead -> Venta:** ${f.conversion_lead_to_deal_pct || 0}%\n\n` +
|
||||||
|
`*Fuente: RPC lead_funnel (Supabase PostgreSQL)*`;
|
||||||
|
return Response.json([{ text }]);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error in lead_funnel:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Intención: Ranking de Campañas / Anuncios
|
||||||
|
if (userMessage.includes('campa') || userMessage.includes('anuncio') || userMessage.includes('ranking') || userMessage.includes('mejor')) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/rank_campaigns`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ p_tenant_id: tenantId, p_metric: 'spend', p_limit: 5 })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const campaigns = await res.json();
|
||||||
|
const list = campaigns.map((c: any) =>
|
||||||
|
`- **${c.campaign_name}** (${c.platform.toUpperCase()}): Inversión ${Number(c.spend || 0).toFixed(2)} €, ${c.clicks || 0} clics, ${c.impressions || 0} imp., ${c.leads || 0} leads.`
|
||||||
|
).join('\n');
|
||||||
|
const text = `🏆 **Top Campañas por Inversión Publicitaria:**\n\n${list || 'No constan campañas publicitarias activas en este periodo.'}\n\n*Fuente: RPC rank_campaigns (Windsor.ai pipeline)*`;
|
||||||
|
return Response.json([{ text }]);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error in rank_campaigns:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Default: Consulta de KPIs Semanales (CAC & ROAS)
|
||||||
let kpis: any[] = [];
|
let kpis: any[] = [];
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${supabaseUrl}/rest/v1/kpi_weekly?select=*`, {
|
const res = await fetch(`${supabaseUrl}/rest/v1/kpi_weekly?select=*`, {
|
||||||
@@ -26,10 +116,10 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const channelsSummary = kpis.map((k: any) =>
|
const channelsSummary = kpis.map((k: any) =>
|
||||||
`- Canal ${k.channel.toUpperCase()}: ${k.total_leads} leads captados, inversión de ${Number(k.total_spend || 0).toFixed(2)} €, ${k.cohort_deals_won} clientes ganados, CAC: ${k.cac_cohort} €, ROAS: ${k.roas_cohort}x.`
|
`- Canal **${k.channel.toUpperCase()}**: ${k.total_leads} leads captados, inversión de ${Number(k.total_spend || 0).toFixed(2)} €, ${k.cohort_deals_won} clientes ganados, facturación de ${Number(k.cohort_revenue_won || 0).toFixed(2)} €, CAC: ${k.cac_cohort} €, ROAS: ${k.roas_cohort}x.`
|
||||||
).join('\n');
|
).join('\n');
|
||||||
|
|
||||||
const answer = `📊 Resumen de Rendimiento Analítico (Fuente: kpi_weekly, corte horario):\n\n${channelsSummary}\n\n💡 Regla de Atribución: Calculado según el modelo oficial de cohorte a 60 días.`;
|
const answer = `📊 **Resumen de Rendimiento Analítico (Fuente: kpi_weekly, corte horario):**\n\n${channelsSummary}\n\n💡 **Regla de Atribución:** Calculado según el modelo oficial de cohorte a 60 días sin alucinación. Puedes preguntarme también sobre *objetivos*, *embudo de conversión* o *mejores campañas*.`;
|
||||||
|
|
||||||
return Response.json([{ text: answer }]);
|
return Response.json([{ text: answer }]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,57 @@ import { Target, CheckCircle2, TrendingUp } from 'lucide-react';
|
|||||||
export const revalidate = 0;
|
export const revalidate = 0;
|
||||||
|
|
||||||
export default async function EstrategiaPage() {
|
export default async function EstrategiaPage() {
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es';
|
||||||
|
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`;
|
||||||
|
|
||||||
|
let targetData: any = {
|
||||||
|
revenue_target: 10000,
|
||||||
|
revenue_actual: 0,
|
||||||
|
revenue_achievement_pct: 0,
|
||||||
|
leads_target: 20,
|
||||||
|
leads_actual: 0,
|
||||||
|
clients_target: 4,
|
||||||
|
clients_actual: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/targets_vs_actual`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
apikey: serviceKey,
|
||||||
|
Authorization: `Bearer ${serviceKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ p_month: currentMonth }),
|
||||||
|
cache: 'no-store'
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (Array.isArray(data) && data.length > 0) {
|
||||||
|
targetData = data[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching targets_vs_actual:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const revenuePct = Math.min(100, Math.round(targetData.revenue_achievement_pct || 0));
|
||||||
|
const leadsPct = targetData.leads_target > 0
|
||||||
|
? Math.min(100, Math.round((Number(targetData.leads_actual) / Number(targetData.leads_target)) * 100))
|
||||||
|
: 0;
|
||||||
|
const clientsPct = targetData.clients_target > 0
|
||||||
|
? Math.min(100, Math.round((Number(targetData.clients_actual) / Number(targetData.clients_target)) * 100))
|
||||||
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight text-white">Estrategia de Crecimiento & Objetivos</h2>
|
<h2 className="text-2xl font-bold tracking-tight text-white">Estrategia de Crecimiento & Objetivos</h2>
|
||||||
<p className="text-sm text-gray-400 mt-1">
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
Monitoreo mensual de metas de facturación, captación de leads y posicionamiento.
|
Monitoreo mensual en tiempo real de metas fijadas frente al rendimiento real consolidado en Supabase.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -19,11 +64,15 @@ export default async function EstrategiaPage() {
|
|||||||
<h3 className="font-semibold text-white text-base">Objetivo de Facturación</h3>
|
<h3 className="font-semibold text-white text-base">Objetivo de Facturación</h3>
|
||||||
<Target className="w-5 h-5 text-brand-500" />
|
<Target className="w-5 h-5 text-brand-500" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-3xl font-extrabold text-white mb-2">50.000 €</p>
|
<p className="text-3xl font-extrabold text-white mb-2">
|
||||||
|
{Number(targetData.revenue_actual || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €
|
||||||
|
</p>
|
||||||
<div className="w-full bg-gray-800 rounded-full h-2 mb-2">
|
<div className="w-full bg-gray-800 rounded-full h-2 mb-2">
|
||||||
<div className="bg-brand-500 h-2 rounded-full w-[65%]"></div>
|
<div className="bg-brand-500 h-2 rounded-full" style={{ width: `${revenuePct}%` }}></div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-400">65% completado este mes</p>
|
<p className="text-xs text-gray-400">
|
||||||
|
Meta: {Number(targetData.revenue_target || 0).toLocaleString('es-ES')} € ({revenuePct}% completado)
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-[#111827] border border-gray-800 rounded-xl p-6">
|
<div className="bg-[#111827] border border-gray-800 rounded-xl p-6">
|
||||||
@@ -31,23 +80,27 @@ export default async function EstrategiaPage() {
|
|||||||
<h3 className="font-semibold text-white text-base">Meta de Leads Cualificados</h3>
|
<h3 className="font-semibold text-white text-base">Meta de Leads Cualificados</h3>
|
||||||
<TrendingUp className="w-5 h-5 text-blue-400" />
|
<TrendingUp className="w-5 h-5 text-blue-400" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-3xl font-extrabold text-white mb-2">150</p>
|
<p className="text-3xl font-extrabold text-white mb-2">{targetData.leads_actual || 0}</p>
|
||||||
<div className="w-full bg-gray-800 rounded-full h-2 mb-2">
|
<div className="w-full bg-gray-800 rounded-full h-2 mb-2">
|
||||||
<div className="bg-blue-400 h-2 rounded-full w-[82%]"></div>
|
<div className="bg-blue-400 h-2 rounded-full" style={{ width: `${leadsPct}%` }}></div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-400">123 leads registrados (82%)</p>
|
<p className="text-xs text-gray-400">
|
||||||
|
{targetData.leads_actual || 0} de {targetData.leads_target || 0} leads captados ({leadsPct}%)
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-[#111827] border border-gray-800 rounded-xl p-6">
|
<div className="bg-[#111827] border border-gray-800 rounded-xl p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="font-semibold text-white text-base">Nuevos Clientes</h3>
|
<h3 className="font-semibold text-white text-base">Nuevos Clientes (Won)</h3>
|
||||||
<CheckCircle2 className="w-5 h-5 text-emerald-400" />
|
<CheckCircle2 className="w-5 h-5 text-emerald-400" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-3xl font-extrabold text-white mb-2">12</p>
|
<p className="text-3xl font-extrabold text-white mb-2">{targetData.clients_actual || 0}</p>
|
||||||
<div className="w-full bg-gray-800 rounded-full h-2 mb-2">
|
<div className="w-full bg-gray-800 rounded-full h-2 mb-2">
|
||||||
<div className="bg-emerald-400 h-2 rounded-full w-[75%]"></div>
|
<div className="bg-emerald-400 h-2 rounded-full" style={{ width: `${clientsPct}%` }}></div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-400">9 cerrados ganados (75%)</p>
|
<p className="text-xs text-gray-400">
|
||||||
|
{targetData.clients_actual || 0} de {targetData.clients_target || 0} clientes ganados ({clientsPct}%)
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user