changes
This commit is contained in:
@@ -25,6 +25,34 @@ type StrategicDiagnosticWizardProps = {
|
||||
|
||||
type TabKey = StrategicSectionKey | "results";
|
||||
|
||||
type StrategicAiInsights = {
|
||||
sectionGaps: {
|
||||
sectionKey: StrategicSectionKey;
|
||||
gap: string;
|
||||
impact: string;
|
||||
urgency: "alta" | "media" | "baja";
|
||||
}[];
|
||||
priorityActions: {
|
||||
title: string;
|
||||
description: string;
|
||||
priority: "alta" | "media" | "baja";
|
||||
ownerSuggestion: string;
|
||||
targetDateSuggestion: string;
|
||||
}[];
|
||||
suggestedEvidence: {
|
||||
sectionKey: StrategicSectionKey;
|
||||
category: string;
|
||||
reason: string;
|
||||
}[];
|
||||
suggestedFieldValues: {
|
||||
sectionKey: StrategicSectionKey;
|
||||
fieldPath: string;
|
||||
suggestedValue: string;
|
||||
rationale: string;
|
||||
}[];
|
||||
confidence: "low" | "medium" | "high";
|
||||
};
|
||||
|
||||
const tabItems: { key: TabKey; label: string }[] = [
|
||||
{ key: "technical", label: "Capacidades Tecnicas" },
|
||||
{ key: "experience", label: "Experiencia" },
|
||||
@@ -135,6 +163,40 @@ function SectionScoreBar({ title, subtitle, score }: { title: string; subtitle:
|
||||
);
|
||||
}
|
||||
|
||||
function setNestedValue(target: unknown, pathSegments: string[], value: string): unknown {
|
||||
if (!pathSegments.length || !target || typeof target !== "object" || Array.isArray(target)) {
|
||||
return target;
|
||||
}
|
||||
|
||||
const [head, ...tail] = pathSegments;
|
||||
const record = target as Record<string, unknown>;
|
||||
|
||||
if (!(head in record)) {
|
||||
return target;
|
||||
}
|
||||
|
||||
if (tail.length === 0) {
|
||||
if (typeof record[head] !== "string") {
|
||||
return target;
|
||||
}
|
||||
|
||||
return {
|
||||
...record,
|
||||
[head]: value,
|
||||
};
|
||||
}
|
||||
|
||||
const nextValue = setNestedValue(record[head], tail, value);
|
||||
if (nextValue === record[head]) {
|
||||
return target;
|
||||
}
|
||||
|
||||
return {
|
||||
...record,
|
||||
[head]: nextValue,
|
||||
};
|
||||
}
|
||||
|
||||
export function StrategicDiagnosticWizard({
|
||||
initialData,
|
||||
initialScores,
|
||||
@@ -160,6 +222,10 @@ export function StrategicDiagnosticWizard({
|
||||
organization: STRATEGIC_EVIDENCE_CATEGORY_OPTIONS.organization[0] ?? "Otro",
|
||||
publicProcurement: STRATEGIC_EVIDENCE_CATEGORY_OPTIONS.publicProcurement[0] ?? "Otro",
|
||||
});
|
||||
const [aiInsights, setAiInsights] = useState<StrategicAiInsights | null>(null);
|
||||
const [aiSuggestionId, setAiSuggestionId] = useState<string | null>(null);
|
||||
const [isLoadingAiInsights, setIsLoadingAiInsights] = useState(false);
|
||||
const [isApplyingAiField, setIsApplyingAiField] = useState<string | null>(null);
|
||||
|
||||
const progressPercent = useMemo(() => {
|
||||
return Math.round((scores.completedSections / scores.totalSections) * 100);
|
||||
@@ -185,6 +251,20 @@ export function StrategicDiagnosticWizard({
|
||||
return tabItems[index + 1]?.key ?? "results";
|
||||
}
|
||||
|
||||
async function setAiDecision(decision: "accept" | "dismiss") {
|
||||
if (!aiSuggestionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fetch(`/api/ai/suggestions/${encodeURIComponent(aiSuggestionId)}/decision`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ decision }),
|
||||
});
|
||||
}
|
||||
|
||||
async function persistData(section: TabKey, forceCompleted = false) {
|
||||
setIsSaving(true);
|
||||
setSavingSection(section);
|
||||
@@ -284,6 +364,103 @@ export function StrategicDiagnosticWizard({
|
||||
}
|
||||
}
|
||||
|
||||
async function generateAiInsights() {
|
||||
setIsLoadingAiInsights(true);
|
||||
clearFeedback();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/strategic-diagnostic/ai/insights", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
data,
|
||||
evidenceMetadata: {
|
||||
technical: {
|
||||
count: evidenceBySection.technical.length,
|
||||
categories: Array.from(new Set(evidenceBySection.technical.map((item) => item.category))),
|
||||
},
|
||||
experience: {
|
||||
count: evidenceBySection.experience.length,
|
||||
categories: Array.from(new Set(evidenceBySection.experience.map((item) => item.category))),
|
||||
},
|
||||
organization: {
|
||||
count: evidenceBySection.organization.length,
|
||||
categories: Array.from(new Set(evidenceBySection.organization.map((item) => item.category))),
|
||||
},
|
||||
publicProcurement: {
|
||||
count: evidenceBySection.publicProcurement.length,
|
||||
categories: Array.from(new Set(evidenceBySection.publicProcurement.map((item) => item.category))),
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as {
|
||||
ok?: boolean;
|
||||
error?: string;
|
||||
sectionGaps?: StrategicAiInsights["sectionGaps"];
|
||||
priorityActions?: StrategicAiInsights["priorityActions"];
|
||||
suggestedEvidence?: StrategicAiInsights["suggestedEvidence"];
|
||||
suggestedFieldValues?: StrategicAiInsights["suggestedFieldValues"];
|
||||
confidence?: StrategicAiInsights["confidence"];
|
||||
suggestionId?: string;
|
||||
};
|
||||
|
||||
if (!response.ok || !payload.ok) {
|
||||
setErrorMessage(payload.error ?? "No fue posible generar plan IA.");
|
||||
return;
|
||||
}
|
||||
|
||||
setAiInsights({
|
||||
sectionGaps: payload.sectionGaps ?? [],
|
||||
priorityActions: payload.priorityActions ?? [],
|
||||
suggestedEvidence: payload.suggestedEvidence ?? [],
|
||||
suggestedFieldValues: payload.suggestedFieldValues ?? [],
|
||||
confidence: payload.confidence ?? "low",
|
||||
});
|
||||
setAiSuggestionId(payload.suggestionId ?? null);
|
||||
setSuccessMessage("Plan sugerido por IA generado.");
|
||||
} catch {
|
||||
setErrorMessage("No fue posible generar plan IA.");
|
||||
} finally {
|
||||
setIsLoadingAiInsights(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function applySuggestedFieldValue(fieldPath: string, value: string) {
|
||||
const segments = fieldPath.split(".").map((segment) => segment.trim()).filter(Boolean);
|
||||
|
||||
if (!segments.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsApplyingAiField(fieldPath);
|
||||
clearFeedback();
|
||||
|
||||
try {
|
||||
let applied = false;
|
||||
setData((previous) => {
|
||||
const nextValue = setNestedValue(previous, segments, value);
|
||||
applied = nextValue !== previous;
|
||||
return (nextValue as StrategicDiagnosticData) ?? previous;
|
||||
});
|
||||
|
||||
if (!applied) {
|
||||
setErrorMessage("Este campo no acepta aplicacion directa desde IA. Aplica manualmente.");
|
||||
return;
|
||||
}
|
||||
|
||||
await setAiDecision("accept");
|
||||
setSuccessMessage(`Valor sugerido aplicado: ${fieldPath}.`);
|
||||
} catch {
|
||||
setErrorMessage("No fue posible aplicar el valor sugerido.");
|
||||
} finally {
|
||||
setIsApplyingAiField(null);
|
||||
}
|
||||
}
|
||||
|
||||
function addListItem(section: "technical" | "experience", field: "coverageRegions" | "certifications" | "sectorsServed", rawValue: string) {
|
||||
const value = rawValue.trim();
|
||||
|
||||
@@ -1239,6 +1416,95 @@ export function StrategicDiagnosticWizard({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-[#cfdcf4] bg-[#f7f9ff]">
|
||||
<CardHeader>
|
||||
<h4 className="text-3xl font-semibold text-[#1b2a46]">Plan sugerido por IA</h4>
|
||||
<p className="text-sm text-[#60718e]">Asistencia opcional. Nunca se aplican cambios automaticamente.</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" variant="secondary" disabled={isLoadingAiInsights} onClick={() => void generateAiInsights()}>
|
||||
{isLoadingAiInsights ? "Generando plan IA..." : "Generar plan IA"}
|
||||
</Button>
|
||||
{aiInsights ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
void setAiDecision("dismiss");
|
||||
setAiInsights(null);
|
||||
setSuccessMessage("Plan IA descartado.");
|
||||
}}
|
||||
>
|
||||
Descartar plan IA
|
||||
</Button>
|
||||
) : null}
|
||||
{aiInsights ? (
|
||||
<p className="text-xs font-semibold text-[#405578]">Confianza IA: {aiInsights.confidence}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!aiInsights ? <p className="text-sm text-[#60718e]">Genera un plan IA para ver brechas y acciones priorizadas.</p> : null}
|
||||
|
||||
{aiInsights ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-[#1d335a]">Brechas por seccion</p>
|
||||
<ul className="mt-2 space-y-2 text-sm text-[#4f6385]">
|
||||
{aiInsights.sectionGaps.map((item) => (
|
||||
<li key={`${item.sectionKey}-${item.gap}`} className="rounded-lg border border-[#d8e1ef] bg-white px-3 py-2">
|
||||
<p className="font-semibold">
|
||||
{item.sectionKey} · {item.urgency}
|
||||
</p>
|
||||
<p>{item.gap}</p>
|
||||
<p className="text-xs">{item.impact}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-[#1d335a]">Acciones prioritarias</p>
|
||||
<ul className="mt-2 space-y-2 text-sm text-[#4f6385]">
|
||||
{aiInsights.priorityActions.map((item) => (
|
||||
<li key={`${item.title}-${item.priority}`} className="rounded-lg border border-[#d8e1ef] bg-white px-3 py-2">
|
||||
<p className="font-semibold">{item.title}</p>
|
||||
<p>{item.description}</p>
|
||||
<p className="text-xs">
|
||||
Prioridad: {item.priority} · Responsable sugerido: {item.ownerSuggestion} · Fecha objetivo: {item.targetDateSuggestion}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-[#1d335a]">Campos sugeridos para aplicar</p>
|
||||
<ul className="mt-2 space-y-2 text-sm text-[#4f6385]">
|
||||
{aiInsights.suggestedFieldValues.map((item) => (
|
||||
<li key={`${item.fieldPath}-${item.suggestedValue}`} className="rounded-lg border border-[#d8e1ef] bg-white px-3 py-2">
|
||||
<p className="font-semibold">{item.fieldPath}</p>
|
||||
<p className="mt-1 text-xs">{item.rationale}</p>
|
||||
<p className="mt-1 rounded bg-[#f5f8ff] px-2 py-1 text-xs font-semibold text-[#1d335a]">{item.suggestedValue}</p>
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={isApplyingAiField === item.fieldPath}
|
||||
onClick={() => void applySuggestedFieldValue(item.fieldPath, item.suggestedValue)}
|
||||
>
|
||||
{isApplyingAiField === item.fieldPath ? "Aplicando..." : "Aplicar valor"}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-[#9bd7bf] bg-[#f6fffb]">
|
||||
<CardHeader>
|
||||
<h4 className="text-3xl font-semibold text-[#1b2a46]">Modulo 2 Completado! Siguiente paso recomendado</h4>
|
||||
@@ -1255,8 +1521,8 @@ export function StrategicDiagnosticWizard({
|
||||
<Link href="/licitations">
|
||||
<Button variant="secondary">Ir a Modulo 3</Button>
|
||||
</Link>
|
||||
<Link href="/manual">
|
||||
<Button variant="secondary">Ir a Analisis Normativo (Modulo 4)</Button>
|
||||
<Link href="/normative-analysis">
|
||||
<Button variant="secondary">Ir a Modulo 4: Analisis Normativo</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
Reference in New Issue
Block a user