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:
co-authored by
Claude Opus 5
parent
91db8cc55c
commit
ce3a38f89d
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user