Files
cotizador/src/components/PropuestaIAPanel.tsx
T
urieljarethandClaude Opus 5 0c127b44c4 Propuesta IA: cerrar los hallazgos medios de la auditoria
El mas urgente lo cause yo al hacer que el bloqueo bloqueara de verdad: si un
aviso bloqueante caia en un campo que el panel no dejaba editar, el asesor
quedaba sin salida salvo regenerar. Ocho campos eran editables y bastantes mas
se imprimen al cliente. Ahora se pueden editar tambien la cita destacada y su
autor, las dimensiones del valor y su nota de metodologia, los resultados con su
metrica y periodo, los materiales, las decisiones con quien decide, y el backlog.
El boton de descarga del documento del cliente aparece deshabilitado cuando hay
bloqueantes, en vez de invitar a un clic que devuelve 409.

Doble propuesta: se rechaza generar. El documento asume UNA lista de alcance y
UNA caja de totales, asi que sumaria las dos opciones —que son alternativas
excluyentes— y mostraria un total que no existe. Soportarlas es rediseniar el
documento; mientras tanto es mejor no producir uno incorrecto. Hoy no hay
cotizaciones dobles en produccion, asi que no bloquea a nadie.

La transcripcion ahora se guarda con la propuesta (columna nueva, migracion
aditiva). Solo vivia en memoria durante la generacion, asi que al editar y
revalidar, R3 se quedaba sin fuente contra la cual comprobar que la cita
destacada siguiera siendo literal, y el aviso desaparecia solo.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 21:15:03 -06:00

591 lines
26 KiB
TypeScript

"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");
const citaDestacada = propuesta?.hechos.citas.find((c) => c.id === propuesta.redaccion.citaDestacadaId);
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>
{/* Los grupos de abajo tambien se imprimen al cliente, asi que tambien
pueden disparar un aviso bloqueante. Si no fueran editables, un
bloqueante ahi no tendria mas salida que regenerar la propuesta. */}
{citaDestacada && (
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-1">
Cita destacada
</label>
<p className="text-xs text-muted mb-2">
Se imprime literal. Editala solo para quitar un dato sensible, nunca para
mejorarle la redaccion al cliente.
</p>
<textarea
value={citaDestacada.textoLiteral}
onChange={(e) =>
editar((p) => {
const c = p.hechos.citas.find((x) => x.id === p.redaccion.citaDestacadaId);
if (c) c.textoLiteral = e.target.value;
})
}
rows={2}
className={`${INPUT} mb-2`}
/>
<input
value={citaDestacada.quienLoDijo}
onChange={(e) =>
editar((p) => {
const c = p.hechos.citas.find((x) => x.id === p.redaccion.citaDestacadaId);
if (c) c.quienLoDijo = e.target.value;
})
}
placeholder="Quien lo dijo (usa el rol, no el nombre)"
className={INPUT}
/>
</div>
)}
{propuesta.diagnostico.valorProblema.dimensiones.length > 0 && (
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
Lo que cuesta no resolverlo
</label>
<div className="space-y-2">
{propuesta.diagnostico.valorProblema.dimensiones.map((dim, i) => (
<div key={i} className="flex gap-2 items-start">
<span className="text-xs text-muted mt-2.5 shrink-0 w-20">{dim.tipo}</span>
<textarea
value={dim.descripcion}
onChange={(e) => editar((p) => { p.diagnostico.valorProblema.dimensiones[i].descripcion = e.target.value; })}
rows={2}
className={INPUT}
/>
</div>
))}
</div>
<textarea
value={propuesta.diagnostico.valorProblema.notaMetodologia}
onChange={(e) => editar((p) => { p.diagnostico.valorProblema.notaMetodologia = e.target.value; })}
rows={2}
placeholder="Nota de metodologia"
className={`${INPUT} mt-2`}
/>
</div>
)}
{propuesta.diagnostico.resultados.length > 0 && (
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
Resultados a lograr
</label>
<div className="space-y-2">
{propuesta.diagnostico.resultados.map((r, i) => (
<div key={i} className="space-y-1">
<textarea
value={r.enunciado}
onChange={(e) => editar((p) => { p.diagnostico.resultados[i].enunciado = e.target.value; })}
rows={2}
className={INPUT}
/>
<div className="flex gap-2">
<input
value={r.metrica}
onChange={(e) => editar((p) => { p.diagnostico.resultados[i].metrica = e.target.value; })}
placeholder="Metrica"
className={INPUT}
/>
<input
value={r.periodoMedicion}
onChange={(e) => editar((p) => { p.diagnostico.resultados[i].periodoMedicion = e.target.value; })}
placeholder="Periodo"
className={INPUT}
/>
</div>
</div>
))}
</div>
</div>
)}
{(propuesta.hechos.materialesPendientes.length > 0 ||
propuesta.hechos.decisionesPendientes.length > 0) && (
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
Que necesitamos de ustedes
</label>
<div className="space-y-2">
{propuesta.hechos.materialesPendientes.map((m, i) => (
<input
key={`mat-${i}`}
value={m.texto}
onChange={(e) => editar((p) => { p.hechos.materialesPendientes[i].texto = e.target.value; })}
className={INPUT}
/>
))}
{propuesta.hechos.decisionesPendientes.map((dd, i) => (
<div key={`dec-${i}`} className="flex gap-2">
<textarea
value={dd.texto}
onChange={(e) => editar((p) => { p.hechos.decisionesPendientes[i].texto = e.target.value; })}
rows={1}
className={INPUT}
/>
<input
value={dd.quienDecide}
onChange={(e) => editar((p) => { p.hechos.decisionesPendientes[i].quienDecide = e.target.value; })}
placeholder="Quien decide"
className={`${INPUT} max-w-[11rem] shrink-0`}
/>
</div>
))}
</div>
</div>
)}
{propuesta.redaccion.backlogEvolucion.length > 0 && (
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
Registrado para mas adelante
</label>
<div className="space-y-2">
{propuesta.redaccion.backlogEvolucion.map((b, i) => (
<div key={i} className="flex gap-2">
<textarea
value={b.problema}
onChange={(e) => editar((p) => { p.redaccion.backlogEvolucion[i].problema = e.target.value; })}
rows={1}
className={INPUT}
/>
<input
value={b.momentoSugerido}
onChange={(e) => editar((p) => { p.redaccion.backlogEvolucion[i].momentoSugerido = e.target.value; })}
placeholder="Cuando"
className={`${INPUT} max-w-[11rem] shrink-0`}
/>
</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>
{/* El servidor devuelve 409 si hay bloqueantes; aqui se refleja para no
invitar a un clic que va a fallar. La descarga del anexo interno
sigue disponible: es la que ayuda a entender que corregir. */}
{bloqueantes.length > 0 ? (
<span
className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm text-muted bg-gray-50 cursor-not-allowed"
title="Resuelve los avisos bloqueantes y guarda para habilitar la descarga"
>
<Download className="w-4 h-4" />
Propuesta consultiva (PDF) bloqueada
</span>
) : (
<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>
);
}