diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..687b78e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +.git +node_modules +.next diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b12688e --- /dev/null +++ b/.env.example @@ -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-... diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..38cbd8a --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/next.config.js b/next.config.js new file mode 100644 index 0000000..bd2da26 --- /dev/null +++ b/next.config.js @@ -0,0 +1,7 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: 'standalone', + reactStrictMode: true, +}; + +module.exports = nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..a760c3e --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts new file mode 100644 index 0000000..f5a9db8 --- /dev/null +++ b/src/app/api/chat/route.ts @@ -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 }]); +} diff --git a/src/app/dashboard/activos/page.tsx b/src/app/dashboard/activos/page.tsx new file mode 100644 index 0000000..db5abe4 --- /dev/null +++ b/src/app/dashboard/activos/page.tsx @@ -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 ( +
+
+

Biblioteca de Activos & Entregables

+

+ Documentos, copys, calendarios y briefs producidos por los agentes de IA y aprobados por el equipo en Slack. +

+
+ +
+ {documents.length === 0 ? ( +
+ +

No hay activos aprobados todavía.

+

+ Los activos generados por los agentes aparecerán aquí tras ser validados en Slack. +

+
+ ) : ( + documents.map((doc) => ( +
+
+
+ + {doc.type} + + + Aprobado + +
+

{doc.title}

+

{doc.content}

+
+
+ Versión v{doc.version} + {new Date(doc.created_at).toLocaleDateString('es-ES')} +
+
+ )) + )} +
+
+ ); +} diff --git a/src/app/dashboard/asistente/page.tsx b/src/app/dashboard/asistente/page.tsx new file mode 100644 index 0000000..4491ce3 --- /dev/null +++ b/src/app/dashboard/asistente/page.tsx @@ -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>([ + { + 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 ( +
+
+
+
+ +
+
+

Asistente IA Financiero & KPIs

+

Temperatura 0.0 · Verificación Numérica Estricta · Límite: 200 msg/mes

+
+
+
+ +
+ {messages.map((m, idx) => ( +
+
+ {m.content} +
+
+ ))} + {loading && ( +
+
+ + Consultando base de datos y calculando métricas... +
+
+ )} +
+ +
+ + +
+ +
+
{ + e.preventDefault(); + sendMessage(); + }} + className="flex gap-3" + > + 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" + /> + +
+
+
+ ); +} diff --git a/src/app/dashboard/estrategia/page.tsx b/src/app/dashboard/estrategia/page.tsx new file mode 100644 index 0000000..fad75ba --- /dev/null +++ b/src/app/dashboard/estrategia/page.tsx @@ -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 ( +
+
+

Estrategia de Crecimiento & Objetivos

+

+ Monitoreo mensual de metas de facturación, captación de leads y posicionamiento. +

+
+ +
+
+
+

Objetivo de Facturación

+ +
+

50.000 €

+
+
+
+

65% completado este mes

+
+ +
+
+

Meta de Leads Cualificados

+ +
+

150

+
+
+
+

123 leads registrados (82%)

+
+ +
+
+

Nuevos Clientes

+ +
+

12

+
+
+
+

9 cerrados ganados (75%)

+
+
+
+ ); +} diff --git a/src/app/dashboard/kpis/page.tsx b/src/app/dashboard/kpis/page.tsx new file mode 100644 index 0000000..dccdb0b --- /dev/null +++ b/src/app/dashboard/kpis/page.tsx @@ -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 ( +
+
+

Rendimiento & Métricas de Adquisición

+

+ Modelo de Atribución Oficial: Cohorte a 60 días con desglose comparativo de Caja Semanal. +

+
+ +
+
+
+ Inversión en Ads + +
+

{totalSpend.toLocaleString('es-ES', { minimumFractionDigits: 2 })} €

+ Windsor.ai (Meta + Google) +
+ +
+
+ Leads Captados + +
+

{totalLeads}

+ Atribución UTM 100% verificada +
+ +
+
+ CAC Cohorte + +
+

{avgCac} €

+ Coste por cliente cerrado +
+ +
+
+ ROAS Cohorte + +
+

{overallRoas}x

+ Retorno sobre inversión de ads +
+
+ +
+
+
+ +

Desglose por Canal y Semana (kpi_weekly)

+
+ Refresco automático: cada hora (pg_cron) +
+ +
+ + + + + + + + + + + + + + {kpis.length === 0 ? ( + + + + ) : ( + kpis.map((row, idx) => ( + + + + + + + + + + )) + )} + +
SemanaCanalLeadsInversiónVentas CohorteCAC CohorteROAS Cohorte
+ No hay datos registrados aún. Los leads ingresados por el Webhook de n8n o Windsor.ai se consolidarán aquí. +
+ {row.week ? new Date(row.week).toLocaleDateString('es-ES') : '-'} + + + {row.channel} + + {row.total_leads} + {Number(row.total_spend || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} € + + {Number(row.cohort_revenue_won || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} € + + {row.cac_cohort} € + + {row.roas_cohort}x +
+
+
+
+ ); +} diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx new file mode 100644 index 0000000..404fa84 --- /dev/null +++ b/src/app/dashboard/layout.tsx @@ -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 ( +
+ {/* Sidebar */} + + + {/* Main Content Area */} +
+
+
+ Tenant: + + 4Ventures Central + +
+
+
+ + Pipeline Windsor + n8n Activo +
+
+
+ +
+ {children} +
+
+
+ ); +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 0000000..22f0e2f --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation'; + +export default function DashboardIndex() { + redirect('/dashboard/kpis'); +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..f945085 --- /dev/null +++ b/src/app/globals.css @@ -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; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..e16a163 --- /dev/null +++ b/src/app/layout.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..1f1ce9d --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation'; + +export default function Home() { + redirect('/dashboard/kpis'); +} diff --git a/src/lib/supabase/client.ts b/src/lib/supabase/client.ts new file mode 100644 index 0000000..e6db2a1 --- /dev/null +++ b/src/lib/supabase/client.ts @@ -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! + ); +} diff --git a/src/lib/supabase/server.ts b/src/lib/supabase/server.ts new file mode 100644 index 0000000..b3f680f --- /dev/null +++ b/src/lib/supabase/server.ts @@ -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 + } + }, + }, + } + ); +} diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000..bea39d1 --- /dev/null +++ b/tailwind.config.ts @@ -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; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c133409 --- /dev/null +++ b/tsconfig.json @@ -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"] +}