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
File diff suppressed because it is too large
Load Diff
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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