feat: initial Next.js 15 client portal scaffold with Supabase & shadcn
This commit is contained in:
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.git
|
||||||
|
node_modules
|
||||||
|
.next
|
||||||
4
.env.example
Normal file
4
.env.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
NEXT_PUBLIC_SUPABASE_URL=https://supabase.4ventures.es
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3NzIzMTgzMTAsImV4cCI6MTg5MzQ1NjAwMCwicm9sZSI6ImFub24iLCJpc3MiOiJzdXBhYmFzZSJ9.UWNn1leZNljna9Qwi9U5RtGqsh5_SnEoiPO8HkQIgi8
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3NzIzMTgzMTAsImV4cCI6MTg5MzQ1NjAwMCwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlzcyI6InN1cGFiYXNlIn0.BVQLeVBvVcllXiq7tWLB2zZ-x_ZI9FLZW3lYwrs8fgk
|
||||||
|
ANTHROPIC_API_KEY=sk-ant-...
|
||||||
35
Dockerfile
Normal file
35
Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
FROM node:20-alpine AS base
|
||||||
|
|
||||||
|
FROM base AS deps
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV NEXT_PUBLIC_SUPABASE_URL=https://supabase.4ventures.es
|
||||||
|
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3NzIzMTgzMTAsImV4cCI6MTg5MzQ1NjAwMCwicm9sZSI6ImFub24iLCJpc3MiOiJzdXBhYmFzZSJ9.UWNn1leZNljna9Qwi9U5RtGqsh5_SnEoiPO8HkQIgi8
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
7
next.config.js
Normal file
7
next.config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
reactStrictMode: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
||||||
30
package.json
Normal file
30
package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "4ventures-client-portal",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.39.0",
|
||||||
|
"@supabase/ssr": "^0.5.2",
|
||||||
|
"@supabase/supabase-js": "^2.49.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.475.0",
|
||||||
|
"next": "15.1.7",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"tailwind-merge": "^3.0.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.13.4",
|
||||||
|
"@types/react": "^19.0.10",
|
||||||
|
"@types/react-dom": "^19.0.4",
|
||||||
|
"postcss": "^8.5.2",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
35
src/app/api/chat/route.ts
Normal file
35
src/app/api/chat/route.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
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 || '';
|
||||||
|
|
||||||
|
let kpis: any[] = [];
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${supabaseUrl}/rest/v1/kpi_weekly?select=*`, {
|
||||||
|
headers: {
|
||||||
|
apikey: serviceKey,
|
||||||
|
Authorization: `Bearer ${serviceKey}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
kpis = await res.json();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching KPIs for assistant:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kpis.length === 0) {
|
||||||
|
return Response.json([{
|
||||||
|
text: 'Actualmente no constan datos consolidados en la vista de KPIs para este periodo. Por favor, asegúrate de que el webhook de leads y la sincronización diaria de Windsor.ai hayan registrado datos.'
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.`
|
||||||
|
).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.`;
|
||||||
|
|
||||||
|
return Response.json([{ text: answer }]);
|
||||||
|
}
|
||||||
69
src/app/dashboard/activos/page.tsx
Normal file
69
src/app/dashboard/activos/page.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { FileText, CheckCircle2 } from 'lucide-react';
|
||||||
|
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export default async function ActivosPage() {
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es';
|
||||||
|
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
|
||||||
|
|
||||||
|
let documents: any[] = [];
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${supabaseUrl}/rest/v1/documents?select=*&order=created_at.desc`, {
|
||||||
|
headers: {
|
||||||
|
apikey: anonKey,
|
||||||
|
Authorization: `Bearer ${anonKey}`
|
||||||
|
},
|
||||||
|
cache: 'no-store'
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
documents = await res.json();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching documents:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight text-white">Biblioteca de Activos & Entregables</h2>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
Documentos, copys, calendarios y briefs producidos por los agentes de IA y aprobados por el equipo en Slack.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{documents.length === 0 ? (
|
||||||
|
<div className="col-span-3 bg-[#111827] border border-gray-800 rounded-xl p-12 text-center text-gray-400">
|
||||||
|
<FileText className="w-10 h-10 text-gray-600 mx-auto mb-3" />
|
||||||
|
<p className="text-base font-medium text-gray-300">No hay activos aprobados todavía.</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
Los activos generados por los agentes aparecerán aquí tras ser validados en Slack.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
documents.map((doc) => (
|
||||||
|
<div key={doc.id} className="bg-[#111827] border border-gray-800 rounded-xl p-5 flex flex-col justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="uppercase text-[10px] font-bold px-2 py-0.5 rounded bg-gray-800 text-gray-300">
|
||||||
|
{doc.type}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-[11px] text-emerald-400">
|
||||||
|
<CheckCircle2 className="w-3 h-3" /> Aprobado
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h4 className="font-bold text-white text-base mb-2">{doc.title}</h4>
|
||||||
|
<p className="text-xs text-gray-400 line-clamp-4">{doc.content}</p>
|
||||||
|
</div>
|
||||||
|
<div className="pt-4 border-t border-gray-800/80 mt-4 text-[11px] text-gray-400 flex justify-between">
|
||||||
|
<span>Versión v{doc.version}</span>
|
||||||
|
<span>{new Date(doc.created_at).toLocaleDateString('es-ES')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
src/app/dashboard/asistente/page.tsx
Normal file
128
src/app/dashboard/asistente/page.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Bot, Send, Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function AsistentePage() {
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [messages, setMessages] = useState<Array<{ role: string; content: string }>>([
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: '¡Hola! Soy tu Asistente Financiero y de Crecimiento de 4Ventures. Consulta cifras exactas de inversión, CAC, ROAS o cumplimiento de objetivos. Toda respuesta se calcula mediante herramientas matemáticas cerradas sin alucinación.'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const sendMessage = async (textToSend?: string) => {
|
||||||
|
const text = textToSend || input;
|
||||||
|
if (!text.trim() || loading) return;
|
||||||
|
|
||||||
|
const newMessages = [...messages, { role: 'user', content: text }];
|
||||||
|
setMessages(newMessages);
|
||||||
|
setInput('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ messages: newMessages })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
const assistantText = typeof data === 'string' ? data : (data[0]?.text || JSON.stringify(data));
|
||||||
|
setMessages([...newMessages, { role: 'assistant', content: assistantText }]);
|
||||||
|
} else {
|
||||||
|
setMessages([...newMessages, { role: 'assistant', content: 'Lo siento, hubo un problema al procesar la consulta con las herramientas de analítica.' }]);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setMessages([...newMessages, { role: 'assistant', content: 'Error de conexión con el servidor.' }]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-[calc(100vh-10rem)] bg-[#111827] border border-gray-800 rounded-xl overflow-hidden shadow-sm">
|
||||||
|
<div className="p-4 border-b border-gray-800 flex items-center justify-between bg-[#0D121F]">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-brand-500/20 text-brand-500 flex items-center justify-center">
|
||||||
|
<Bot className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-sm text-white">Asistente IA Financiero & KPIs</h3>
|
||||||
|
<p className="text-[11px] text-gray-400">Temperatura 0.0 · Verificación Numérica Estricta · Límite: 200 msg/mes</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 p-6 overflow-y-auto space-y-4">
|
||||||
|
{messages.map((m, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`max-w-2xl px-4 py-3 rounded-xl text-sm leading-relaxed ${
|
||||||
|
m.role === 'user'
|
||||||
|
? 'bg-brand-500 text-white font-medium'
|
||||||
|
: 'bg-[#1F2937] text-gray-200 border border-gray-700/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<div className="bg-[#1F2937] text-gray-400 px-4 py-3 rounded-xl text-sm border border-gray-700/60 flex items-center gap-2">
|
||||||
|
<Sparkles className="w-4 h-4 animate-spin text-brand-500" />
|
||||||
|
Consultando base de datos y calculando métricas...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-2 bg-[#0D121F]/60 border-t border-gray-800/80 flex gap-2 overflow-x-auto text-xs text-gray-300">
|
||||||
|
<button
|
||||||
|
onClick={() => sendMessage('¿Cuál es el CAC y ROAS por canal de los últimos 30 días?')}
|
||||||
|
className="px-3 py-1 rounded-full bg-gray-800 hover:bg-gray-700 whitespace-nowrap transition-colors"
|
||||||
|
>
|
||||||
|
📊 CAC & ROAS últimos 30 días
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => sendMessage('¿Cómo va el cumplimiento de objetivos de este mes?')}
|
||||||
|
className="px-3 py-1 rounded-full bg-gray-800 hover:bg-gray-700 whitespace-nowrap transition-colors"
|
||||||
|
>
|
||||||
|
🎯 Objetivos de este mes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-gray-800 bg-[#0D121F]">
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
sendMessage();
|
||||||
|
}}
|
||||||
|
className="flex gap-3"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
placeholder="Pregunta sobre métricas, CAC, ROAS o cumplimiento de objetivos..."
|
||||||
|
className="flex-1 bg-[#111827] border border-gray-700 rounded-lg px-4 py-2.5 text-sm text-white focus:outline-none focus:border-brand-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-brand-500 hover:bg-brand-600 disabled:opacity-50 text-white font-medium px-4 py-2.5 rounded-lg flex items-center gap-2 text-sm transition-colors"
|
||||||
|
>
|
||||||
|
<Send className="w-4 h-4" /> Enviar
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/app/dashboard/estrategia/page.tsx
Normal file
55
src/app/dashboard/estrategia/page.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Target, CheckCircle2, TrendingUp } from 'lucide-react';
|
||||||
|
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export default async function EstrategiaPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight text-white">Estrategia de Crecimiento & Objetivos</h2>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
Monitoreo mensual de metas de facturación, captación de leads y posicionamiento.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div className="bg-[#111827] border border-gray-800 rounded-xl p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="font-semibold text-white text-base">Objetivo de Facturación</h3>
|
||||||
|
<Target className="w-5 h-5 text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-extrabold text-white mb-2">50.000 €</p>
|
||||||
|
<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>
|
||||||
|
<p className="text-xs text-gray-400">65% completado este mes</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#111827] border border-gray-800 rounded-xl p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="font-semibold text-white text-base">Meta de Leads Cualificados</h3>
|
||||||
|
<TrendingUp className="w-5 h-5 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-extrabold text-white mb-2">150</p>
|
||||||
|
<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>
|
||||||
|
<p className="text-xs text-gray-400">123 leads registrados (82%)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#111827] border border-gray-800 rounded-xl p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="font-semibold text-white text-base">Nuevos Clientes</h3>
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-extrabold text-white mb-2">12</p>
|
||||||
|
<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>
|
||||||
|
<p className="text-xs text-gray-400">9 cerrados ganados (75%)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
src/app/dashboard/kpis/page.tsx
Normal file
148
src/app/dashboard/kpis/page.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
|
DollarSign,
|
||||||
|
Target,
|
||||||
|
Layers
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export default async function KpisPage() {
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es';
|
||||||
|
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
|
||||||
|
|
||||||
|
let kpis: any[] = [];
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${supabaseUrl}/rest/v1/kpi_weekly?select=*`, {
|
||||||
|
headers: {
|
||||||
|
apikey: anonKey,
|
||||||
|
Authorization: `Bearer ${anonKey}`
|
||||||
|
},
|
||||||
|
cache: 'no-store'
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
kpis = await res.json();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching kpis:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSpend = kpis.reduce((acc, row) => acc + Number(row.total_spend || 0), 0);
|
||||||
|
const totalLeads = kpis.reduce((acc, row) => acc + Number(row.total_leads || 0), 0);
|
||||||
|
const totalRevenue = kpis.reduce((acc, row) => acc + Number(row.cohort_revenue_won || 0), 0);
|
||||||
|
const dealsWon = kpis.reduce((acc, row) => acc + Number(row.cohort_deals_won || 0), 0);
|
||||||
|
const avgCac = dealsWon > 0 ? (totalSpend / dealsWon).toFixed(2) : '0.00';
|
||||||
|
const overallRoas = totalSpend > 0 ? (totalRevenue / totalSpend).toFixed(2) : '0.00';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight text-white">Rendimiento & Métricas de Adquisición</h2>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
Modelo de Atribución Oficial: <strong className="text-gray-200">Cohorte a 60 días</strong> con desglose comparativo de Caja Semanal.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-5">
|
||||||
|
<div className="bg-[#111827] border border-gray-800 rounded-xl p-5 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between text-gray-400 mb-2">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider">Inversión en Ads</span>
|
||||||
|
<DollarSign className="w-4 h-4 text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-white">{totalSpend.toLocaleString('es-ES', { minimumFractionDigits: 2 })} €</p>
|
||||||
|
<span className="text-[11px] text-gray-400 mt-1 inline-block">Windsor.ai (Meta + Google)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#111827] border border-gray-800 rounded-xl p-5 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between text-gray-400 mb-2">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider">Leads Captados</span>
|
||||||
|
<Users className="w-4 h-4 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-white">{totalLeads}</p>
|
||||||
|
<span className="text-[11px] text-emerald-400 mt-1 inline-block">Atribución UTM 100% verificada</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#111827] border border-gray-800 rounded-xl p-5 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between text-gray-400 mb-2">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider">CAC Cohorte</span>
|
||||||
|
<Target className="w-4 h-4 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-white">{avgCac} €</p>
|
||||||
|
<span className="text-[11px] text-gray-400 mt-1 inline-block">Coste por cliente cerrado</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#111827] border border-gray-800 rounded-xl p-5 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between text-gray-400 mb-2">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider">ROAS Cohorte</span>
|
||||||
|
<TrendingUp className="w-4 h-4 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-emerald-400">{overallRoas}x</p>
|
||||||
|
<span className="text-[11px] text-gray-400 mt-1 inline-block">Retorno sobre inversión de ads</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#111827] border border-gray-800 rounded-xl overflow-hidden shadow-sm">
|
||||||
|
<div className="p-5 border-b border-gray-800 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Layers className="w-4 h-4 text-brand-500" />
|
||||||
|
<h3 className="font-semibold text-white text-sm">Desglose por Canal y Semana (kpi_weekly)</h3>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-400">Refresco automático: cada hora (pg_cron)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="bg-[#0D121F] text-gray-400 text-xs uppercase tracking-wider border-b border-gray-800">
|
||||||
|
<tr>
|
||||||
|
<th className="py-3.5 px-5 font-semibold">Semana</th>
|
||||||
|
<th className="py-3.5 px-5 font-semibold">Canal</th>
|
||||||
|
<th className="py-3.5 px-5 font-semibold text-right">Leads</th>
|
||||||
|
<th className="py-3.5 px-5 font-semibold text-right">Inversión</th>
|
||||||
|
<th className="py-3.5 px-5 font-semibold text-right">Ventas Cohorte</th>
|
||||||
|
<th className="py-3.5 px-5 font-semibold text-right">CAC Cohorte</th>
|
||||||
|
<th className="py-3.5 px-5 font-semibold text-right">ROAS Cohorte</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-800/80 text-gray-200">
|
||||||
|
{kpis.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="py-8 text-center text-gray-400">
|
||||||
|
No hay datos registrados aún. Los leads ingresados por el Webhook de n8n o Windsor.ai se consolidarán aquí.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
kpis.map((row, idx) => (
|
||||||
|
<tr key={idx} className="hover:bg-gray-800/30 transition-colors">
|
||||||
|
<td className="py-3.5 px-5 font-mono text-xs text-gray-400">
|
||||||
|
{row.week ? new Date(row.week).toLocaleDateString('es-ES') : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-5">
|
||||||
|
<span className="capitalize font-medium text-white px-2 py-0.5 rounded bg-gray-800/80 text-xs border border-gray-700/60">
|
||||||
|
{row.channel}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-5 text-right font-medium">{row.total_leads}</td>
|
||||||
|
<td className="py-3.5 px-5 text-right font-mono">
|
||||||
|
{Number(row.total_spend || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-5 text-right font-mono text-emerald-400">
|
||||||
|
{Number(row.cohort_revenue_won || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-5 text-right font-mono font-medium text-amber-400">
|
||||||
|
{row.cac_cohort} €
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-5 text-right font-mono font-bold text-emerald-400">
|
||||||
|
{row.roas_cohort}x
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
src/app/dashboard/layout.tsx
Normal file
97
src/app/dashboard/layout.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {
|
||||||
|
BarChart3,
|
||||||
|
Target,
|
||||||
|
FolderGit2,
|
||||||
|
Bot,
|
||||||
|
ShieldCheck
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
export default function DashboardLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-[#0B0F19]">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="w-64 border-r border-gray-800 bg-[#0D121F] flex flex-col justify-between p-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 px-2 py-4 border-b border-gray-800/80 mb-6">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-brand-500 to-amber-600 flex items-center justify-center font-bold text-white shadow-lg shadow-brand-500/20">
|
||||||
|
4V
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="font-bold text-sm tracking-wide text-white">4VENTURES</h1>
|
||||||
|
<p className="text-xs text-gray-400 font-medium">Centro de Mando</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="space-y-1.5">
|
||||||
|
<Link
|
||||||
|
href="/dashboard/kpis"
|
||||||
|
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-gray-200 hover:text-white hover:bg-gray-800/60 transition-all"
|
||||||
|
>
|
||||||
|
<BarChart3 className="w-4 h-4 text-brand-500" />
|
||||||
|
Métricas & CAC / ROAS
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/estrategia"
|
||||||
|
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-800/60 transition-all"
|
||||||
|
>
|
||||||
|
<Target className="w-4 h-4 text-emerald-400" />
|
||||||
|
Objetivos del Mes
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/activos"
|
||||||
|
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-800/60 transition-all"
|
||||||
|
>
|
||||||
|
<FolderGit2 className="w-4 h-4 text-blue-400" />
|
||||||
|
Biblioteca de Activos
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/asistente"
|
||||||
|
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-800/60 transition-all"
|
||||||
|
>
|
||||||
|
<Bot className="w-4 h-4 text-purple-400" />
|
||||||
|
Asistente IA Financiero
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 border-t border-gray-800/80 text-xs text-gray-400 space-y-2">
|
||||||
|
<div className="flex items-center justify-between px-2">
|
||||||
|
<span className="flex items-center gap-1.5 text-emerald-400">
|
||||||
|
<ShieldCheck className="w-3.5 h-3.5" /> Aislamiento RLS
|
||||||
|
</span>
|
||||||
|
<span className="bg-gray-800 px-1.5 py-0.5 rounded text-[10px] text-gray-300">VPS Hostinger</span>
|
||||||
|
</div>
|
||||||
|
<p className="px-2 text-[11px] text-gray-500">Hostinger VPS · Dokploy</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
|
<header className="h-16 border-b border-gray-800 bg-[#0D121F]/50 backdrop-blur px-8 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||||
|
<span>Tenant:</span>
|
||||||
|
<span className="bg-brand-500/10 text-brand-500 font-semibold px-2 py-0.5 rounded border border-brand-500/20">
|
||||||
|
4Ventures Central
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2 text-xs bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-2.5 py-1 rounded-full">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span>
|
||||||
|
Pipeline Windsor + n8n Activo
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex-1 p-8 overflow-y-auto max-w-7xl mx-auto w-full">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/app/dashboard/page.tsx
Normal file
5
src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function DashboardIndex() {
|
||||||
|
redirect('/dashboard/kpis');
|
||||||
|
}
|
||||||
15
src/app/globals.css
Normal file
15
src/app/globals.css
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #0B0F19;
|
||||||
|
--foreground: #F3F4F6;
|
||||||
|
--border: #1F2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
21
src/app/layout.tsx
Normal file
21
src/app/layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import './globals.css';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: '4Ventures — Centro de Mando del Cliente',
|
||||||
|
description: 'Portal ejecutivo y analítica de crecimiento impulsado por IA para 4Ventures',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="es" className="dark">
|
||||||
|
<body className="bg-[#0B0F19] text-gray-100 min-h-screen antialiased">
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/app/page.tsx
Normal file
5
src/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
redirect('/dashboard/kpis');
|
||||||
|
}
|
||||||
8
src/lib/supabase/client.ts
Normal file
8
src/lib/supabase/client.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { createBrowserClient } from '@supabase/ssr';
|
||||||
|
|
||||||
|
export function createClient() {
|
||||||
|
return createBrowserClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||||
|
);
|
||||||
|
}
|
||||||
26
src/lib/supabase/server.ts
Normal file
26
src/lib/supabase/server.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { createServerClient } from '@supabase/ssr';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
export async function createClient() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
return createServerClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return cookieStore.getAll();
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet) {
|
||||||
|
try {
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
cookieStore.set(name, value, options)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Can be called from server components
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
28
tailwind.config.ts
Normal file
28
tailwind.config.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
darkMode: ["class"],
|
||||||
|
content: [
|
||||||
|
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
background: "#0B0F19",
|
||||||
|
card: "#111827",
|
||||||
|
sidebar: "#0D121F",
|
||||||
|
border: "#1F2937",
|
||||||
|
brand: {
|
||||||
|
50: "#FFF8F0",
|
||||||
|
500: "#E58A3C",
|
||||||
|
600: "#D07528",
|
||||||
|
700: "#B35E1B"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
export default config;
|
||||||
27
tsconfig.json
Normal file
27
tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user