Propuesta consultiva con IA: rutas, panel de edicion y tercer documento

Cierra la funcionalidad: el Cotizador ahora ofrece tres descargas por cotizacion
—Excel, PDF economico y PDF consultivo con marca— mas un anexo interno aparte.

Rutas: POST genera, GET lee la ultima, PATCH guarda la edicion del asesor
(revalidando, porque una edicion manual puede introducir una fuga o un USD), y
GET /pdf descarga, con ?anexo=1 para el interno.

Panel: transcripcion opcional, avisos de validacion, campos editables (titulo,
subtitulo, hallazgos, alcance, exclusiones y beneficios) y las descargas.
Los avisos van deliberadamente ARRIBA de los botones de descarga: el objetivo es
que revisar sea mas facil que aprobar.

El anexo interno lleva la ponderacion: ratio precio/valor sobre el desembolso
del primer ano con IVA, su lectura (subcotizado / en rango / alto / objecion
probable), conteo de hallazgos y evidencia, red flags y notas para el asesor.
Va en archivo separado con banda roja "NO ENVIAR", nunca como seccion oculta.

Verificado con 6 suites (114 comprobaciones), typecheck, lint y build. Las mas
importantes son las estructurales: ningun schema declara un campo de dinero de
E3, ningun prompt filtra precios ni cuids, y el documento del cliente no
contiene notas internas ni red flags aunque se inyecten a la fuerza.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
urieljareth
2026-07-28 18:25:39 -06:00
co-authored by Claude Opus 5
parent 91db8cc55c
commit ce3a38f89d
5 changed files with 2570 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+3
View File
@@ -17,6 +17,7 @@ import { DeleteCotizacionButton } from "./DeleteButton";
import { CambiarEstadoButtons } from "./CambiarEstadoButtons"; import { CambiarEstadoButtons } from "./CambiarEstadoButtons";
import { PreciosEditables } from "./PreciosEditables"; import { PreciosEditables } from "./PreciosEditables";
import { RegistroHorasPanel } from "./RegistroHorasPanel"; import { RegistroHorasPanel } from "./RegistroHorasPanel";
import PropuestaIAPanel from "@/components/PropuestaIAPanel";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -236,6 +237,8 @@ export default async function CotizacionDetailPage({
</div> </div>
)} )}
<PropuestaIAPanel cotizacionId={cot.id} numero={cot.numero} />
{/* Unico lugar donde se muestran las notas internas. No van a ningun {/* Unico lugar donde se muestran las notas internas. No van a ningun
exportador: ni CotizacionPDFData ni ExcelData declaran el campo. */} exportador: ni CotizacionPDFData ni ExcelData declaran el campo. */}
{cot.observacionesInternas && ( {cot.observacionesInternas && (
@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getConfigBranding } from "@/lib/config-helpers";
import { sanitizeFilename } from "@/lib/calculators";
import { cargarEconomia } from "@/lib/propuesta/economia";
import { generarPropuestaPDF, generarAnexoInternoPDF } from "@/lib/propuesta/pdf";
import type { PropuestaConsultiva } from "@/lib/propuesta/schemas";
/** GET /api/propuesta-ia/:id/pdf -> documento del cliente
* GET /api/propuesta-ia/:id/pdf?anexo=1 -> anexo interno (archivo aparte, a proposito) */
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const anexo = request.nextUrl.searchParams.get("anexo") === "1";
const [cot, fila, branding] = await Promise.all([
prisma.cotizacion.findUnique({ where: { id }, include: { cliente: true, asesor: true } }),
prisma.propuestaIA.findFirst({ where: { cotizacionId: id }, orderBy: { createdAt: "desc" } }),
getConfigBranding(),
]);
if (!cot) return NextResponse.json({ error: "Cotizacion no encontrada" }, { status: 404 });
if (!fila) return NextResponse.json({ error: "Todavia no se ha generado la propuesta" }, { status: 404 });
const propuesta = (fila.contenidoEditado ?? fila.contenidoIA) as unknown as PropuestaConsultiva;
const economia = await cargarEconomia(id);
const datos = {
propuesta,
economia,
cliente: cot.cliente.nombre,
empresa: cot.cliente.empresa || "",
asesor: cot.asesor.name,
numero: cot.numero,
fecha: cot.fecha,
vigencia: cot.vigencia,
proyecto: cot.proyecto,
branding,
};
const buffer = anexo ? await generarAnexoInternoPDF(datos) : await generarPropuestaPDF(datos);
const empresa = cot.cliente.empresa || cot.cliente.nombre;
const nombre = anexo
? `${sanitizeFilename(empresa)} - ${cot.numero} - INTERNO NO ENVIAR`
: `${sanitizeFilename(empresa)} - ${cot.numero} - Propuesta consultiva`;
return new NextResponse(new Uint8Array(buffer), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${nombre}.pdf"`,
},
});
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+135
View File
@@ -0,0 +1,135 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { cargarEconomia } from "@/lib/propuesta/economia";
import { generarPropuesta } from "@/lib/propuesta/pipeline";
import { validarPropuesta } from "@/lib/propuesta/validacion";
import { propuestaCompletaSchema, type PropuestaConsultiva } from "@/lib/propuesta/schemas";
/** Reune el contexto humano de la cotizacion. Distingue lo que ve el cliente de lo
* interno: el interno se manda al modelo (necesita el contexto) pero R0 comprueba
* despues que no se haya colado a la salida. */
async function contextoDe(cotizacionId: string) {
const cot = await prisma.cotizacion.findUnique({
where: { id: cotizacionId },
include: { cliente: true, asesor: true, servicios: true },
});
if (!cot) return null;
const notasPartidas = cot.servicios
.map((s) => s.notas)
.filter((n): n is string => Boolean(n && n.trim()))
.join("\n");
return { cot, notasPartidas };
}
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const body = await request.json().catch(() => ({}));
const transcripcion: string = typeof body.transcripcion === "string" ? body.transcripcion : "";
const ctx = await contextoDe(id);
if (!ctx) return NextResponse.json({ error: "Cotizacion no encontrada" }, { status: 404 });
const { cot, notasPartidas } = ctx;
const economia = await cargarEconomia(id);
if (!economia.partidas.length) {
return NextResponse.json(
{ error: "La cotizacion no tiene partidas seleccionadas. Agrega servicios antes de generar la propuesta." },
{ status: 400 }
);
}
const { propuesta, traza } = await generarPropuesta({
entrada: {
transcripcion,
notas: notasPartidas,
observaciones: cot.observaciones || "",
cliente: cot.cliente.nombre,
empresa: cot.cliente.empresa || "",
proyecto: cot.proyecto,
},
economia,
});
const avisos = validarPropuesta(propuesta, economia, {
textoCliente: [transcripcion, cot.observaciones || "", notasPartidas].join("\n"),
textoInterno: cot.observacionesInternas || "",
});
const fila = await prisma.propuestaIA.create({
data: {
cotizacionId: id,
contenidoIA: propuesta as unknown as object,
traza: traza as unknown as object,
avisos: avisos as unknown as object,
modelo: traza.modelo,
},
});
return NextResponse.json({ id: fila.id, propuesta, avisos, traza });
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const fila = await prisma.propuestaIA.findFirst({
where: { cotizacionId: id },
orderBy: { createdAt: "desc" },
});
if (!fila) return NextResponse.json({ propuesta: null });
return NextResponse.json({
id: fila.id,
propuesta: fila.contenidoEditado ?? fila.contenidoIA,
editada: fila.contenidoEditado !== null,
avisos: fila.avisos,
traza: fila.traza,
createdAt: fila.createdAt,
});
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const body = await request.json();
const parsed = propuestaCompletaSchema.safeParse(body.propuesta);
if (!parsed.success) {
return NextResponse.json({ error: z.prettifyError(parsed.error) }, { status: 400 });
}
const fila = await prisma.propuestaIA.findFirst({
where: { cotizacionId: id },
orderBy: { createdAt: "desc" },
});
if (!fila) return NextResponse.json({ error: "No hay propuesta generada para esta cotizacion" }, { status: 404 });
// La edicion del asesor se revalida: pudo introducir una fuga, un USD o una
// promesa de resultado sin darse cuenta.
const [economia, ctx] = await Promise.all([cargarEconomia(id), contextoDe(id)]);
const avisos = validarPropuesta(parsed.data as PropuestaConsultiva, economia, {
textoCliente: [ctx?.cot.observaciones || "", ctx?.notasPartidas || ""].join("\n"),
textoInterno: ctx?.cot.observacionesInternas || "",
});
await prisma.propuestaIA.update({
where: { id: fila.id },
data: {
contenidoEditado: parsed.data as unknown as object,
avisos: avisos as unknown as object,
},
});
return NextResponse.json({ ok: true, avisos });
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+415
View File
@@ -0,0 +1,415 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Sparkles, Download, Save, AlertTriangle, Info, Lock, RefreshCw, ChevronDown, ChevronRight } from "lucide-react";
import { useToast, useConfirm } from "@/components/ui/DialogProvider";
// Panel de la propuesta consultiva: generar, revisar, editar y descargar.
//
// Regla de diseno deliberada: los avisos van ARRIBA de los botones de descarga.
// El objetivo es que revisar sea mas facil que aprobar, no al reves.
type Confianza = "confirmado" | "estimado" | "por_validar";
type Urgencia = "rojo" | "ambar" | "azul" | "verde";
interface Aviso {
regla: string;
severidad: "bloqueante" | "advertencia";
mensaje: string;
ruta?: string;
}
interface Propuesta {
hechos: {
citas: { id: string; textoLiteral: string; quienLoDijo: string }[];
hechos: { id: string; enunciado: string; confianza: Confianza; citas: string[] }[];
materialesPendientes: { texto: string; bloqueaEntrega: boolean }[];
decisionesPendientes: { texto: string; quienDecide: string }[];
mencionesFueraDeAlcance: string[];
redFlags: { senal: string; severidad: string }[];
};
diagnostico: {
hallazgos: { id: string; urgencia: Urgencia; titulo: string; descripcion: string; confianza: Confianza; citas: string[]; hechos: string[] }[];
valorProblema: {
dimensiones: { tipo: string; descripcion: string; calculo: { factores: { nombre: string; valor: number }[]; montoAnualMXN: number | null }; confianza: Confianza; hechos: string[] }[];
notaMetodologia: string;
};
resultados: { enunciado: string; metrica: string; lineaBase: string | null; periodoMedicion: string }[];
};
redaccion: {
hero: { titulo: string; subtitulo: string };
citaDestacadaId: string;
alcance: { refPartida: string; descripcionResultado: string }[];
beneficios: { etiqueta: string; texto: string; hallazgoId: string }[];
exclusiones: { texto: string; razon: string }[];
backlogEvolucion: { problema: string; momentoSugerido: string }[];
notasInternas: string[];
};
}
const COLOR_URGENCIA: Record<Urgencia, string> = {
rojo: "bg-red-500",
ambar: "bg-amber-500",
azul: "bg-blue-500",
verde: "bg-green-500",
};
const ETIQUETA_URGENCIA: Record<Urgencia, string> = {
rojo: "Problema critico",
ambar: "Area de mejora",
azul: "Oportunidad",
verde: "Ventaja existente",
};
const INPUT = "w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-1 focus:ring-primary";
export default function PropuestaIAPanel({ cotizacionId }: { cotizacionId: string; numero?: string }) {
const toast = useToast();
const confirm = useConfirm();
const [abierto, setAbierto] = useState(false);
const [cargando, setCargando] = useState(true);
const [generando, setGenerando] = useState(false);
const [guardando, setGuardando] = useState(false);
const [transcripcion, setTranscripcion] = useState("");
const [propuesta, setPropuesta] = useState<Propuesta | null>(null);
const [avisos, setAvisos] = useState<Aviso[]>([]);
const [editada, setEditada] = useState(false);
const [sucio, setSucio] = useState(false);
const cargar = useCallback(async () => {
try {
const r = await fetch(`/api/propuesta-ia/${cotizacionId}`);
const d = await r.json();
if (d.propuesta) {
setPropuesta(d.propuesta);
setAvisos(Array.isArray(d.avisos) ? d.avisos : []);
setEditada(Boolean(d.editada));
setAbierto(true);
}
} finally {
setCargando(false);
}
}, [cotizacionId]);
useEffect(() => {
void cargar();
}, [cargar]);
async function generar() {
if (propuesta) {
const ok = await confirm({
title: "Regenerar la propuesta",
message: "Se generara una propuesta nueva. La version actual y tus ediciones quedaran reemplazadas.",
confirmText: "Regenerar",
danger: true,
});
if (!ok) return;
}
setGenerando(true);
try {
const r = await fetch(`/api/propuesta-ia/${cotizacionId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ transcripcion }),
});
const d = await r.json();
if (!r.ok) {
toast(d.error || "No se pudo generar la propuesta", "error");
return;
}
setPropuesta(d.propuesta);
setAvisos(Array.isArray(d.avisos) ? d.avisos : []);
setEditada(false);
setSucio(false);
setAbierto(true);
const bloqueantes = (d.avisos as Aviso[]).filter((a) => a.severidad === "bloqueante").length;
toast(
bloqueantes > 0
? `Propuesta generada con ${bloqueantes} aviso(s) que debes resolver antes de enviarla.`
: "Propuesta generada. Revisala antes de enviarla.",
bloqueantes > 0 ? "error" : "success"
);
} catch (e) {
toast(e instanceof Error ? e.message : "Error al generar", "error");
} finally {
setGenerando(false);
}
}
async function guardar() {
if (!propuesta) return;
setGuardando(true);
try {
const r = await fetch(`/api/propuesta-ia/${cotizacionId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ propuesta }),
});
const d = await r.json();
if (!r.ok) {
toast(typeof d.error === "string" ? d.error : "No se pudo guardar", "error");
return;
}
setAvisos(Array.isArray(d.avisos) ? d.avisos : []);
setEditada(true);
setSucio(false);
toast("Cambios guardados y revalidados.", "success");
} finally {
setGuardando(false);
}
}
/** Aplica un cambio al objeto y marca el panel como sucio. */
function editar(fn: (p: Propuesta) => void) {
setPropuesta((prev) => {
if (!prev) return prev;
const copia = structuredClone(prev) as Propuesta;
fn(copia);
return copia;
});
setSucio(true);
}
const bloqueantes = avisos.filter((a) => a.severidad === "bloqueante");
const advertencias = avisos.filter((a) => a.severidad === "advertencia");
if (cargando) {
return (
<div className="bg-card-bg rounded-xl border border-border p-5">
<p className="text-sm text-muted">Cargando propuesta consultiva...</p>
</div>
);
}
return (
<div className="bg-card-bg rounded-xl border border-border overflow-hidden">
<button
onClick={() => setAbierto((v) => !v)}
className="w-full flex items-center justify-between px-5 py-4 hover:bg-gray-50"
>
<span className="flex items-center gap-2 font-semibold">
<Sparkles className="w-4 h-4 text-primary" />
Propuesta consultiva con IA
{editada && (
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-primary-light text-primary">
editada por ti
</span>
)}
</span>
{abierto ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</button>
{abierto && (
<div className="px-5 pb-5 space-y-5 border-t border-border pt-5">
{/* ── Entrada ── */}
<div>
<label className="block text-sm font-medium mb-1">Transcripcion de la reunion (opcional)</label>
<p className="text-xs text-muted mb-2">
Pega aqui lo que se dijo en la reunion de discovery. Sin transcripcion, la IA trabaja
solo con las observaciones y notas de la cotizacion, y marcara mas cosas por validar.
</p>
<textarea
value={transcripcion}
onChange={(e) => setTranscripcion(e.target.value)}
rows={4}
placeholder="Pega la transcripcion..."
className={INPUT}
/>
<button
onClick={generar}
disabled={generando}
className="mt-3 flex items-center gap-2 px-4 py-2 rounded-lg text-sm text-white bg-primary hover:opacity-90 disabled:opacity-50"
>
{generando ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
{generando ? "Generando (tarda un par de minutos)..." : propuesta ? "Regenerar" : "Generar propuesta"}
</button>
</div>
{propuesta && (
<>
{/* ── Avisos: van ARRIBA de las descargas a proposito ── */}
{bloqueantes.length > 0 && (
<div className="rounded-lg border border-red-300 bg-red-50 p-4">
<p className="flex items-center gap-2 text-sm font-semibold text-red-800 mb-2">
<AlertTriangle className="w-4 h-4" />
{bloqueantes.length} aviso(s) que debes resolver antes de enviar
</p>
<ul className="space-y-1">
{bloqueantes.map((a, i) => (
<li key={i} className="text-xs text-red-800">
<span className="font-mono font-semibold">{a.regla}</span>
{a.ruta && <span className="text-red-600"> · {a.ruta}</span>} {a.mensaje}
</li>
))}
</ul>
</div>
)}
{advertencias.length > 0 && (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-4">
<p className="flex items-center gap-2 text-sm font-semibold text-amber-900 mb-2">
<Info className="w-4 h-4" />
{advertencias.length} cosa(s) que conviene revisar
</p>
<ul className="space-y-1">
{advertencias.map((a, i) => (
<li key={i} className="text-xs text-amber-900">
<span className="font-mono font-semibold">{a.regla}</span>
{a.ruta && <span className="opacity-70"> · {a.ruta}</span>} {a.mensaje}
</li>
))}
</ul>
</div>
)}
{avisos.length === 0 && (
<div className="rounded-lg border border-green-300 bg-green-50 p-3">
<p className="text-sm text-green-800">Sin avisos de validacion. Revisa el contenido de todas formas.</p>
</div>
)}
{/* ── Edicion ── */}
<div className="space-y-4">
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-1">Titulo</label>
<input
value={propuesta.redaccion.hero.titulo}
onChange={(e) => editar((p) => { p.redaccion.hero.titulo = e.target.value; })}
className={INPUT}
/>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-1">Subtitulo</label>
<textarea
value={propuesta.redaccion.hero.subtitulo}
onChange={(e) => editar((p) => { p.redaccion.hero.subtitulo = e.target.value; })}
rows={2}
className={INPUT}
/>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
Hallazgos del diagnostico
</label>
<div className="space-y-3">
{propuesta.diagnostico.hallazgos.map((h, i) => (
<div key={h.id} className="border border-border rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<span className={`w-2.5 h-2.5 rounded-full ${COLOR_URGENCIA[h.urgencia]}`} />
<span className="text-[11px] text-muted">
{ETIQUETA_URGENCIA[h.urgencia]} · {h.confianza.replace("_", " ")}
</span>
</div>
<input
value={h.titulo}
onChange={(e) => editar((p) => { p.diagnostico.hallazgos[i].titulo = e.target.value; })}
className={`${INPUT} mb-2 font-medium`}
/>
<textarea
value={h.descripcion}
onChange={(e) => editar((p) => { p.diagnostico.hallazgos[i].descripcion = e.target.value; })}
rows={2}
className={INPUT}
/>
</div>
))}
</div>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
Alcance (la descripcion; los precios los pone el sistema)
</label>
<div className="space-y-2">
{propuesta.redaccion.alcance.map((a, i) => (
<div key={a.refPartida} className="flex gap-2 items-start">
<span className="text-xs font-mono text-muted mt-2.5 shrink-0">{a.refPartida}</span>
<textarea
value={a.descripcionResultado}
onChange={(e) => editar((p) => { p.redaccion.alcance[i].descripcionResultado = e.target.value; })}
rows={2}
className={INPUT}
/>
</div>
))}
</div>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">Exclusiones</label>
<div className="space-y-2">
{propuesta.redaccion.exclusiones.map((ex, i) => (
<textarea
key={i}
value={ex.texto}
onChange={(e) => editar((p) => { p.redaccion.exclusiones[i].texto = e.target.value; })}
rows={2}
className={INPUT}
/>
))}
</div>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
Por que tiene sentido
</label>
<div className="space-y-2">
{propuesta.redaccion.beneficios.map((b, i) => (
<div key={i} className="flex gap-2 items-start">
<input
value={b.etiqueta}
onChange={(e) => editar((p) => { p.redaccion.beneficios[i].etiqueta = e.target.value; })}
className={`${INPUT} max-w-[9rem] shrink-0`}
/>
<textarea
value={b.texto}
onChange={(e) => editar((p) => { p.redaccion.beneficios[i].texto = e.target.value; })}
rows={2}
className={INPUT}
/>
</div>
))}
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-border">
<button
onClick={guardar}
disabled={!sucio || guardando}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm text-white bg-primary hover:opacity-90 disabled:opacity-40"
>
<Save className="w-4 h-4" />
{guardando ? "Guardando..." : sucio ? "Guardar cambios" : "Sin cambios"}
</button>
<a
href={`/api/propuesta-ia/${cotizacionId}/pdf`}
className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm hover:bg-gray-50"
>
<Download className="w-4 h-4" />
Propuesta consultiva (PDF)
</a>
<a
href={`/api/propuesta-ia/${cotizacionId}/pdf?anexo=1`}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm border border-red-300 bg-red-50 text-red-800 hover:bg-red-100"
title="Contiene ponderacion, red flags y notas internas"
>
<Lock className="w-4 h-4" />
Anexo interno NO ENVIAR
</a>
{sucio && (
<span className="text-xs text-amber-700">
Tienes cambios sin guardar. El PDF se genera con lo ultimo guardado.
</span>
)}
</div>
</>
)}
</div>
)}
</div>
);
}