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]>
591 lines
26 KiB
TypeScript
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>
|
|
);
|
|
}
|