changes
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { runPeriodicNormativeVerification } from "@/lib/compliance/regulations";
|
||||
import { runDailyLicitationsSync } from "@/lib/licitations/sync";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
@@ -18,6 +19,8 @@ export async function POST(request: Request) {
|
||||
skip?: number;
|
||||
targetYear?: number;
|
||||
includePnt?: boolean;
|
||||
includeLicitaya?: boolean;
|
||||
includeRegulations?: boolean;
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
@@ -26,11 +29,23 @@ export async function POST(request: Request) {
|
||||
limit: typeof body.limit === "number" ? body.limit : undefined,
|
||||
skip: typeof body.skip === "number" ? body.skip : undefined,
|
||||
targetYear: typeof body.targetYear === "number" ? body.targetYear : undefined,
|
||||
includePnt: body.includePnt === true,
|
||||
force: body.force === true,
|
||||
includePnt: typeof body.includePnt === "boolean" ? body.includePnt : undefined,
|
||||
includeLicitaya: typeof body.includeLicitaya === "boolean" ? body.includeLicitaya : undefined,
|
||||
force: typeof body.force === "boolean" ? body.force : undefined,
|
||||
});
|
||||
const includeRegulations = body.includeRegulations !== false;
|
||||
let regulations = null;
|
||||
let regulationsError: string | null = null;
|
||||
|
||||
return NextResponse.json({ ok: true, payload });
|
||||
if (includeRegulations) {
|
||||
try {
|
||||
regulations = await runPeriodicNormativeVerification(new Date());
|
||||
} catch (error) {
|
||||
regulationsError = error instanceof Error ? error.message : "No fue posible verificar reglamentos oficiales.";
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, payload, regulations, regulationsError });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
35
src/app/api/ai/suggestions/[id]/decision/route.ts
Normal file
35
src/app/api/ai/suggestions/[id]/decision/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionPayload } from "@/lib/auth/session";
|
||||
import { applyAiSuggestionDecision } from "@/lib/ai/suggestions";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
function parseDecision(value: unknown) {
|
||||
return value === "accept" || value === "dismiss" ? value : null;
|
||||
}
|
||||
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
const session = await getSessionPayload();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const decision = parseDecision(body.decision);
|
||||
|
||||
if (!id || !decision) {
|
||||
return NextResponse.json({ error: "Decision invalida." }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated = await applyAiSuggestionDecision(session.userId, id, decision);
|
||||
|
||||
if (!updated) {
|
||||
return NextResponse.json({ error: "Sugerencia no encontrada." }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, suggestion: updated });
|
||||
}
|
||||
188
src/app/api/audits/ai/findings/route.ts
Normal file
188
src/app/api/audits/ai/findings/route.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { z } from "zod";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { callOpenAiJsonSchema } from "@/lib/ai/openai";
|
||||
import { storeAiSuggestionFromEnvelope } from "@/lib/ai/suggestions";
|
||||
import { buildInstitutionalDossier, getAuditSimulationDetail, getLatestInstitutionalDossierSnapshot, listAuditSimulationsForUser } from "@/lib/audits/server";
|
||||
|
||||
const M10_PROMPT_VERSION = "m10_findings_v1";
|
||||
|
||||
const M10FindingsSchema = z.object({
|
||||
auditorLikelyFindings: z
|
||||
.array(
|
||||
z.object({
|
||||
area: z.string().min(4).max(120),
|
||||
finding: z.string().min(8).max(500),
|
||||
severity: z.enum(["alto", "medio", "bajo"]),
|
||||
}),
|
||||
)
|
||||
.max(20),
|
||||
missingEvidence: z.array(z.string().min(6).max(280)).max(20),
|
||||
topRisks: z.array(z.string().min(6).max(280)).max(12),
|
||||
remediationPlan: z
|
||||
.array(
|
||||
z.object({
|
||||
step: z.string().min(8).max(420),
|
||||
priority: z.enum(["alta", "media", "baja"]),
|
||||
ownerSuggestion: z.string().min(3).max(120),
|
||||
targetDate: z.string().min(4).max(40),
|
||||
}),
|
||||
)
|
||||
.max(20),
|
||||
confidence: z.enum(["low", "medium", "high"]),
|
||||
});
|
||||
|
||||
const M10FindingsJsonSchema = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["auditorLikelyFindings", "missingEvidence", "topRisks", "remediationPlan", "confidence"],
|
||||
properties: {
|
||||
auditorLikelyFindings: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["area", "finding", "severity"],
|
||||
properties: {
|
||||
area: { type: "string" },
|
||||
finding: { type: "string" },
|
||||
severity: { type: "string", enum: ["alto", "medio", "bajo"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
missingEvidence: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
topRisks: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
remediationPlan: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["step", "priority", "ownerSuggestion", "targetDate"],
|
||||
properties: {
|
||||
step: { type: "string" },
|
||||
priority: { type: "string", enum: ["alta", "media", "baja"] },
|
||||
ownerSuggestion: { type: "string" },
|
||||
targetDate: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
confidence: { type: "string", enum: ["low", "medium", "high"] },
|
||||
},
|
||||
} as const;
|
||||
|
||||
function parseString(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
async function resolveSimulation(userId: string, simulationId: string) {
|
||||
if (simulationId) {
|
||||
return getAuditSimulationDetail(userId, simulationId);
|
||||
}
|
||||
|
||||
const simulations = await listAuditSimulationsForUser(userId);
|
||||
return simulations.find((simulation) => simulation.status === "COMPLETED") ?? simulations[0] ?? null;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const simulationId = parseString(body.simulationId);
|
||||
const simulation = await resolveSimulation(user.id, simulationId);
|
||||
|
||||
if (!simulation) {
|
||||
return NextResponse.json({ error: "No hay simulaciones disponibles para generar dictamen IA." }, { status: 400 });
|
||||
}
|
||||
|
||||
const latestDossier = await getLatestInstitutionalDossierSnapshot(user.id);
|
||||
const dossier = latestDossier?.payload ?? (await buildInstitutionalDossier(user.id));
|
||||
|
||||
const condensedContext = {
|
||||
simulation: {
|
||||
id: simulation.id,
|
||||
name: simulation.name,
|
||||
auditType: simulation.auditType,
|
||||
status: simulation.status,
|
||||
overallScore: simulation.overallScore,
|
||||
completedAt: simulation.completedAt,
|
||||
sections: simulation.sections,
|
||||
},
|
||||
dossier: {
|
||||
generatedAt: dossier.generatedAt,
|
||||
summary: dossier.summary,
|
||||
components: dossier.components,
|
||||
},
|
||||
};
|
||||
|
||||
const systemPrompt = [
|
||||
"Eres auditor preventivo especializado en contratacion publica y cumplimiento documental en Mexico.",
|
||||
"Simula hallazgos probables de una revision formal sin modificar resultados deterministas existentes.",
|
||||
"Debes responder solo JSON valido en espanol.",
|
||||
].join(" ");
|
||||
|
||||
const userPrompt = [
|
||||
"Contexto actual de simulacion y expediente institucional:",
|
||||
JSON.stringify(condensedContext),
|
||||
"",
|
||||
"Genera:",
|
||||
"- auditorLikelyFindings: hallazgos probables por area y severidad.",
|
||||
"- missingEvidence: evidencia concreta faltante.",
|
||||
"- topRisks: riesgos principales priorizados.",
|
||||
"- remediationPlan: plan de remediacion con prioridad, responsable sugerido y fecha objetivo.",
|
||||
].join("\n");
|
||||
|
||||
const envelope = await callOpenAiJsonSchema({
|
||||
promptVersion: M10_PROMPT_VERSION,
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
outputSchema: M10FindingsSchema,
|
||||
schemaName: "m10_audit_findings",
|
||||
jsonSchema: M10FindingsJsonSchema as unknown as Record<string, unknown>,
|
||||
model: process.env.OPENAI_M10_MODEL?.trim() || undefined,
|
||||
});
|
||||
|
||||
const payload =
|
||||
envelope.data ??
|
||||
({
|
||||
auditorLikelyFindings: [],
|
||||
missingEvidence: [],
|
||||
topRisks: [],
|
||||
remediationPlan: [],
|
||||
confidence: envelope.confidence ?? "low",
|
||||
} satisfies z.infer<typeof M10FindingsSchema>);
|
||||
|
||||
const persisted = await storeAiSuggestionFromEnvelope({
|
||||
userId: user.id,
|
||||
moduleKey: "M10",
|
||||
featureKey: "audit_findings",
|
||||
subjectType: "audit_simulation",
|
||||
subjectId: simulation.id,
|
||||
inputForHash: condensedContext,
|
||||
envelope,
|
||||
responsePayload: payload,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
simulationId: simulation.id,
|
||||
...payload,
|
||||
suggestionId: persisted.suggestionId,
|
||||
meta: {
|
||||
engine: envelope.engine,
|
||||
model: envelope.model,
|
||||
usage: envelope.usage,
|
||||
warnings: envelope.warnings,
|
||||
confidence: envelope.confidence,
|
||||
},
|
||||
});
|
||||
}
|
||||
21
src/app/api/audits/expediente/refresh/route.ts
Normal file
21
src/app/api/audits/expediente/refresh/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { refreshInstitutionalDossierSnapshot } from "@/lib/audits/server";
|
||||
|
||||
export async function POST() {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const snapshot = await refreshInstitutionalDossierSnapshot(user.id);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
snapshotId: snapshot.id,
|
||||
generatedAt: snapshot.generatedAt,
|
||||
dossier: snapshot.payload,
|
||||
freshness: snapshot.freshness,
|
||||
});
|
||||
}
|
||||
25
src/app/api/audits/expediente/route.ts
Normal file
25
src/app/api/audits/expediente/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { getInstitutionalDossierForUser } from "@/lib/audits/server";
|
||||
|
||||
function parseStrategy(value: string | null) {
|
||||
return value === "snapshot" ? "snapshot" : "live";
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const strategy = parseStrategy(searchParams.get("strategy"));
|
||||
const resolved = await getInstitutionalDossierForUser(user.id, strategy);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
dossier: resolved.dossier,
|
||||
freshness: resolved.freshness,
|
||||
});
|
||||
}
|
||||
24
src/app/api/audits/simulations/[id]/route.ts
Normal file
24
src/app/api/audits/simulations/[id]/route.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { getAuditSimulationDetail } from "@/lib/audits/server";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function GET(_request: Request, context: RouteContext) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
const simulation = await getAuditSimulationDetail(user.id, id);
|
||||
|
||||
if (!simulation) {
|
||||
return NextResponse.json({ error: "Simulacion no encontrada." }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, simulation });
|
||||
}
|
||||
36
src/app/api/audits/simulations/[id]/score/route.ts
Normal file
36
src/app/api/audits/simulations/[id]/score/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { AuditSimulationScoreInputSchema } from "@/lib/audits/types";
|
||||
import { scoreAuditSimulationForUser } from "@/lib/audits/server";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
function parseJsonBody(body: unknown) {
|
||||
return typeof body === "object" && body !== null ? (body as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
const body = parseJsonBody(await request.json().catch(() => ({})));
|
||||
const parsed = AuditSimulationScoreInputSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Payload de scoring invalido." }, { status: 400 });
|
||||
}
|
||||
|
||||
const simulation = await scoreAuditSimulationForUser(user.id, id, parsed.data);
|
||||
|
||||
if (!simulation) {
|
||||
return NextResponse.json({ error: "Simulacion no encontrada." }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, simulation });
|
||||
}
|
||||
39
src/app/api/audits/simulations/route.ts
Normal file
39
src/app/api/audits/simulations/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import {
|
||||
AuditSimulationCreateInputSchema,
|
||||
} from "@/lib/audits/types";
|
||||
import { createAuditSimulationForUser, getAuditKpisForUser, listAuditSimulationsForUser } from "@/lib/audits/server";
|
||||
|
||||
function parseJsonBody(body: unknown) {
|
||||
return typeof body === "object" && body !== null ? (body as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const [simulations, kpis] = await Promise.all([listAuditSimulationsForUser(user.id), getAuditKpisForUser(user.id)]);
|
||||
return NextResponse.json({ ok: true, simulations, kpis });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = parseJsonBody(await request.json().catch(() => ({})));
|
||||
const parsed = AuditSimulationCreateInputSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Datos de simulacion invalidos." }, { status: 400 });
|
||||
}
|
||||
|
||||
const simulation = await createAuditSimulationForUser(user.id, parsed.data);
|
||||
return NextResponse.json({ ok: true, simulation });
|
||||
}
|
||||
169
src/app/api/compliance/m7/ai/playbook/route.ts
Normal file
169
src/app/api/compliance/m7/ai/playbook/route.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { z } from "zod";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { callOpenAiJsonSchema } from "@/lib/ai/openai";
|
||||
import { storeAiSuggestionFromEnvelope } from "@/lib/ai/suggestions";
|
||||
import { getM7DatasetForUser } from "@/lib/compliance/server";
|
||||
import type { M7Dataset } from "@/lib/compliance/types";
|
||||
|
||||
const M7_PROMPT_VERSION = "m7_playbook_v1";
|
||||
|
||||
const M7PlaybookSchema = z.object({
|
||||
predictedIncidents: z
|
||||
.array(
|
||||
z.object({
|
||||
title: z.string().min(8).max(220),
|
||||
likelihood: z.enum(["alta", "media", "baja"]),
|
||||
impact: z.enum(["alto", "medio", "bajo"]),
|
||||
timeHorizon: z.string().min(4).max(80),
|
||||
}),
|
||||
)
|
||||
.max(12),
|
||||
priorityOrder: z.array(z.string().min(8).max(220)).max(12),
|
||||
preventiveActions: z
|
||||
.array(
|
||||
z.object({
|
||||
action: z.string().min(8).max(400),
|
||||
ownerSuggestion: z.string().min(3).max(120),
|
||||
targetDate: z.string().min(4).max(40),
|
||||
}),
|
||||
)
|
||||
.max(20),
|
||||
escalationAdvice: z.array(z.string().min(8).max(420)).max(8),
|
||||
confidence: z.enum(["low", "medium", "high"]),
|
||||
});
|
||||
|
||||
const M7PlaybookJsonSchema = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["predictedIncidents", "priorityOrder", "preventiveActions", "escalationAdvice", "confidence"],
|
||||
properties: {
|
||||
predictedIncidents: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["title", "likelihood", "impact", "timeHorizon"],
|
||||
properties: {
|
||||
title: { type: "string" },
|
||||
likelihood: { type: "string", enum: ["alta", "media", "baja"] },
|
||||
impact: { type: "string", enum: ["alto", "medio", "bajo"] },
|
||||
timeHorizon: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
priorityOrder: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
preventiveActions: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["action", "ownerSuggestion", "targetDate"],
|
||||
properties: {
|
||||
action: { type: "string" },
|
||||
ownerSuggestion: { type: "string" },
|
||||
targetDate: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
escalationAdvice: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
confidence: { type: "string", enum: ["low", "medium", "high"] },
|
||||
},
|
||||
} as const;
|
||||
|
||||
function parseDataset(value: unknown): M7Dataset | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value as M7Dataset;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const providedDataset = parseDataset(body.dataset);
|
||||
const dataset = providedDataset ?? (await getM7DatasetForUser(user.id));
|
||||
|
||||
const condensedDataset = {
|
||||
generatedAt: dataset.generatedAt,
|
||||
kpis: dataset.kpis,
|
||||
m3States: dataset.m3States,
|
||||
deadlines: dataset.tabs.plazos.slice(0, 25),
|
||||
alerts: dataset.tabs.alertas.slice(0, 40),
|
||||
checklist: dataset.tabs.checklist.slice(0, 25),
|
||||
};
|
||||
|
||||
const systemPrompt = [
|
||||
"Eres un especialista en cumplimiento para contratacion publica en Mexico.",
|
||||
"Construye un playbook preventivo con enfoque operativo para los proximos 30 dias.",
|
||||
"No cambies severidades ni estados existentes: solo sugiere acciones.",
|
||||
"Responde solo JSON valido en espanol.",
|
||||
].join(" ");
|
||||
|
||||
const userPrompt = [
|
||||
"Dataset actual de M7:",
|
||||
JSON.stringify(condensedDataset),
|
||||
"",
|
||||
"Genera:",
|
||||
"- predictedIncidents: incidentes probables (sin inventar datos externos).",
|
||||
"- priorityOrder: orden de atencion recomendado.",
|
||||
"- preventiveActions: acciones con ownerSuggestion y targetDate.",
|
||||
"- escalationAdvice: criterios breves para escalar a legal/direccion.",
|
||||
].join("\n");
|
||||
|
||||
const envelope = await callOpenAiJsonSchema({
|
||||
promptVersion: M7_PROMPT_VERSION,
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
outputSchema: M7PlaybookSchema,
|
||||
schemaName: "m7_playbook",
|
||||
jsonSchema: M7PlaybookJsonSchema as unknown as Record<string, unknown>,
|
||||
model: process.env.OPENAI_M7_MODEL?.trim() || undefined,
|
||||
});
|
||||
|
||||
const payload =
|
||||
envelope.data ??
|
||||
({
|
||||
predictedIncidents: [],
|
||||
priorityOrder: [],
|
||||
preventiveActions: [],
|
||||
escalationAdvice: [],
|
||||
confidence: envelope.confidence ?? "low",
|
||||
} satisfies z.infer<typeof M7PlaybookSchema>);
|
||||
|
||||
const persisted = await storeAiSuggestionFromEnvelope({
|
||||
userId: user.id,
|
||||
moduleKey: "M7",
|
||||
featureKey: "compliance_playbook",
|
||||
subjectType: "m7_dataset",
|
||||
subjectId: dataset.generatedAt,
|
||||
inputForHash: condensedDataset,
|
||||
envelope,
|
||||
responsePayload: payload,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
...payload,
|
||||
suggestionId: persisted.suggestionId,
|
||||
meta: {
|
||||
engine: envelope.engine,
|
||||
model: envelope.model,
|
||||
usage: envelope.usage,
|
||||
warnings: envelope.warnings,
|
||||
confidence: envelope.confidence,
|
||||
},
|
||||
});
|
||||
}
|
||||
14
src/app/api/compliance/m7/route.ts
Normal file
14
src/app/api/compliance/m7/route.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { getM7DatasetForUser } from "@/lib/compliance/server";
|
||||
|
||||
export async function GET() {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const dataset = await getM7DatasetForUser(user.id);
|
||||
return NextResponse.json({ ok: true, dataset });
|
||||
}
|
||||
75
src/app/api/contracts/[id]/deliverables/route.ts
Normal file
75
src/app/api/contracts/[id]/deliverables/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { DeliverableCreateInputSchema } from "@/lib/contracts/types";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
function parseJsonBody(body: unknown) {
|
||||
return typeof body === "object" && body !== null ? (body as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id: contractId } = await context.params;
|
||||
|
||||
try {
|
||||
const body = parseJsonBody(await request.json().catch(() => ({})));
|
||||
const parsed = DeliverableCreateInputSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Datos de entregable invalidos." }, { status: 400 });
|
||||
}
|
||||
|
||||
const contract = await prisma.contractRecord.findFirst({
|
||||
where: {
|
||||
id: contractId,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!contract) {
|
||||
return NextResponse.json({ error: "Contrato no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
|
||||
const deliverable = await prisma.contractDeliverable.create({
|
||||
data: {
|
||||
contractId,
|
||||
title: input.title,
|
||||
dueDate: input.dueDate ? new Date(input.dueDate) : null,
|
||||
amountLinked: input.amountLinked ?? null,
|
||||
notes: input.notes ?? "",
|
||||
status: "PENDING",
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
deliverable: {
|
||||
id: deliverable.id,
|
||||
contractId: deliverable.contractId,
|
||||
title: deliverable.title,
|
||||
dueDate: deliverable.dueDate ? deliverable.dueDate.toISOString() : null,
|
||||
amountLinked: deliverable.amountLinked ? Number(deliverable.amountLinked) : null,
|
||||
status: deliverable.status,
|
||||
deliveredAt: deliverable.deliveredAt ? deliverable.deliveredAt.toISOString() : null,
|
||||
approvedAt: deliverable.approvedAt ? deliverable.approvedAt.toISOString() : null,
|
||||
notes: deliverable.notes,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: "No fue posible crear el entregable." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { requireAdminApiUserMock, readStoredContractDocumentFileMock, prismaMock } = vi.hoisted(() => ({
|
||||
requireAdminApiUserMock: vi.fn(),
|
||||
readStoredContractDocumentFileMock: vi.fn(),
|
||||
prismaMock: {
|
||||
contractDocument: {
|
||||
findFirst: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/admin", () => ({
|
||||
requireAdminApiUser: requireAdminApiUserMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/contracts/storage", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/contracts/storage")>("@/lib/contracts/storage");
|
||||
return {
|
||||
...actual,
|
||||
readStoredContractDocumentFile: readStoredContractDocumentFileMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
import { GET } from "@/app/api/contracts/[id]/documents/[documentId]/route";
|
||||
|
||||
describe("contract document download API security", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns 404 for non-owned or missing contract document", async () => {
|
||||
requireAdminApiUserMock.mockResolvedValue({ id: "user-1" });
|
||||
prismaMock.contractDocument.findFirst.mockResolvedValue(null);
|
||||
|
||||
const response = await GET(new Request("http://localhost/api/contracts/c1/documents/d1"), {
|
||||
params: Promise.resolve({ id: "c1", documentId: "d1" }),
|
||||
});
|
||||
const payload = (await response.json()) as { error?: string };
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toContain("Documento no encontrado");
|
||||
expect(readStoredContractDocumentFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 when contract storage path is invalid", async () => {
|
||||
requireAdminApiUserMock.mockResolvedValue({ id: "user-1" });
|
||||
prismaMock.contractDocument.findFirst.mockResolvedValue({
|
||||
fileName: "contrato.pdf",
|
||||
filePath: "../../escape.pdf",
|
||||
mimeType: "application/pdf",
|
||||
});
|
||||
readStoredContractDocumentFileMock.mockRejectedValue(new Error("CONTRACT_DOCUMENT_INVALID_PATH"));
|
||||
|
||||
const response = await GET(new Request("http://localhost/api/contracts/c1/documents/d1"), {
|
||||
params: Promise.resolve({ id: "c1", documentId: "d1" }),
|
||||
});
|
||||
const payload = (await response.json()) as { error?: string };
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toContain("Ruta de almacenamiento invalida");
|
||||
});
|
||||
});
|
||||
150
src/app/api/contracts/[id]/documents/[documentId]/route.ts
Normal file
150
src/app/api/contracts/[id]/documents/[documentId]/route.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { readStoredContractDocumentFile, removeStoredContractDocumentFile } from "@/lib/contracts/storage";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function isSchemaNotReadyError(error: unknown) {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
|
||||
}
|
||||
|
||||
function safeContentDispositionFilename(value: string) {
|
||||
const normalized = value.trim().replace(/[\r\n"]/g, "_");
|
||||
return normalized || "contrato-documento";
|
||||
}
|
||||
|
||||
function mapDocumentReadError(error: unknown) {
|
||||
if (!(error instanceof Error)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (error.message === "CONTRACT_DOCUMENT_NOT_FOUND") {
|
||||
return { status: 404, message: "Archivo no encontrado en almacenamiento." };
|
||||
}
|
||||
|
||||
if (error.message === "CONTRACT_DOCUMENT_INVALID_PATH") {
|
||||
return { status: 400, message: "Ruta de almacenamiento invalida." };
|
||||
}
|
||||
|
||||
if (error.message === "CONTRACT_DOCUMENT_EMPTY") {
|
||||
return { status: 400, message: "Archivo vacio en almacenamiento." };
|
||||
}
|
||||
|
||||
if (error.message === "CONTRACT_DOCUMENT_TOO_LARGE") {
|
||||
return { status: 400, message: "Archivo excede el limite permitido." };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET(_: Request, context: { params: Promise<{ id: string; documentId: string }> }) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id, documentId } = await context.params;
|
||||
|
||||
const document = await prisma.contractDocument.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
contractId: id,
|
||||
contract: {
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
fileName: true,
|
||||
filePath: true,
|
||||
mimeType: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
return NextResponse.json({ error: "Documento no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
let fileBuffer: Buffer;
|
||||
try {
|
||||
fileBuffer = await readStoredContractDocumentFile(document.filePath);
|
||||
} catch (error) {
|
||||
const mapped = mapDocumentReadError(error);
|
||||
if (mapped) {
|
||||
return NextResponse.json({ error: mapped.message }, { status: mapped.status });
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const encodedFilename = encodeURIComponent(document.fileName);
|
||||
return new NextResponse(new Uint8Array(fileBuffer), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": document.mimeType || "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="${safeContentDispositionFilename(document.fileName)}"; filename*=UTF-8''${encodedFilename}`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene las tablas de Gestion Estrategica de Contratos. Ejecuta prisma migrate." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible descargar el documento." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_: Request, context: { params: Promise<{ id: string; documentId: string }> }) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id, documentId } = await context.params;
|
||||
|
||||
const document = await prisma.contractDocument.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
contractId: id,
|
||||
contract: {
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
filePath: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
return NextResponse.json({ error: "Documento no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
await removeStoredContractDocumentFile(document.filePath);
|
||||
await prisma.contractDocument.delete({
|
||||
where: {
|
||||
id: document.id,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene las tablas de Gestion Estrategica de Contratos. Ejecuta prisma migrate." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible eliminar el documento." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
116
src/app/api/contracts/[id]/payments/route.ts
Normal file
116
src/app/api/contracts/[id]/payments/route.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { PaymentCreateInputSchema } from "@/lib/contracts/types";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
function parseJsonBody(body: unknown) {
|
||||
return typeof body === "object" && body !== null ? (body as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
export async function GET(_request: Request, context: RouteContext) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id: contractId } = await context.params;
|
||||
|
||||
const contract = await prisma.contractRecord.findFirst({
|
||||
where: {
|
||||
id: contractId,
|
||||
userId: user.id,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!contract) {
|
||||
return NextResponse.json({ error: "Contrato no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
const payments = await prisma.contractPayment.findMany({
|
||||
where: {
|
||||
contractId,
|
||||
},
|
||||
orderBy: [{ paymentDate: "desc" }, { createdAt: "desc" }],
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
payments: payments.map((item) => ({
|
||||
id: item.id,
|
||||
contractId: item.contractId,
|
||||
amount: Number(item.amount),
|
||||
paymentDate: item.paymentDate.toISOString(),
|
||||
invoiceNumber: item.invoiceNumber,
|
||||
concept: item.concept,
|
||||
status: item.status,
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
updatedAt: item.updatedAt.toISOString(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id: contractId } = await context.params;
|
||||
|
||||
try {
|
||||
const body = parseJsonBody(await request.json().catch(() => ({})));
|
||||
const parsed = PaymentCreateInputSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Datos de pago invalidos." }, { status: 400 });
|
||||
}
|
||||
|
||||
const contract = await prisma.contractRecord.findFirst({
|
||||
where: {
|
||||
id: contractId,
|
||||
userId: user.id,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!contract) {
|
||||
return NextResponse.json({ error: "Contrato no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
const payment = await prisma.contractPayment.create({
|
||||
data: {
|
||||
contractId,
|
||||
amount: input.amount,
|
||||
paymentDate: new Date(input.paymentDate),
|
||||
invoiceNumber: input.invoiceNumber ?? null,
|
||||
concept: input.concept,
|
||||
status: input.status ?? "REGISTERED",
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
payment: {
|
||||
id: payment.id,
|
||||
contractId: payment.contractId,
|
||||
amount: Number(payment.amount),
|
||||
paymentDate: payment.paymentDate.toISOString(),
|
||||
invoiceNumber: payment.invoiceNumber,
|
||||
concept: payment.concept,
|
||||
status: payment.status,
|
||||
createdAt: payment.createdAt.toISOString(),
|
||||
updatedAt: payment.updatedAt.toISOString(),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: "No fue posible registrar el pago." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
70
src/app/api/contracts/[id]/route.ts
Normal file
70
src/app/api/contracts/[id]/route.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ContractUpdateInputSchema } from "@/lib/contracts/types";
|
||||
import { listContractsForUser } from "@/lib/contracts/server";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
function parseJsonBody(body: unknown) {
|
||||
return typeof body === "object" && body !== null ? (body as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, context: RouteContext) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
|
||||
try {
|
||||
const body = parseJsonBody(await request.json().catch(() => ({})));
|
||||
const parsed = ContractUpdateInputSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Datos de actualizacion invalidos." }, { status: 400 });
|
||||
}
|
||||
|
||||
const current = await prisma.contractRecord.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: user.id,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!current) {
|
||||
return NextResponse.json({ error: "Contrato no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
|
||||
await prisma.contractRecord.update({
|
||||
where: { id },
|
||||
data: {
|
||||
title: input.title,
|
||||
counterpartyEntity: input.counterpartyEntity,
|
||||
contractNumber: input.contractNumber === undefined ? undefined : input.contractNumber ?? null,
|
||||
contractType: input.contractType,
|
||||
startDate: input.startDate === undefined ? undefined : input.startDate ? new Date(input.startDate) : null,
|
||||
endDate: input.endDate === undefined ? undefined : input.endDate ? new Date(input.endDate) : null,
|
||||
totalAmount: input.totalAmount === undefined ? undefined : input.totalAmount ?? null,
|
||||
currency: input.currency ? input.currency.toUpperCase() : undefined,
|
||||
status: input.status,
|
||||
description: input.description,
|
||||
sourceProposalId: input.sourceProposalId === undefined ? undefined : input.sourceProposalId ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
const contracts = await listContractsForUser(user.id, { q: id });
|
||||
const contract = contracts.find((item) => item.id === id) ?? null;
|
||||
|
||||
return NextResponse.json({ ok: true, contract });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "No fue posible actualizar el contrato." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
297
src/app/api/contracts/extract/route.ts
Normal file
297
src/app/api/contracts/extract/route.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { analyzePdf } from "@/lib/pdf/analyzePdf";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { contractCreateInputFromExtraction } from "@/lib/contracts/server";
|
||||
import { extractContractWithAi } from "@/lib/contracts/extraction";
|
||||
import { MAX_CONTRACT_PDF_BYTES, storeContractDocumentFile } from "@/lib/contracts/storage";
|
||||
import {
|
||||
ensureProposalPdfLinkedToContract,
|
||||
getLatestProposalPdfSourceForUser,
|
||||
} from "@/lib/contracts/proposal-continuity";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function isPdfFile(file: File) {
|
||||
return file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf");
|
||||
}
|
||||
|
||||
function removePdfExtension(fileName: string) {
|
||||
return fileName.replace(/\.pdf$/i, "").trim();
|
||||
}
|
||||
|
||||
function logContinuityHandoff(event: string, data: Record<string, unknown>) {
|
||||
console.info(
|
||||
"[continuity_handoff]",
|
||||
JSON.stringify({
|
||||
event,
|
||||
at: new Date().toISOString(),
|
||||
...data,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const contractIdRaw = formData.get("contractId");
|
||||
const contractId = typeof contractIdRaw === "string" ? contractIdRaw.trim() : "";
|
||||
const sourceProposalIdRaw = formData.get("sourceProposalId");
|
||||
const sourceProposalId = typeof sourceProposalIdRaw === "string" ? sourceProposalIdRaw.trim() : "";
|
||||
const fileValue = formData.get("file");
|
||||
const uploadedFile = fileValue instanceof File ? fileValue : null;
|
||||
|
||||
logContinuityHandoff("m5_to_m8_extract_requested", {
|
||||
userId: user.id,
|
||||
contractId: contractId || null,
|
||||
sourceProposalId: sourceProposalId || null,
|
||||
hasUploadedFile: Boolean(uploadedFile),
|
||||
});
|
||||
|
||||
let sourceProposalLookup: Awaited<ReturnType<typeof getLatestProposalPdfSourceForUser>> | null = null;
|
||||
let resolvedSourceProposalId: string | null = null;
|
||||
if (sourceProposalId) {
|
||||
try {
|
||||
sourceProposalLookup = await getLatestProposalPdfSourceForUser(user.id, sourceProposalId);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "No fue posible leer el PDF de la propuesta fuente." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (sourceProposalLookup.status === "proposal_not_found") {
|
||||
return NextResponse.json({ error: "Propuesta fuente no encontrada." }, { status: 404 });
|
||||
}
|
||||
|
||||
resolvedSourceProposalId = sourceProposalId;
|
||||
logContinuityHandoff("m5_to_m8_source_proposal_resolved", {
|
||||
userId: user.id,
|
||||
sourceProposalId: resolvedSourceProposalId,
|
||||
lookupStatus: sourceProposalLookup.status,
|
||||
});
|
||||
}
|
||||
|
||||
let fileName = "";
|
||||
let mimeType = "application/pdf";
|
||||
let fileBuffer: Buffer;
|
||||
|
||||
if (uploadedFile) {
|
||||
if (uploadedFile.size <= 0) {
|
||||
return NextResponse.json({ error: "El archivo esta vacio." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (uploadedFile.size > MAX_CONTRACT_PDF_BYTES) {
|
||||
return NextResponse.json({ error: "El archivo excede el limite de 20MB." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!isPdfFile(uploadedFile)) {
|
||||
return NextResponse.json({ error: "Solo se permite PDF en extraccion de contrato." }, { status: 400 });
|
||||
}
|
||||
|
||||
fileName = uploadedFile.name;
|
||||
mimeType = uploadedFile.type || "application/pdf";
|
||||
fileBuffer = Buffer.from(await uploadedFile.arrayBuffer());
|
||||
} else {
|
||||
if (!sourceProposalLookup) {
|
||||
return NextResponse.json({ error: "Debes adjuntar un contrato PDF o seleccionar una propuesta de Modulo 5." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (sourceProposalLookup.status !== "ok") {
|
||||
return NextResponse.json({ error: "La propuesta fuente no tiene un PDF disponible para reutilizar." }, { status: 400 });
|
||||
}
|
||||
|
||||
fileName = sourceProposalLookup.source.document.fileName;
|
||||
mimeType = sourceProposalLookup.source.document.mimeType;
|
||||
fileBuffer = sourceProposalLookup.source.fileBuffer;
|
||||
}
|
||||
|
||||
const analyzed = await analyzePdf(fileBuffer);
|
||||
const extraction = await extractContractWithAi(analyzed.text);
|
||||
|
||||
let contract = null as { id: string } | null;
|
||||
|
||||
if (contractId) {
|
||||
const existing = await prisma.contractRecord.findFirst({
|
||||
where: {
|
||||
id: contractId,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: "Contrato no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
const base = contractCreateInputFromExtraction({
|
||||
extraction,
|
||||
fallbackTitle: removePdfExtension(fileName) || "Contrato sin titulo",
|
||||
});
|
||||
|
||||
await prisma.contractRecord.update({
|
||||
where: {
|
||||
id: existing.id,
|
||||
},
|
||||
data: {
|
||||
title: extraction.fields.title ? base.title : undefined,
|
||||
counterpartyEntity: extraction.fields.counterpartyEntity ? base.counterpartyEntity : undefined,
|
||||
contractNumber: extraction.fields.contractNumber === null ? undefined : base.contractNumber,
|
||||
contractType: extraction.fields.contractType ? base.contractType : undefined,
|
||||
startDate: extraction.fields.startDate ? new Date(extraction.fields.startDate) : undefined,
|
||||
endDate: extraction.fields.endDate ? new Date(extraction.fields.endDate) : undefined,
|
||||
totalAmount: extraction.fields.totalAmount === null ? undefined : base.totalAmount,
|
||||
currency: base.currency,
|
||||
description: extraction.fields.description ? base.description : undefined,
|
||||
sourceProposalId: resolvedSourceProposalId ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
contract = existing;
|
||||
} else {
|
||||
const base = contractCreateInputFromExtraction({
|
||||
extraction,
|
||||
fallbackTitle: removePdfExtension(fileName) || "Contrato sin titulo",
|
||||
});
|
||||
|
||||
contract = await prisma.contractRecord.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
sourceProposalId: resolvedSourceProposalId,
|
||||
title: base.title,
|
||||
counterpartyEntity: base.counterpartyEntity,
|
||||
contractNumber: base.contractNumber,
|
||||
contractType: base.contractType,
|
||||
startDate: base.startDate ? new Date(base.startDate) : null,
|
||||
endDate: base.endDate ? new Date(base.endDate) : null,
|
||||
totalAmount: base.totalAmount,
|
||||
currency: base.currency,
|
||||
status: base.status,
|
||||
description: base.description,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const targetContractId = contract.id;
|
||||
logContinuityHandoff("m5_to_m8_contract_target_resolved", {
|
||||
userId: user.id,
|
||||
targetContractId,
|
||||
sourceProposalId: resolvedSourceProposalId,
|
||||
mode: contractId ? "update" : "create",
|
||||
});
|
||||
|
||||
const [deliverableCount, paymentCount] = await Promise.all([
|
||||
prisma.contractDeliverable.count({
|
||||
where: {
|
||||
contractId: targetContractId,
|
||||
},
|
||||
}),
|
||||
prisma.contractPayment.count({
|
||||
where: {
|
||||
contractId: targetContractId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
if (deliverableCount === 0 && extraction.deliverables.length > 0) {
|
||||
await prisma.contractDeliverable.createMany({
|
||||
data: extraction.deliverables.map((item) => ({
|
||||
contractId: targetContractId,
|
||||
title: item.title,
|
||||
dueDate: item.dueDate ? new Date(item.dueDate) : null,
|
||||
amountLinked: item.amountLinked,
|
||||
notes: item.notes ?? "Detectado automaticamente por IA.",
|
||||
status: "PENDING",
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (paymentCount === 0 && extraction.paymentMilestones.length > 0) {
|
||||
await prisma.contractPayment.createMany({
|
||||
data: extraction.paymentMilestones
|
||||
.filter((item) => item.paymentDate)
|
||||
.map((item) => ({
|
||||
contractId: targetContractId,
|
||||
amount: item.amount ?? 0,
|
||||
paymentDate: new Date(item.paymentDate as string),
|
||||
concept: item.concept,
|
||||
status: "REGISTERED",
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
let uploadedDocumentId: string | null = null;
|
||||
if (uploadedFile) {
|
||||
const stored = await storeContractDocumentFile(user.id, targetContractId, fileName, mimeType, fileBuffer);
|
||||
const createdDocument = await prisma.contractDocument.create({
|
||||
data: {
|
||||
contractId: targetContractId,
|
||||
fileName: stored.fileName,
|
||||
filePath: stored.filePath,
|
||||
mimeType: stored.mimeType,
|
||||
sizeBytes: stored.sizeBytes,
|
||||
checksumSha256: stored.checksumSha256,
|
||||
kind: "SIGNED_CONTRACT",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
uploadedDocumentId = createdDocument.id;
|
||||
} else if (sourceProposalLookup?.status === "ok") {
|
||||
const linked = await ensureProposalPdfLinkedToContract({
|
||||
userId: user.id,
|
||||
contractId: targetContractId,
|
||||
source: sourceProposalLookup.source,
|
||||
});
|
||||
uploadedDocumentId = linked.documentId;
|
||||
}
|
||||
|
||||
await prisma.contractExtractionHistory.create({
|
||||
data: {
|
||||
contractId: targetContractId,
|
||||
userId: user.id,
|
||||
engine: extraction.engine,
|
||||
model: extraction.model,
|
||||
resultJson: extraction as unknown as Prisma.InputJsonValue,
|
||||
warningsJson: extraction.warnings as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
const fullContract = await prisma.contractRecord.findUnique({
|
||||
where: { id: targetContractId },
|
||||
include: {
|
||||
deliverables: {
|
||||
orderBy: [{ dueDate: "asc" }, { createdAt: "asc" }],
|
||||
},
|
||||
payments: {
|
||||
orderBy: [{ paymentDate: "desc" }, { createdAt: "desc" }],
|
||||
},
|
||||
documents: {
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
contract: fullContract,
|
||||
extraction: {
|
||||
...extraction,
|
||||
analyzed: {
|
||||
methodUsed: analyzed.methodUsed,
|
||||
numPages: analyzed.numPages,
|
||||
warnings: analyzed.warnings,
|
||||
},
|
||||
},
|
||||
uploadedDocumentId,
|
||||
});
|
||||
}
|
||||
14
src/app/api/contracts/kpis/route.ts
Normal file
14
src/app/api/contracts/kpis/route.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { getContractsKpisForUser } from "@/lib/contracts/server";
|
||||
|
||||
export async function GET() {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const kpis = await getContractsKpisForUser(user.id);
|
||||
return NextResponse.json({ ok: true, kpis });
|
||||
}
|
||||
100
src/app/api/contracts/route.ts
Normal file
100
src/app/api/contracts/route.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
ContractCreateInputSchema,
|
||||
ContractListFiltersSchema,
|
||||
} from "@/lib/contracts/types";
|
||||
import { listContractsForUser } from "@/lib/contracts/server";
|
||||
|
||||
function isSchemaNotReadyError(error: unknown) {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
|
||||
}
|
||||
|
||||
function parseJsonBody(body: unknown) {
|
||||
return typeof body === "object" && body !== null ? (body as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const parsed = ContractListFiltersSchema.safeParse({
|
||||
q: searchParams.get("q") ?? undefined,
|
||||
status: searchParams.get("status") ?? undefined,
|
||||
sort: searchParams.get("sort") ?? undefined,
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Filtros invalidos." }, { status: 400 });
|
||||
}
|
||||
|
||||
const contracts = await listContractsForUser(user.id, parsed.data);
|
||||
return NextResponse.json({ ok: true, contracts });
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene las tablas de Gestion Estrategica de Contratos. Ejecuta prisma migrate." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible obtener contratos." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = parseJsonBody(await request.json().catch(() => ({})));
|
||||
const parsed = ContractCreateInputSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Datos de contrato invalidos." }, { status: 400 });
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
const created = await prisma.contractRecord.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
sourceProposalId: input.sourceProposalId ?? null,
|
||||
title: input.title,
|
||||
counterpartyEntity: input.counterpartyEntity,
|
||||
contractNumber: input.contractNumber ?? null,
|
||||
contractType: input.contractType,
|
||||
startDate: input.startDate ? new Date(input.startDate) : null,
|
||||
endDate: input.endDate ? new Date(input.endDate) : null,
|
||||
totalAmount: input.totalAmount ?? null,
|
||||
currency: (input.currency ?? "MXN").toUpperCase(),
|
||||
status: input.status ?? "ACTIVE",
|
||||
description: input.description ?? "",
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const contracts = await listContractsForUser(user.id, { q: created.id });
|
||||
const contract = contracts.find((item) => item.id === created.id) ?? null;
|
||||
|
||||
return NextResponse.json({ ok: true, contract });
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene las tablas de Gestion Estrategica de Contratos. Ejecuta prisma migrate." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible crear el contrato." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
103
src/app/api/contracts/upload/route.ts
Normal file
103
src/app/api/contracts/upload/route.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { MAX_CONTRACT_PDF_BYTES, storeContractDocumentFile } from "@/lib/contracts/storage";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function isPdfFile(file: File) {
|
||||
return file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf");
|
||||
}
|
||||
|
||||
function parseKind(value: FormDataEntryValue | null) {
|
||||
const asString = typeof value === "string" ? value.trim().toUpperCase() : "";
|
||||
|
||||
if (
|
||||
asString === "SIGNED_CONTRACT" ||
|
||||
asString === "ADDENDUM" ||
|
||||
asString === "DELIVERABLE_EVIDENCE" ||
|
||||
asString === "PAYMENT_EVIDENCE" ||
|
||||
asString === "OTHER"
|
||||
) {
|
||||
return asString;
|
||||
}
|
||||
|
||||
return "SIGNED_CONTRACT";
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const contractIdValue = formData.get("contractId");
|
||||
const fileValue = formData.get("file");
|
||||
|
||||
if (!(fileValue instanceof File)) {
|
||||
return NextResponse.json({ error: "Debes adjuntar un archivo." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (fileValue.size <= 0) {
|
||||
return NextResponse.json({ error: "El archivo esta vacio." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (fileValue.size > MAX_CONTRACT_PDF_BYTES) {
|
||||
return NextResponse.json({ error: "El archivo excede el limite de 20MB." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!isPdfFile(fileValue)) {
|
||||
return NextResponse.json({ error: "Solo se permiten archivos PDF para este flujo." }, { status: 400 });
|
||||
}
|
||||
|
||||
const contractId = typeof contractIdValue === "string" ? contractIdValue.trim() : "";
|
||||
|
||||
if (!contractId) {
|
||||
return NextResponse.json({ error: "contractId es requerido." }, { status: 400 });
|
||||
}
|
||||
|
||||
const contract = await prisma.contractRecord.findFirst({
|
||||
where: {
|
||||
id: contractId,
|
||||
userId: user.id,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!contract) {
|
||||
return NextResponse.json({ error: "Contrato no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
const fileBuffer = Buffer.from(await fileValue.arrayBuffer());
|
||||
const stored = await storeContractDocumentFile(user.id, contractId, fileValue.name, fileValue.type, fileBuffer);
|
||||
const kind = parseKind(formData.get("kind"));
|
||||
|
||||
const document = await prisma.contractDocument.create({
|
||||
data: {
|
||||
contractId,
|
||||
fileName: stored.fileName,
|
||||
filePath: stored.filePath,
|
||||
mimeType: stored.mimeType,
|
||||
sizeBytes: stored.sizeBytes,
|
||||
checksumSha256: stored.checksumSha256,
|
||||
kind,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
document: {
|
||||
id: document.id,
|
||||
contractId: document.contractId,
|
||||
fileName: document.fileName,
|
||||
filePath: document.filePath,
|
||||
mimeType: document.mimeType,
|
||||
sizeBytes: document.sizeBytes,
|
||||
checksumSha256: document.checksumSha256,
|
||||
kind: document.kind,
|
||||
createdAt: document.createdAt.toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { licitationsConfig } from "@/lib/licitations/config";
|
||||
import { runPeriodicNormativeVerification } from "@/lib/compliance/regulations";
|
||||
import { isAuthorizedCronRequest } from "@/lib/cron/auth";
|
||||
import { runDailyLicitationsSync } from "@/lib/licitations/sync";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
@@ -31,10 +32,8 @@ function parseBoolean(value: string | null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const token = request.headers.get("x-sync-token") ?? "";
|
||||
|
||||
if (!licitationsConfig.syncCronToken || token !== licitationsConfig.syncCronToken) {
|
||||
async function runSync(request: Request) {
|
||||
if (!isAuthorizedCronRequest(request)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -44,11 +43,33 @@ export async function POST(request: Request) {
|
||||
const skip = parsePositiveInt(url.searchParams.get("skip"));
|
||||
const targetYear = parsePositiveInt(url.searchParams.get("target_year"));
|
||||
const includePnt = parseBoolean(url.searchParams.get("include_pnt"));
|
||||
const includeLicitaya = parseBoolean(url.searchParams.get("include_licitaya"));
|
||||
const includeRegulations = parseBoolean(url.searchParams.get("include_regulations"));
|
||||
const force = parseBoolean(url.searchParams.get("force"));
|
||||
const payload = await runDailyLicitationsSync({ municipalityId, limit, skip, targetYear, includePnt, force });
|
||||
const payload = await runDailyLicitationsSync({ municipalityId, limit, skip, targetYear, includePnt, includeLicitaya, force });
|
||||
let regulations = null;
|
||||
let regulationsError: string | null = null;
|
||||
|
||||
if (includeRegulations !== false) {
|
||||
try {
|
||||
regulations = await runPeriodicNormativeVerification(new Date());
|
||||
} catch (error) {
|
||||
regulationsError = error instanceof Error ? error.message : "No fue posible verificar reglamentos oficiales.";
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
payload,
|
||||
regulations,
|
||||
regulationsError,
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
return runSync(request);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return runSync(request);
|
||||
}
|
||||
|
||||
25
src/app/api/cron/regulations-verify/route.ts
Normal file
25
src/app/api/cron/regulations-verify/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { runPeriodicNormativeVerification } from "@/lib/compliance/regulations";
|
||||
import { isAuthorizedCronRequest } from "@/lib/cron/auth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
async function runVerification(request: Request) {
|
||||
if (!isAuthorizedCronRequest(request)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload = await runPeriodicNormativeVerification(new Date());
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
return runVerification(request);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return runVerification(request);
|
||||
}
|
||||
77
src/app/api/deliverables/[id]/route.ts
Normal file
77
src/app/api/deliverables/[id]/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { DeliverableUpdateInputSchema } from "@/lib/contracts/types";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
function parseJsonBody(body: unknown) {
|
||||
return typeof body === "object" && body !== null ? (body as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, context: RouteContext) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
|
||||
try {
|
||||
const body = parseJsonBody(await request.json().catch(() => ({})));
|
||||
const parsed = DeliverableUpdateInputSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Datos de actualizacion invalidos." }, { status: 400 });
|
||||
}
|
||||
|
||||
const deliverable = await prisma.contractDeliverable.findFirst({
|
||||
where: {
|
||||
id,
|
||||
contract: {
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!deliverable) {
|
||||
return NextResponse.json({ error: "Entregable no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
|
||||
const updated = await prisma.contractDeliverable.update({
|
||||
where: { id },
|
||||
data: {
|
||||
title: input.title,
|
||||
status: input.status,
|
||||
dueDate: input.dueDate === undefined ? undefined : input.dueDate ? new Date(input.dueDate) : null,
|
||||
deliveredAt: input.deliveredAt === undefined ? undefined : input.deliveredAt ? new Date(input.deliveredAt) : null,
|
||||
approvedAt: input.approvedAt === undefined ? undefined : input.approvedAt ? new Date(input.approvedAt) : null,
|
||||
notes: input.notes,
|
||||
amountLinked: input.amountLinked === undefined ? undefined : input.amountLinked ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
deliverable: {
|
||||
id: updated.id,
|
||||
contractId: updated.contractId,
|
||||
title: updated.title,
|
||||
dueDate: updated.dueDate ? updated.dueDate.toISOString() : null,
|
||||
amountLinked: updated.amountLinked ? Number(updated.amountLinked) : null,
|
||||
status: updated.status,
|
||||
deliveredAt: updated.deliveredAt ? updated.deliveredAt.toISOString() : null,
|
||||
approvedAt: updated.approvedAt ? updated.approvedAt.toISOString() : null,
|
||||
notes: updated.notes,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: "No fue posible actualizar el entregable." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
309
src/app/api/diagnostic/ai/suggestions/route.ts
Normal file
309
src/app/api/diagnostic/ai/suggestions/route.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import { z } from "zod";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionPayload } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { callOpenAiJsonSchema } from "@/lib/ai/openai";
|
||||
import { storeAiSuggestionFromEnvelope } from "@/lib/ai/suggestions";
|
||||
|
||||
const M1_PROMPT_VERSION = "m1_assist_v1";
|
||||
|
||||
const M1AiSuggestionItemSchema = z.object({
|
||||
questionId: z.string().min(1),
|
||||
suggestedAnswerOptionId: z.string().min(1),
|
||||
rationale: z.string().min(10).max(1_500),
|
||||
missingEvidence: z.array(z.string().min(2).max(240)).max(8).default([]),
|
||||
confidence: z.number().min(0).max(1).optional(),
|
||||
});
|
||||
|
||||
const M1AiSuggestionResponseSchema = z.object({
|
||||
suggestions: z.array(M1AiSuggestionItemSchema).max(50),
|
||||
confidence: z.enum(["low", "medium", "high"]).optional(),
|
||||
});
|
||||
|
||||
const M1AiSuggestionJsonSchema = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["suggestions"],
|
||||
properties: {
|
||||
suggestions: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["questionId", "suggestedAnswerOptionId", "rationale", "missingEvidence"],
|
||||
properties: {
|
||||
questionId: { type: "string" },
|
||||
suggestedAnswerOptionId: { type: "string" },
|
||||
rationale: { type: "string" },
|
||||
missingEvidence: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
confidence: { type: "number", minimum: 0, maximum: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
confidence: { type: "string", enum: ["low", "medium", "high"] },
|
||||
},
|
||||
} as const;
|
||||
|
||||
function parseString(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function parseResponseEvidence(rawValue: unknown): { notes: string; links: string[] } | null {
|
||||
if (!rawValue || typeof rawValue !== "object" || Array.isArray(rawValue)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = rawValue as Record<string, unknown>;
|
||||
const notes = typeof value.notes === "string" ? value.notes.trim() : "";
|
||||
const links = Array.isArray(value.links)
|
||||
? value.links
|
||||
.filter((entry): entry is string => typeof entry === "string")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0)
|
||||
: [];
|
||||
|
||||
if (!notes && links.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
notes,
|
||||
links,
|
||||
};
|
||||
}
|
||||
|
||||
function toConfidenceNumber(confidence: number | undefined, fallback: "low" | "medium" | "high" | null) {
|
||||
if (typeof confidence === "number" && Number.isFinite(confidence)) {
|
||||
return Math.max(0, Math.min(1, confidence));
|
||||
}
|
||||
|
||||
if (fallback === "high") {
|
||||
return 0.9;
|
||||
}
|
||||
|
||||
if (fallback === "medium") {
|
||||
return 0.65;
|
||||
}
|
||||
|
||||
if (fallback === "low") {
|
||||
return 0.35;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await getSessionPayload();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const moduleKey = parseString(body.moduleKey);
|
||||
const requestedQuestionId = parseString(body.questionId);
|
||||
|
||||
if (!moduleKey) {
|
||||
return NextResponse.json({ error: "moduleKey es requerido." }, { status: 400 });
|
||||
}
|
||||
|
||||
const moduleRecord = await prisma.diagnosticModule.findUnique({
|
||||
where: { key: moduleKey },
|
||||
select: {
|
||||
id: true,
|
||||
key: true,
|
||||
name: true,
|
||||
questions: {
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
prompt: true,
|
||||
helpText: true,
|
||||
answerOptions: {
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
label: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!moduleRecord) {
|
||||
return NextResponse.json({ error: "Modulo no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
const questionIds = moduleRecord.questions.map((question) => question.id);
|
||||
const responses = questionIds.length
|
||||
? await prisma.response.findMany({
|
||||
where: {
|
||||
userId: session.userId,
|
||||
questionId: {
|
||||
in: questionIds,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
questionId: true,
|
||||
answerOptionId: true,
|
||||
evidence: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
const responseByQuestionId = new Map(responses.map((item) => [item.questionId, item]));
|
||||
const targetQuestions = moduleRecord.questions.filter((question) => {
|
||||
if (requestedQuestionId) {
|
||||
return question.id === requestedQuestionId;
|
||||
}
|
||||
|
||||
return !responseByQuestionId.has(question.id);
|
||||
});
|
||||
|
||||
if (requestedQuestionId && !targetQuestions.length) {
|
||||
return NextResponse.json({ error: "questionId no pertenece al modulo solicitado." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!targetQuestions.length) {
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
moduleKey,
|
||||
suggestions: [],
|
||||
warnings: [{ code: "no_unanswered_questions", message: "No hay preguntas pendientes para sugerir en este modulo." }],
|
||||
});
|
||||
}
|
||||
|
||||
const answeredContext = moduleRecord.questions
|
||||
.map((question) => {
|
||||
const response = responseByQuestionId.get(question.id);
|
||||
if (!response) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedOption = question.answerOptions.find((option) => option.id === response.answerOptionId) ?? null;
|
||||
const evidence = parseResponseEvidence(response.evidence);
|
||||
|
||||
return {
|
||||
questionId: question.id,
|
||||
prompt: question.prompt,
|
||||
selectedAnswerOptionId: selectedOption?.id ?? response.answerOptionId,
|
||||
selectedAnswerLabel: selectedOption?.label ?? "No identificada",
|
||||
evidenceNotes: evidence?.notes ?? "",
|
||||
evidenceLinks: evidence?.links ?? [],
|
||||
};
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => Boolean(item));
|
||||
|
||||
const targetContext = targetQuestions.map((question) => ({
|
||||
questionId: question.id,
|
||||
prompt: question.prompt,
|
||||
helpText: question.helpText ?? "",
|
||||
options: question.answerOptions.map((option) => ({
|
||||
id: option.id,
|
||||
label: option.label,
|
||||
})),
|
||||
}));
|
||||
|
||||
const systemPrompt = [
|
||||
"Eres un asistente experto en diagnostico organizacional para contratacion publica en Mexico.",
|
||||
"Genera sugerencias de respuesta para preguntas no contestadas sin inventar evidencia.",
|
||||
"Debes responder solamente JSON valido.",
|
||||
"El idioma de salida debe ser espanol.",
|
||||
].join(" ");
|
||||
|
||||
const userPrompt = [
|
||||
`Modulo: ${moduleRecord.name} (${moduleRecord.key})`,
|
||||
"Respuestas ya contestadas por la organizacion:",
|
||||
JSON.stringify(answeredContext),
|
||||
"",
|
||||
"Preguntas objetivo a sugerir:",
|
||||
JSON.stringify(targetContext),
|
||||
"",
|
||||
"Instrucciones:",
|
||||
"- Para cada pregunta objetivo devuelve questionId y suggestedAnswerOptionId usando solo IDs listados en options.",
|
||||
"- rationale debe explicar por que esa opcion es la mas probable segun contexto.",
|
||||
"- missingEvidence debe listar evidencia que ayudaria a confirmar o corregir la sugerencia.",
|
||||
"- confidence debe ir de 0 a 1.",
|
||||
].join("\n");
|
||||
|
||||
const envelope = await callOpenAiJsonSchema({
|
||||
promptVersion: M1_PROMPT_VERSION,
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
outputSchema: M1AiSuggestionResponseSchema,
|
||||
schemaName: "m1_question_suggestions",
|
||||
jsonSchema: M1AiSuggestionJsonSchema as unknown as Record<string, unknown>,
|
||||
model: process.env.OPENAI_M1_MODEL?.trim() || undefined,
|
||||
});
|
||||
|
||||
const targetQuestionById = new Map(
|
||||
targetQuestions.map((question) => [
|
||||
question.id,
|
||||
{
|
||||
id: question.id,
|
||||
optionIds: new Set(question.answerOptions.map((option) => option.id)),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const aiSuggestions = envelope.data?.suggestions ?? [];
|
||||
const normalizedSuggestions = aiSuggestions
|
||||
.map((item) => {
|
||||
const question = targetQuestionById.get(item.questionId);
|
||||
if (!question || !question.optionIds.has(item.suggestedAnswerOptionId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return item;
|
||||
})
|
||||
.filter((item): item is z.infer<typeof M1AiSuggestionItemSchema> => Boolean(item));
|
||||
|
||||
const persisted = await Promise.all(
|
||||
normalizedSuggestions.map(async (item) => {
|
||||
const saved = await storeAiSuggestionFromEnvelope({
|
||||
userId: session.userId,
|
||||
moduleKey: "M1",
|
||||
featureKey: "diagnostic_answer_suggestion",
|
||||
subjectType: "question",
|
||||
subjectId: item.questionId,
|
||||
inputForHash: {
|
||||
moduleKey,
|
||||
questionId: item.questionId,
|
||||
answeredContext,
|
||||
targetQuestion: targetContext.find((question) => question.questionId === item.questionId) ?? null,
|
||||
},
|
||||
envelope,
|
||||
responsePayload: item,
|
||||
});
|
||||
|
||||
return {
|
||||
...item,
|
||||
suggestionId: saved.suggestionId,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
moduleKey,
|
||||
suggestions: persisted.map((item) => ({
|
||||
questionId: item.questionId,
|
||||
suggestedAnswerOptionId: item.suggestedAnswerOptionId,
|
||||
rationale: item.rationale,
|
||||
missingEvidence: item.missingEvidence,
|
||||
confidence: toConfidenceNumber(item.confidence, envelope.confidence),
|
||||
suggestionId: item.suggestionId,
|
||||
})),
|
||||
meta: {
|
||||
engine: envelope.engine,
|
||||
model: envelope.model,
|
||||
usage: envelope.usage,
|
||||
warnings: envelope.warnings,
|
||||
confidence: envelope.confidence,
|
||||
},
|
||||
});
|
||||
}
|
||||
64
src/app/api/legal/cases/[id]/route.ts
Normal file
64
src/app/api/legal/cases/[id]/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { LegalCaseUpdateInputSchema } from "@/lib/legal/types";
|
||||
import { listLegalCasesForUser } from "@/lib/legal/server";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
function parseJsonBody(body: unknown) {
|
||||
return typeof body === "object" && body !== null ? (body as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, context: RouteContext) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
|
||||
try {
|
||||
const body = parseJsonBody(await request.json().catch(() => ({})));
|
||||
const parsed = LegalCaseUpdateInputSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Datos de actualizacion invalidos." }, { status: 400 });
|
||||
}
|
||||
|
||||
const owned = await prisma.legalCase.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: user.id,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!owned) {
|
||||
return NextResponse.json({ error: "Caso no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
await prisma.legalCase.update({
|
||||
where: { id },
|
||||
data: {
|
||||
severity: input.severity,
|
||||
status: input.status,
|
||||
resolvedAt: input.resolvedAt === undefined ? undefined : input.resolvedAt ? new Date(input.resolvedAt) : null,
|
||||
description: input.description,
|
||||
amountAtRisk: input.amountAtRisk === undefined ? undefined : input.amountAtRisk ?? null,
|
||||
counterparty: input.counterparty,
|
||||
},
|
||||
});
|
||||
|
||||
const cases = await listLegalCasesForUser(user.id);
|
||||
const legalCase = cases.find((item) => item.id === id) ?? null;
|
||||
|
||||
return NextResponse.json({ ok: true, case: legalCase });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "No fue posible actualizar el caso legal." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
62
src/app/api/legal/cases/route.ts
Normal file
62
src/app/api/legal/cases/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { LegalCaseCreateInputSchema } from "@/lib/legal/types";
|
||||
import { listLegalCasesForUser } from "@/lib/legal/server";
|
||||
|
||||
function parseJsonBody(body: unknown) {
|
||||
return typeof body === "object" && body !== null ? (body as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const cases = await listLegalCasesForUser(user.id);
|
||||
return NextResponse.json({ ok: true, cases });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = parseJsonBody(await request.json().catch(() => ({})));
|
||||
const parsed = LegalCaseCreateInputSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Datos de caso legal invalidos." }, { status: 400 });
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
|
||||
const created = await prisma.legalCase.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
contractId: input.contractId ?? null,
|
||||
caseType: input.caseType,
|
||||
severity: input.severity,
|
||||
counterparty: input.counterparty,
|
||||
description: input.description,
|
||||
amountAtRisk: input.amountAtRisk ?? null,
|
||||
status: input.status ?? "OPEN",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const cases = await listLegalCasesForUser(user.id);
|
||||
const legalCase = cases.find((item) => item.id === created.id) ?? null;
|
||||
|
||||
return NextResponse.json({ ok: true, case: legalCase });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "No fue posible crear el caso legal." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
118
src/app/api/legal/diagnosis/answer/route.test.ts
Normal file
118
src/app/api/legal/diagnosis/answer/route.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { requireAdminApiUserMock, parseDiagnosisAnswersMock, evaluateDiagnosisMock, prismaMock } = vi.hoisted(() => ({
|
||||
requireAdminApiUserMock: vi.fn(),
|
||||
parseDiagnosisAnswersMock: vi.fn(),
|
||||
evaluateDiagnosisMock: vi.fn(),
|
||||
prismaMock: {
|
||||
legalDiagnosis: {
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
contractRecord: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
legalCase: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/admin", () => ({
|
||||
requireAdminApiUser: requireAdminApiUserMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/legal/diagnosis", () => ({
|
||||
parseDiagnosisAnswers: parseDiagnosisAnswersMock,
|
||||
evaluateDiagnosis: evaluateDiagnosisMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
import { POST } from "@/app/api/legal/diagnosis/answer/route";
|
||||
|
||||
describe("M9 diagnosis contract continuity regression", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("keeps persisted contractId when finalizing diagnosis into legal case", async () => {
|
||||
const now = new Date("2026-04-15T11:00:00.000Z");
|
||||
|
||||
requireAdminApiUserMock.mockResolvedValue({ id: "user-1" });
|
||||
prismaMock.legalDiagnosis.findFirst.mockResolvedValue({
|
||||
id: "diag-1",
|
||||
totalSteps: 4,
|
||||
legalCaseId: null,
|
||||
answersJson: {
|
||||
contractId: "contract-1",
|
||||
counterparty: "Proveedor SA",
|
||||
description: "Incumplimiento de entregable",
|
||||
},
|
||||
});
|
||||
parseDiagnosisAnswersMock.mockReturnValue({ amountAtRisk: 5000 });
|
||||
evaluateDiagnosisMock.mockReturnValue({
|
||||
suggestedSeverity: "HIGH",
|
||||
suggestedCaseType: "CONTRACT_BREACH",
|
||||
});
|
||||
prismaMock.contractRecord.findFirst.mockResolvedValue({ id: "contract-1" });
|
||||
prismaMock.legalCase.create.mockResolvedValue({ id: "case-1" });
|
||||
prismaMock.legalDiagnosis.update.mockResolvedValue({
|
||||
id: "diag-1",
|
||||
legalCaseId: "case-1",
|
||||
stepIndex: 4,
|
||||
totalSteps: 4,
|
||||
answersJson: {
|
||||
contractId: "contract-1",
|
||||
},
|
||||
recommendedRouteJson: {
|
||||
suggestedSeverity: "HIGH",
|
||||
suggestedCaseType: "CONTRACT_BREACH",
|
||||
},
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const request = new Request("http://localhost/api/legal/diagnosis/answer", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
diagnosisId: "diag-1",
|
||||
stepIndex: 3,
|
||||
answers: {},
|
||||
finalize: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
const payload = (await response.json()) as {
|
||||
ok?: boolean;
|
||||
diagnosis?: {
|
||||
legalCaseId: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.ok).toBe(true);
|
||||
expect(payload.diagnosis?.legalCaseId).toBe("case-1");
|
||||
expect(prismaMock.contractRecord.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "contract-1",
|
||||
userId: "user-1",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
expect(prismaMock.legalCase.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
contractId: "contract-1",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
172
src/app/api/legal/diagnosis/answer/route.ts
Normal file
172
src/app/api/legal/diagnosis/answer/route.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { evaluateDiagnosis, parseDiagnosisAnswers } from "@/lib/legal/diagnosis";
|
||||
import { LegalDiagnosisAnswerInputSchema } from "@/lib/legal/types";
|
||||
|
||||
function parseJsonBody(body: unknown) {
|
||||
return typeof body === "object" && body !== null ? (body as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function stringOrEmpty(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function extractContractId(value: unknown) {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function logContinuityHandoff(event: string, data: Record<string, unknown>) {
|
||||
console.info(
|
||||
"[continuity_handoff]",
|
||||
JSON.stringify({
|
||||
event,
|
||||
at: new Date().toISOString(),
|
||||
...data,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = parseJsonBody(await request.json().catch(() => ({})));
|
||||
const parsed = LegalDiagnosisAnswerInputSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Datos de respuesta invalidos." }, { status: 400 });
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
logContinuityHandoff("m9_diagnosis_answer_received", {
|
||||
userId: user.id,
|
||||
diagnosisId: input.diagnosisId,
|
||||
finalize: Boolean(input.finalize),
|
||||
});
|
||||
|
||||
const diagnosis = await prisma.legalDiagnosis.findFirst({
|
||||
where: {
|
||||
id: input.diagnosisId,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
answersJson: true,
|
||||
totalSteps: true,
|
||||
legalCaseId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!diagnosis) {
|
||||
return NextResponse.json({ error: "Diagnostico no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
const currentAnswers =
|
||||
diagnosis.answersJson && typeof diagnosis.answersJson === "object" && !Array.isArray(diagnosis.answersJson)
|
||||
? (diagnosis.answersJson as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const mergedAnswers = {
|
||||
...currentAnswers,
|
||||
...input.answers,
|
||||
};
|
||||
|
||||
const normalizedAnswers = parseDiagnosisAnswers(mergedAnswers);
|
||||
const recommendation = evaluateDiagnosis(normalizedAnswers);
|
||||
const nextStep = input.finalize ? diagnosis.totalSteps : Math.min(diagnosis.totalSteps, input.stepIndex + 1);
|
||||
|
||||
let legalCaseId = diagnosis.legalCaseId;
|
||||
const persistedContractId = extractContractId(mergedAnswers.contractId);
|
||||
logContinuityHandoff("m9_diagnosis_contract_context", {
|
||||
userId: user.id,
|
||||
diagnosisId: diagnosis.id,
|
||||
persistedContractId,
|
||||
hasLegalCaseAlready: Boolean(legalCaseId),
|
||||
});
|
||||
|
||||
if (input.finalize && !legalCaseId) {
|
||||
if (persistedContractId) {
|
||||
const ownedContract = await prisma.contractRecord.findFirst({
|
||||
where: {
|
||||
id: persistedContractId,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!ownedContract) {
|
||||
return NextResponse.json({ error: "El contrato vinculado al diagnostico ya no esta disponible." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const createdCase = await prisma.legalCase.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
contractId: persistedContractId,
|
||||
caseType: recommendation.suggestedCaseType,
|
||||
severity: recommendation.suggestedSeverity,
|
||||
counterparty: stringOrEmpty(mergedAnswers.counterparty) || "Contraparte por validar",
|
||||
description: stringOrEmpty(mergedAnswers.description) || "Caso generado desde diagnostico legal asistido.",
|
||||
amountAtRisk: normalizedAnswers.amountAtRisk,
|
||||
status: "OPEN",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
legalCaseId = createdCase.id;
|
||||
logContinuityHandoff("m9_case_autocreated_from_diagnosis", {
|
||||
userId: user.id,
|
||||
diagnosisId: diagnosis.id,
|
||||
legalCaseId,
|
||||
contractId: persistedContractId,
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await prisma.legalDiagnosis.update({
|
||||
where: {
|
||||
id: diagnosis.id,
|
||||
},
|
||||
data: {
|
||||
stepIndex: nextStep,
|
||||
answersJson: mergedAnswers as Prisma.InputJsonValue,
|
||||
recommendedRouteJson: recommendation as unknown as Prisma.InputJsonValue,
|
||||
legalCaseId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
legalCaseId: true,
|
||||
stepIndex: true,
|
||||
totalSteps: true,
|
||||
answersJson: true,
|
||||
recommendedRouteJson: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
diagnosis: {
|
||||
id: updated.id,
|
||||
legalCaseId: updated.legalCaseId,
|
||||
stepIndex: updated.stepIndex,
|
||||
totalSteps: updated.totalSteps,
|
||||
answers: updated.answersJson,
|
||||
recommendation: updated.recommendedRouteJson,
|
||||
updatedAt: updated.updatedAt.toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
105
src/app/api/legal/diagnosis/start/route.ts
Normal file
105
src/app/api/legal/diagnosis/start/route.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { evaluateDiagnosis, parseDiagnosisAnswers } from "@/lib/legal/diagnosis";
|
||||
import { LegalDiagnosisStartInputSchema } from "@/lib/legal/types";
|
||||
|
||||
function parseJsonBody(body: unknown) {
|
||||
return typeof body === "object" && body !== null ? (body as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function logContinuityHandoff(event: string, data: Record<string, unknown>) {
|
||||
console.info(
|
||||
"[continuity_handoff]",
|
||||
JSON.stringify({
|
||||
event,
|
||||
at: new Date().toISOString(),
|
||||
...data,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = parseJsonBody(await request.json().catch(() => ({})));
|
||||
const parsed = LegalDiagnosisStartInputSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Datos de inicio de diagnostico invalidos." }, { status: 400 });
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
logContinuityHandoff("m8_to_m9_diagnosis_start_requested", {
|
||||
userId: user.id,
|
||||
requestedContractId: input.contractId ?? null,
|
||||
});
|
||||
|
||||
let contractId: string | null = null;
|
||||
if (input.contractId) {
|
||||
const ownedContract = await prisma.contractRecord.findFirst({
|
||||
where: {
|
||||
id: input.contractId,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!ownedContract) {
|
||||
return NextResponse.json({ error: "Contrato no encontrado para iniciar diagnostico." }, { status: 404 });
|
||||
}
|
||||
|
||||
contractId = ownedContract.id;
|
||||
}
|
||||
|
||||
logContinuityHandoff("m8_to_m9_diagnosis_started", {
|
||||
userId: user.id,
|
||||
persistedContractId: contractId,
|
||||
});
|
||||
|
||||
const normalizedAnswers = parseDiagnosisAnswers({});
|
||||
const recommendation = evaluateDiagnosis(normalizedAnswers);
|
||||
const answersJson: Record<string, unknown> = {
|
||||
...normalizedAnswers,
|
||||
};
|
||||
|
||||
if (contractId) {
|
||||
answersJson.contractId = contractId;
|
||||
}
|
||||
|
||||
const diagnosis = await prisma.legalDiagnosis.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
answersJson: answersJson as Prisma.InputJsonValue,
|
||||
recommendedRouteJson: recommendation as unknown as Prisma.InputJsonValue,
|
||||
stepIndex: 1,
|
||||
totalSteps: 4,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
stepIndex: true,
|
||||
totalSteps: true,
|
||||
answersJson: true,
|
||||
recommendedRouteJson: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
diagnosis: {
|
||||
id: diagnosis.id,
|
||||
stepIndex: diagnosis.stepIndex,
|
||||
totalSteps: diagnosis.totalSteps,
|
||||
answers: diagnosis.answersJson,
|
||||
contractId,
|
||||
recommendation: diagnosis.recommendedRouteJson,
|
||||
},
|
||||
});
|
||||
}
|
||||
26
src/app/api/legal/directory/route.ts
Normal file
26
src/app/api/legal/directory/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { LegalDirectoryFiltersSchema } from "@/lib/legal/types";
|
||||
import { listLegalDirectory } from "@/lib/legal/server";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const parsed = LegalDirectoryFiltersSchema.safeParse({
|
||||
jurisdictionLevel: searchParams.get("jurisdictionLevel") ?? undefined,
|
||||
stateCode: searchParams.get("stateCode") ?? undefined,
|
||||
q: searchParams.get("q") ?? undefined,
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Filtros invalidos." }, { status: 400 });
|
||||
}
|
||||
|
||||
const entities = await listLegalDirectory(parsed.data);
|
||||
return NextResponse.json({ ok: true, entities });
|
||||
}
|
||||
91
src/app/api/legal/documents/generate/route.ts
Normal file
91
src/app/api/legal/documents/generate/route.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { generateLegalDocument } from "@/lib/legal/ai-documents";
|
||||
import { LegalDocumentGenerateInputSchema } from "@/lib/legal/types";
|
||||
|
||||
function parseJsonBody(body: unknown) {
|
||||
return typeof body === "object" && body !== null ? (body as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = parseJsonBody(await request.json().catch(() => ({})));
|
||||
const parsed = LegalDocumentGenerateInputSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Datos para generar escrito invalidos." }, { status: 400 });
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
|
||||
let legalCaseId: string | null = input.legalCaseId ?? null;
|
||||
if (legalCaseId) {
|
||||
const ownedCase = await prisma.legalCase.findFirst({
|
||||
where: {
|
||||
id: legalCaseId,
|
||||
userId: user.id,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!ownedCase) {
|
||||
return NextResponse.json({ error: "Caso legal no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
legalCaseId = ownedCase.id;
|
||||
}
|
||||
|
||||
const generated = await generateLegalDocument({
|
||||
legalCaseId,
|
||||
caseType: input.caseType,
|
||||
severity: input.severity,
|
||||
templateKey: input.templateKey ?? null,
|
||||
counterparty: input.counterparty,
|
||||
companyName: input.companyName,
|
||||
amountAtRisk: input.amountAtRisk ?? null,
|
||||
description: input.description,
|
||||
objective: input.objective,
|
||||
});
|
||||
|
||||
const title = input.title?.trim() || generated.title;
|
||||
|
||||
const document = await prisma.legalDocument.create({
|
||||
data: {
|
||||
legalCaseId,
|
||||
userId: user.id,
|
||||
templateKey: input.templateKey ?? null,
|
||||
aiGenerated: generated.engine === "openai_json_object",
|
||||
title,
|
||||
content: generated.content,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
document: {
|
||||
id: document.id,
|
||||
legalCaseId: document.legalCaseId,
|
||||
templateKey: document.templateKey,
|
||||
aiGenerated: document.aiGenerated,
|
||||
title: document.title,
|
||||
content: document.content,
|
||||
createdAt: document.createdAt.toISOString(),
|
||||
},
|
||||
generation: {
|
||||
engine: generated.engine,
|
||||
model: generated.model,
|
||||
warnings: generated.warnings,
|
||||
usage: generated.usage,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: "No fue posible generar el escrito legal." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
90
src/app/api/legal/escalation/[caseId]/route.ts
Normal file
90
src/app/api/legal/escalation/[caseId]/route.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getEscalationForCase } from "@/lib/legal/server";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ caseId: string }>;
|
||||
};
|
||||
|
||||
const VALID_STEP_KEYS = new Set([
|
||||
"formal_requirement",
|
||||
"administrative_conciliation",
|
||||
"oic_complaint",
|
||||
"sfp_or_state_challenge",
|
||||
"jurisdictional_path",
|
||||
]);
|
||||
|
||||
function parseJsonBody(body: unknown) {
|
||||
return typeof body === "object" && body !== null ? (body as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
export async function GET(_request: Request, context: RouteContext) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { caseId } = await context.params;
|
||||
const escalation = await getEscalationForCase(user.id, caseId);
|
||||
|
||||
if (!escalation) {
|
||||
return NextResponse.json({ error: "Caso no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, escalation });
|
||||
}
|
||||
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { caseId } = await context.params;
|
||||
const body = parseJsonBody(await request.json().catch(() => ({})));
|
||||
|
||||
const routeStepKey = typeof body.routeStepKey === "string" ? body.routeStepKey.trim() : "";
|
||||
const notes = typeof body.notes === "string" ? body.notes.trim() : "";
|
||||
const completed = body.completed === false ? false : true;
|
||||
|
||||
if (!VALID_STEP_KEYS.has(routeStepKey)) {
|
||||
return NextResponse.json({ error: "routeStepKey invalido." }, { status: 400 });
|
||||
}
|
||||
|
||||
const legalCase = await prisma.legalCase.findFirst({
|
||||
where: {
|
||||
id: caseId,
|
||||
userId: user.id,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!legalCase) {
|
||||
return NextResponse.json({ error: "Caso no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.legalEscalationStepLog.upsert({
|
||||
where: {
|
||||
legalCaseId_routeStepKey: {
|
||||
legalCaseId: caseId,
|
||||
routeStepKey,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
completedAt: completed ? new Date() : null,
|
||||
notes,
|
||||
},
|
||||
create: {
|
||||
legalCaseId: caseId,
|
||||
routeStepKey,
|
||||
completedAt: completed ? new Date() : null,
|
||||
notes,
|
||||
},
|
||||
});
|
||||
|
||||
const escalation = await getEscalationForCase(user.id, caseId);
|
||||
return NextResponse.json({ ok: true, escalation });
|
||||
}
|
||||
14
src/app/api/legal/kpis/route.ts
Normal file
14
src/app/api/legal/kpis/route.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { getLegalKpisForUser } from "@/lib/legal/server";
|
||||
|
||||
export async function GET() {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const kpis = await getLegalKpisForUser(user.id);
|
||||
return NextResponse.json({ ok: true, kpis });
|
||||
}
|
||||
13
src/app/api/legal/templates/route.ts
Normal file
13
src/app/api/legal/templates/route.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { listLegalTemplates } from "@/lib/legal/ai-documents";
|
||||
|
||||
export async function GET() {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, templates: listLegalTemplates() });
|
||||
}
|
||||
33
src/app/api/licitations/ai/recommendations/route.ts
Normal file
33
src/app/api/licitations/ai/recommendations/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionPayload } from "@/lib/auth/session";
|
||||
import { getAiEnhancedLicitationRecommendationsForUser } from "@/lib/licitations/ai-recommendations";
|
||||
|
||||
function parseTop(value: string | null) {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const session = await getSessionPayload();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const profileId = url.searchParams.get("profileId") ?? url.searchParams.get("profile_id");
|
||||
const top = parseTop(url.searchParams.get("top"));
|
||||
const payload = await getAiEnhancedLicitationRecommendationsForUser(session.userId, {
|
||||
profileId,
|
||||
top,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
78
src/app/api/licitations/preferences/[id]/route.ts
Normal file
78
src/app/api/licitations/preferences/[id]/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Prisma, type LicitationReviewStatus } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { parseLicitationReviewStatus } from "@/lib/licitations/preferences";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function isSchemaNotReadyError(error: unknown) {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const body = (await request.json()) as Record<string, unknown>;
|
||||
const status = parseLicitationReviewStatus(body.status);
|
||||
|
||||
if (!status) {
|
||||
return NextResponse.json({ error: "Estatus invalido." }, { status: 400 });
|
||||
}
|
||||
|
||||
const licitation = await prisma.licitation.findUnique({
|
||||
where: { id },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!licitation) {
|
||||
return NextResponse.json({ error: "Licitacion no encontrada." }, { status: 404 });
|
||||
}
|
||||
|
||||
if (status === "NEW") {
|
||||
await prisma.licitationUserPreference.deleteMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
licitationId: id,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, status: "NEW" });
|
||||
}
|
||||
|
||||
const updated = await prisma.licitationUserPreference.upsert({
|
||||
where: {
|
||||
userId_licitationId: {
|
||||
userId: user.id,
|
||||
licitationId: id,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
status: status as LicitationReviewStatus,
|
||||
},
|
||||
create: {
|
||||
userId: user.id,
|
||||
licitationId: id,
|
||||
status: status as LicitationReviewStatus,
|
||||
},
|
||||
select: {
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, status: updated.status });
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene las tablas de estado de oportunidades. Ejecuta prisma migrate para continuar." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible actualizar el estado." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
46
src/app/api/normative-analysis/history/[id]/route.ts
Normal file
46
src/app/api/normative-analysis/history/[id]/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function isSchemaNotReadyError(error: unknown) {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
|
||||
}
|
||||
|
||||
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
|
||||
const deleted = await prisma.normativeAnalysisHistory.updateMany({
|
||||
where: {
|
||||
id,
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
},
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
if (deleted.count === 0) {
|
||||
return NextResponse.json({ error: "Analisis no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene las tablas de Analisis Normativo. Ejecuta prisma migrate para continuar." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible eliminar el analisis." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
98
src/app/api/normative-analysis/history/route.ts
Normal file
98
src/app/api/normative-analysis/history/route.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { toNormativeHistoryView } from "@/lib/normative-analysis/history";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function parsePositiveInt(value: string | null, fallback: number) {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function isSchemaNotReadyError(error: unknown) {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const take = Math.min(parsePositiveInt(url.searchParams.get("take"), 50), 100);
|
||||
const proposalId = url.searchParams.get("proposalId")?.trim() || null;
|
||||
let sourceLicitationId = url.searchParams.get("sourceId")?.trim() || null;
|
||||
|
||||
if (!sourceLicitationId && proposalId) {
|
||||
const proposal = await prisma.proposal.findFirst({
|
||||
where: {
|
||||
id: proposalId,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
sourceLicitationId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!proposal?.sourceLicitationId) {
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
items: [],
|
||||
});
|
||||
}
|
||||
|
||||
sourceLicitationId = proposal.sourceLicitationId;
|
||||
}
|
||||
|
||||
const history = await prisma.normativeAnalysisHistory.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
...(sourceLicitationId ? { sourceLicitationId } : {}),
|
||||
},
|
||||
orderBy: [{ analyzedAt: "desc" }],
|
||||
take,
|
||||
select: {
|
||||
id: true,
|
||||
sourceLicitationId: true,
|
||||
fileName: true,
|
||||
documentType: true,
|
||||
issuingEntity: true,
|
||||
methodUsed: true,
|
||||
numPages: true,
|
||||
warnings: true,
|
||||
extractedChars: true,
|
||||
analyzedAt: true,
|
||||
viabilityScore: true,
|
||||
riskLevel: true,
|
||||
confidence: true,
|
||||
result: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
items: history.map((entry) => toNormativeHistoryView(entry)),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene las tablas de Analisis Normativo. Ejecuta prisma migrate para continuar." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible consultar el historial." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
612
src/app/api/normative-analysis/route.ts
Normal file
612
src/app/api/normative-analysis/route.ts
Normal file
@@ -0,0 +1,612 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { type NormativeConfidence, type NormativeDocumentType as PrismaNormativeDocumentType, type NormativeRiskLevel, Prisma } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { isLikelyPdfUrl, parseLicitationDocumentLinks, pickPrimaryLicitationDocument, pickPrimaryLicitationPdfDocument } from "@/lib/licitations/documents";
|
||||
import { type NormativeDocumentType } from "@/lib/normative-analysis/analyze";
|
||||
import { analyzeNormativeTextWithAi } from "@/lib/normative-analysis/ai-analyze";
|
||||
import { toNormativeHistoryView } from "@/lib/normative-analysis/history";
|
||||
import { analyzePdf } from "@/lib/pdf/analyzePdf";
|
||||
import { OcrFailedError, OcrUnavailableError, PdfEncryptedError, PdfNoTextDetectedError, PdfUnreadableError } from "@/lib/pdf/errors";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const MAX_NORMATIVE_PDF_BYTES = 20 * 1024 * 1024;
|
||||
const SOURCE_FETCH_TIMEOUT_MS = 45_000;
|
||||
const STORAGE_ROOT = path.resolve(process.cwd(), "storage");
|
||||
|
||||
function isPdfFile(file: File) {
|
||||
const extension = file.name.toLowerCase().endsWith(".pdf");
|
||||
const mimeType = file.type === "application/pdf";
|
||||
return extension || mimeType;
|
||||
}
|
||||
|
||||
function hasPdfSignature(buffer: Buffer) {
|
||||
return buffer.subarray(0, 5).toString("utf8") === "%PDF-";
|
||||
}
|
||||
|
||||
function guessFileNameFromUrl(url: string) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const segments = parsed.pathname.split("/").filter(Boolean);
|
||||
const lastSegment = segments.at(-1) ?? "";
|
||||
const decoded = decodeURIComponent(lastSegment).trim();
|
||||
if (!decoded) {
|
||||
return "documento-principal.pdf";
|
||||
}
|
||||
|
||||
return decoded.toLowerCase().endsWith(".pdf") ? decoded : `${decoded}.pdf`;
|
||||
} catch {
|
||||
return "documento-principal.pdf";
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSourcePdf(url: string) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), SOURCE_FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
redirect: "follow",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`SOURCE_FETCH_FAILED_${response.status}`);
|
||||
}
|
||||
|
||||
const headerLength = response.headers.get("content-length");
|
||||
const sizeHint = headerLength ? Number.parseInt(headerLength, 10) : 0;
|
||||
if (Number.isFinite(sizeHint) && sizeHint > MAX_NORMATIVE_PDF_BYTES) {
|
||||
throw new Error("SOURCE_PDF_TOO_LARGE");
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
if (!buffer.byteLength) {
|
||||
throw new Error("SOURCE_PDF_EMPTY");
|
||||
}
|
||||
|
||||
if (buffer.byteLength > MAX_NORMATIVE_PDF_BYTES) {
|
||||
throw new Error("SOURCE_PDF_TOO_LARGE");
|
||||
}
|
||||
|
||||
return buffer;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
type LicitationSourcePdf = {
|
||||
fileBuffer: Buffer;
|
||||
fileName: string;
|
||||
linkedLicitationId: string;
|
||||
issuingEntity: string | null;
|
||||
};
|
||||
|
||||
type SourcePdfCandidate = {
|
||||
url: string;
|
||||
fileName: string;
|
||||
};
|
||||
|
||||
function buildSourcePdfCandidates(documentsRaw: unknown, rawSourceUrl: string | null) {
|
||||
const sourceDocuments = parseLicitationDocumentLinks(documentsRaw);
|
||||
const primaryPdf = pickPrimaryLicitationPdfDocument(sourceDocuments);
|
||||
const primaryAny = pickPrimaryLicitationDocument(sourceDocuments);
|
||||
const candidates: SourcePdfCandidate[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
function pushCandidate(url: string | null | undefined, fileName: string | null | undefined) {
|
||||
const normalizedUrl = typeof url === "string" ? url.trim() : "";
|
||||
if (!normalizedUrl || seen.has(normalizedUrl)) {
|
||||
return;
|
||||
}
|
||||
seen.add(normalizedUrl);
|
||||
candidates.push({
|
||||
url: normalizedUrl,
|
||||
fileName: (fileName?.trim() || guessFileNameFromUrl(normalizedUrl)),
|
||||
});
|
||||
}
|
||||
|
||||
pushCandidate(primaryPdf?.url, primaryPdf?.name);
|
||||
pushCandidate(primaryAny?.url, primaryAny?.name);
|
||||
|
||||
for (const document of sourceDocuments) {
|
||||
pushCandidate(document.url, document.name);
|
||||
}
|
||||
|
||||
if (rawSourceUrl) {
|
||||
const normalizedSourceUrl = rawSourceUrl.trim();
|
||||
const existingIndex = candidates.findIndex((item) => item.url === normalizedSourceUrl);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
if (isLikelyPdfUrl(normalizedSourceUrl) && existingIndex > 0) {
|
||||
const [entry] = candidates.splice(existingIndex, 1);
|
||||
candidates.unshift(entry);
|
||||
}
|
||||
} else if (isLikelyPdfUrl(normalizedSourceUrl)) {
|
||||
candidates.unshift({
|
||||
url: normalizedSourceUrl,
|
||||
fileName: guessFileNameFromUrl(normalizedSourceUrl),
|
||||
});
|
||||
} else {
|
||||
pushCandidate(normalizedSourceUrl, guessFileNameFromUrl(normalizedSourceUrl));
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async function resolveLicitationSourcePdf(sourceLicitationId: string, currentIssuingEntity: string | null): Promise<LicitationSourcePdf> {
|
||||
const sourceLicitation = await prisma.licitation.findUnique({
|
||||
where: { id: sourceLicitationId },
|
||||
select: {
|
||||
id: true,
|
||||
supplierAwarded: true,
|
||||
documents: true,
|
||||
rawSourceUrl: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!sourceLicitation) {
|
||||
throw new Error("SOURCE_LICITATION_NOT_FOUND");
|
||||
}
|
||||
|
||||
const candidates = buildSourcePdfCandidates(sourceLicitation.documents, sourceLicitation.rawSourceUrl);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
throw new Error("SOURCE_LICITATION_NO_PDF");
|
||||
}
|
||||
|
||||
let sawSourceTooLarge = false;
|
||||
let sawSourceTimeout = false;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const fileBuffer = await fetchSourcePdf(candidate.url);
|
||||
if (!hasPdfSignature(fileBuffer)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
fileBuffer,
|
||||
fileName: candidate.fileName,
|
||||
linkedLicitationId: sourceLicitation.id,
|
||||
issuingEntity: currentIssuingEntity || sourceLicitation.supplierAwarded?.trim() || null,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === "SOURCE_PDF_TOO_LARGE") {
|
||||
sawSourceTooLarge = true;
|
||||
}
|
||||
|
||||
if (error.name === "AbortError") {
|
||||
sawSourceTimeout = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sawSourceTooLarge) {
|
||||
throw new Error("SOURCE_LICITATION_PDF_TOO_LARGE");
|
||||
}
|
||||
|
||||
if (sawSourceTimeout) {
|
||||
throw new Error("SOURCE_LICITATION_PDF_TIMEOUT");
|
||||
}
|
||||
|
||||
throw new Error("SOURCE_LICITATION_NO_VALID_PDF");
|
||||
}
|
||||
|
||||
type ProposalDocumentSource = {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
function isPdfProposalDocument(document: ProposalDocumentSource) {
|
||||
return document.mimeType === "application/pdf" || document.fileName.toLowerCase().endsWith(".pdf");
|
||||
}
|
||||
|
||||
function pickPrimaryProposalPdfDocument(documents: ProposalDocumentSource[]) {
|
||||
return documents.find((document) => isPdfProposalDocument(document)) ?? null;
|
||||
}
|
||||
|
||||
async function readStoredProposalPdf(filePath: string) {
|
||||
const absolutePath = path.resolve(process.cwd(), filePath);
|
||||
|
||||
if (!absolutePath.startsWith(`${STORAGE_ROOT}${path.sep}`)) {
|
||||
throw new Error("PROPOSAL_PDF_INVALID_PATH");
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await readFile(absolutePath);
|
||||
|
||||
if (!buffer.byteLength) {
|
||||
throw new Error("PROPOSAL_PDF_EMPTY");
|
||||
}
|
||||
|
||||
if (buffer.byteLength > MAX_NORMATIVE_PDF_BYTES) {
|
||||
throw new Error("PROPOSAL_PDF_TOO_LARGE");
|
||||
}
|
||||
|
||||
return buffer;
|
||||
} catch (error) {
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"code" in error &&
|
||||
(error as NodeJS.ErrnoException).code === "ENOENT"
|
||||
) {
|
||||
throw new Error("PROPOSAL_PDF_NOT_FOUND");
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function parseDocumentType(value: unknown): NormativeDocumentType {
|
||||
if (typeof value !== "string") {
|
||||
return "OTRO";
|
||||
}
|
||||
|
||||
const normalized = value.trim().toUpperCase();
|
||||
if (normalized === "BASES_LICITACION" || normalized === "CONVOCATORIA" || normalized === "ANEXOS" || normalized === "REGLAMENTO" || normalized === "LEY") {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return "OTRO";
|
||||
}
|
||||
|
||||
function toPrismaDocumentType(value: NormativeDocumentType): PrismaNormativeDocumentType {
|
||||
if (value === "BASES_LICITACION" || value === "CONVOCATORIA" || value === "REGLAMENTO" || value === "LEY") {
|
||||
return value;
|
||||
}
|
||||
|
||||
return "OTRO";
|
||||
}
|
||||
|
||||
function toPrismaRiskLevel(value: "alto" | "medio" | "bajo"): NormativeRiskLevel {
|
||||
if (value === "alto") {
|
||||
return "ALTO";
|
||||
}
|
||||
|
||||
if (value === "medio") {
|
||||
return "MEDIO";
|
||||
}
|
||||
|
||||
return "BAJO";
|
||||
}
|
||||
|
||||
function toPrismaConfidence(value: "low" | "medium" | "high"): NormativeConfidence {
|
||||
if (value === "high") {
|
||||
return "HIGH";
|
||||
}
|
||||
|
||||
if (value === "medium") {
|
||||
return "MEDIUM";
|
||||
}
|
||||
|
||||
return "LOW";
|
||||
}
|
||||
|
||||
function mapAnalysisError(error: unknown) {
|
||||
if (error instanceof PdfEncryptedError) {
|
||||
return {
|
||||
status: 422,
|
||||
error: "El PDF esta protegido/encriptado. Sube una version sin bloqueo para extraer texto.",
|
||||
code: error.code,
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof PdfUnreadableError) {
|
||||
return {
|
||||
status: 422,
|
||||
error: "No fue posible leer el PDF. Verifica que el archivo no este danado.",
|
||||
code: error.code,
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof OcrUnavailableError) {
|
||||
return {
|
||||
status: 503,
|
||||
error: "No se detecto texto suficiente y OCRmyPDF no esta disponible.",
|
||||
code: error.code,
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof PdfNoTextDetectedError) {
|
||||
return {
|
||||
status: 422,
|
||||
error: "No se detecto texto util en el PDF para analizar.",
|
||||
code: error.code,
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof OcrFailedError) {
|
||||
return {
|
||||
status: 422,
|
||||
error: "No se detecto texto suficiente y el OCR fallo durante el procesamiento.",
|
||||
code: error.code,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 422,
|
||||
error: "No fue posible analizar el PDF.",
|
||||
code: "NORMATIVE_ANALYSIS_FAILED",
|
||||
};
|
||||
}
|
||||
|
||||
function isSchemaNotReadyError(error: unknown) {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const documentType = parseDocumentType(formData.get("documentType"));
|
||||
const issuingEntityRaw = formData.get("issuingEntity");
|
||||
let issuingEntity = typeof issuingEntityRaw === "string" ? issuingEntityRaw.trim() || null : null;
|
||||
const sourceLicitationIdRaw = formData.get("sourceLicitationId");
|
||||
const sourceLicitationId = typeof sourceLicitationIdRaw === "string" ? sourceLicitationIdRaw.trim() || null : null;
|
||||
const sourceProposalIdRaw = formData.get("sourceProposalId");
|
||||
const sourceProposalId = typeof sourceProposalIdRaw === "string" ? sourceProposalIdRaw.trim() || null : null;
|
||||
const fileInput = formData.get("file");
|
||||
const uploadedFile = fileInput instanceof File && fileInput.size > 0 ? fileInput : null;
|
||||
const useSourceDocument = String(formData.get("useSourceDocument") ?? "") === "1";
|
||||
|
||||
try {
|
||||
let fileName = "";
|
||||
let fileBuffer: Buffer | null = null;
|
||||
let linkedLicitationId: string | null = null;
|
||||
|
||||
if (uploadedFile) {
|
||||
if (uploadedFile.size > MAX_NORMATIVE_PDF_BYTES) {
|
||||
return NextResponse.json({ error: "El archivo excede el limite de 20MB." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!isPdfFile(uploadedFile)) {
|
||||
return NextResponse.json({ error: "Solo se permiten archivos PDF." }, { status: 400 });
|
||||
}
|
||||
|
||||
fileBuffer = Buffer.from(await uploadedFile.arrayBuffer());
|
||||
fileName = uploadedFile.name;
|
||||
|
||||
if (!hasPdfSignature(fileBuffer)) {
|
||||
return NextResponse.json({ error: "El archivo no parece ser un PDF valido." }, { status: 400 });
|
||||
}
|
||||
} else if (sourceProposalId && useSourceDocument) {
|
||||
const sourceProposal = await prisma.proposal.findFirst({
|
||||
where: {
|
||||
id: sourceProposalId,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
issuingEntity: true,
|
||||
sourceLicitationId: true,
|
||||
documents: {
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
select: {
|
||||
fileName: true,
|
||||
filePath: true,
|
||||
mimeType: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!sourceProposal) {
|
||||
return NextResponse.json({ error: "No se encontro la propuesta vinculada de Modulo 5." }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!issuingEntity) {
|
||||
issuingEntity = sourceProposal.issuingEntity?.trim() || null;
|
||||
}
|
||||
|
||||
linkedLicitationId = sourceProposal.sourceLicitationId ?? null;
|
||||
const proposalPdf = pickPrimaryProposalPdfDocument(sourceProposal.documents);
|
||||
|
||||
if (proposalPdf) {
|
||||
fileBuffer = await readStoredProposalPdf(proposalPdf.filePath);
|
||||
fileName = proposalPdf.fileName;
|
||||
|
||||
if (!hasPdfSignature(fileBuffer)) {
|
||||
return NextResponse.json({ error: "El PDF principal de Modulo 5 no tiene una firma valida." }, { status: 422 });
|
||||
}
|
||||
} else if (sourceProposal.sourceLicitationId) {
|
||||
const licitationSource = await resolveLicitationSourcePdf(sourceProposal.sourceLicitationId, issuingEntity);
|
||||
fileBuffer = licitationSource.fileBuffer;
|
||||
fileName = licitationSource.fileName;
|
||||
linkedLicitationId = licitationSource.linkedLicitationId;
|
||||
issuingEntity = licitationSource.issuingEntity;
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: "La propuesta de Modulo 5 no tiene un PDF disponible para analisis automatico. Sube un PDF manualmente." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
} else if (sourceLicitationId && useSourceDocument) {
|
||||
const licitationSource = await resolveLicitationSourcePdf(sourceLicitationId, issuingEntity);
|
||||
fileBuffer = licitationSource.fileBuffer;
|
||||
fileName = licitationSource.fileName;
|
||||
linkedLicitationId = licitationSource.linkedLicitationId;
|
||||
issuingEntity = licitationSource.issuingEntity;
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: "Debes adjuntar un PDF o vincular una propuesta de Modulo 5 / oportunidad de Modulo 3 para usar su documento principal." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!fileBuffer) {
|
||||
return NextResponse.json({ error: "No fue posible obtener un documento PDF para analizar." }, { status: 400 });
|
||||
}
|
||||
|
||||
const analyzed = await analyzePdf(fileBuffer);
|
||||
const aiAnalysis = await analyzeNormativeTextWithAi({
|
||||
text: analyzed.text,
|
||||
fileName,
|
||||
documentType,
|
||||
issuingEntity,
|
||||
methodUsed: analyzed.methodUsed,
|
||||
numPages: analyzed.numPages,
|
||||
warnings: analyzed.warnings,
|
||||
});
|
||||
const result = aiAnalysis.result;
|
||||
const combinedWarnings = [
|
||||
...analyzed.warnings,
|
||||
...aiAnalysis.warnings,
|
||||
`Motor de analisis: ${aiAnalysis.engine}${aiAnalysis.model ? ` (${aiAnalysis.model})` : ""}`,
|
||||
];
|
||||
|
||||
if (!linkedLicitationId && sourceLicitationId) {
|
||||
const linkedLicitation = await prisma.licitation.findUnique({
|
||||
where: { id: sourceLicitationId },
|
||||
select: { id: true },
|
||||
});
|
||||
linkedLicitationId = linkedLicitation?.id ?? null;
|
||||
}
|
||||
|
||||
if (!linkedLicitationId && sourceProposalId) {
|
||||
const linkedProposal = await prisma.proposal.findFirst({
|
||||
where: {
|
||||
id: sourceProposalId,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
sourceLicitationId: true,
|
||||
},
|
||||
});
|
||||
linkedLicitationId = linkedProposal?.sourceLicitationId ?? null;
|
||||
}
|
||||
|
||||
const created = await prisma.normativeAnalysisHistory.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
sourceLicitationId: linkedLicitationId,
|
||||
fileName,
|
||||
documentType: toPrismaDocumentType(documentType),
|
||||
issuingEntity,
|
||||
methodUsed: analyzed.methodUsed === "ocr" ? "OCR" : "DIRECT",
|
||||
numPages: analyzed.numPages,
|
||||
warnings: combinedWarnings,
|
||||
extractedChars: analyzed.text.length,
|
||||
confidence: toPrismaConfidence(result.confidence),
|
||||
viabilityScore: result.participationViability.score,
|
||||
riskLevel: toPrismaRiskLevel(result.risk.level),
|
||||
executiveSummary: result.executiveSummary,
|
||||
result,
|
||||
analyzedAt: new Date(),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
sourceLicitationId: true,
|
||||
fileName: true,
|
||||
documentType: true,
|
||||
issuingEntity: true,
|
||||
methodUsed: true,
|
||||
numPages: true,
|
||||
warnings: true,
|
||||
extractedChars: true,
|
||||
analyzedAt: true,
|
||||
viabilityScore: true,
|
||||
riskLevel: true,
|
||||
confidence: true,
|
||||
result: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
item: toNormativeHistoryView(created),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene las tablas de Analisis Normativo. Ejecuta prisma migrate para continuar." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message === "SOURCE_LICITATION_NOT_FOUND") {
|
||||
return NextResponse.json({ error: "No se encontro la oportunidad vinculada de Modulo 3." }, { status: 404 });
|
||||
}
|
||||
|
||||
if (error.message === "SOURCE_LICITATION_NO_PDF") {
|
||||
return NextResponse.json(
|
||||
{ error: "La oportunidad de Modulo 3 no tiene un PDF principal disponible para analisis automatico." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (error.message === "SOURCE_LICITATION_NO_VALID_PDF") {
|
||||
return NextResponse.json(
|
||||
{ error: "No se pudo obtener un PDF valido desde los enlaces de la oportunidad en Modulo 3." },
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
|
||||
if (error.message === "SOURCE_LICITATION_PDF_TOO_LARGE") {
|
||||
return NextResponse.json({ error: "Los documentos vinculados de Modulo 3 exceden el limite de 20MB." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (error.message === "SOURCE_LICITATION_PDF_TIMEOUT") {
|
||||
return NextResponse.json({ error: "Se excedio el tiempo al descargar documentos vinculados de Modulo 3." }, { status: 504 });
|
||||
}
|
||||
|
||||
if (error.message === "SOURCE_LICITATION_INVALID_PDF") {
|
||||
return NextResponse.json(
|
||||
{ error: "El documento principal de Modulo 3 no tiene una firma PDF valida." },
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
|
||||
if (error.message === "PROPOSAL_PDF_INVALID_PATH") {
|
||||
return NextResponse.json({ error: "El archivo de Modulo 5 no esta disponible para lectura segura." }, { status: 422 });
|
||||
}
|
||||
|
||||
if (error.message === "PROPOSAL_PDF_NOT_FOUND") {
|
||||
return NextResponse.json({ error: "No se encontro el PDF principal cargado en Modulo 5." }, { status: 422 });
|
||||
}
|
||||
|
||||
if (error.message === "PROPOSAL_PDF_EMPTY") {
|
||||
return NextResponse.json({ error: "El PDF principal de Modulo 5 esta vacio." }, { status: 422 });
|
||||
}
|
||||
|
||||
if (error.message === "PROPOSAL_PDF_TOO_LARGE") {
|
||||
return NextResponse.json({ error: "El PDF principal de Modulo 5 excede el limite de 20MB." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (error.name === "AbortError") {
|
||||
return NextResponse.json({ error: "Se excedio el tiempo al descargar el PDF principal de Modulo 3." }, { status: 504 });
|
||||
}
|
||||
|
||||
if (error.message === "SOURCE_PDF_TOO_LARGE") {
|
||||
return NextResponse.json({ error: "El PDF principal de Modulo 3 excede el limite de 20MB." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (error.message === "SOURCE_PDF_EMPTY") {
|
||||
return NextResponse.json({ error: "El PDF principal de Modulo 3 esta vacio." }, { status: 422 });
|
||||
}
|
||||
|
||||
if (error.message.startsWith("SOURCE_FETCH_FAILED_")) {
|
||||
return NextResponse.json({ error: "No fue posible descargar el PDF principal desde la fuente de Modulo 3." }, { status: 422 });
|
||||
}
|
||||
}
|
||||
|
||||
const mapped = mapAnalysisError(error);
|
||||
return NextResponse.json({ error: mapped.error, code: mapped.code }, { status: mapped.status });
|
||||
}
|
||||
}
|
||||
50
src/app/api/payments/checkout/return/route.ts
Normal file
50
src/app/api/payments/checkout/return/route.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { buildAppUrl } from "@/lib/http/url";
|
||||
import { fetchMercadoPaymentById, hasMercadoPagoConfig } from "@/lib/payments/mercadopago";
|
||||
import { syncPurchaseFromPayment } from "@/lib/payments/subscriptions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function pickFirst(url: URL, keys: string[]) {
|
||||
for (const key of keys) {
|
||||
const value = url.searchParams.get(key)?.trim();
|
||||
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const requestUrl = new URL(request.url);
|
||||
const result = requestUrl.searchParams.get("result")?.trim() || "pending";
|
||||
const paymentId = pickFirst(requestUrl, ["payment_id", "collection_id"]);
|
||||
const externalReference = pickFirst(requestUrl, ["external_reference"]);
|
||||
const fallbackStatus = pickFirst(requestUrl, ["status", "collection_status"]) || result;
|
||||
const merchantOrderId = pickFirst(requestUrl, ["merchant_order_id"]);
|
||||
|
||||
if (paymentId && externalReference && hasMercadoPagoConfig()) {
|
||||
try {
|
||||
const payment = await fetchMercadoPaymentById(paymentId);
|
||||
const resolvedExternalReference = payment.external_reference || externalReference;
|
||||
|
||||
await syncPurchaseFromPayment(prisma, {
|
||||
externalReference: resolvedExternalReference,
|
||||
paymentId,
|
||||
paymentStatus: payment.status,
|
||||
mercadoOrderId: payment.order?.id || merchantOrderId || null,
|
||||
rawPayload: payment,
|
||||
});
|
||||
} catch {
|
||||
// Callback reconciliation is best-effort; webhook still updates asynchronously.
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.redirect(
|
||||
buildAppUrl(request, "/dashboard", {
|
||||
mp_result: result,
|
||||
mp_payment_status: fallbackStatus,
|
||||
}),
|
||||
);
|
||||
}
|
||||
104
src/app/api/payments/checkout/route.ts
Normal file
104
src/app/api/payments/checkout/route.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionPayload } from "@/lib/auth/session";
|
||||
import { buildAppUrl, getRequestOrigin } from "@/lib/http/url";
|
||||
import { createMercadoPreference, hasMercadoPagoConfig } from "@/lib/payments/mercadopago";
|
||||
import { getModulePlanByUiKey } from "@/lib/payments/plans";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function redirectToDashboard(request: Request, params: Record<string, string>) {
|
||||
return NextResponse.redirect(buildAppUrl(request, "/dashboard", params));
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const session = await getSessionPayload();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.redirect(buildAppUrl(request, "/login", { error: "auth_required" }));
|
||||
}
|
||||
|
||||
const requestUrl = new URL(request.url);
|
||||
const planUiKey = requestUrl.searchParams.get("plan")?.trim() || "";
|
||||
const selectedPlan = getModulePlanByUiKey(planUiKey);
|
||||
|
||||
if (!selectedPlan) {
|
||||
return redirectToDashboard(request, { mp_error: "invalid_plan" });
|
||||
}
|
||||
|
||||
if (!hasMercadoPagoConfig()) {
|
||||
return redirectToDashboard(request, { mp_error: "missing_mp_config" });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.redirect(buildAppUrl(request, "/login", { error: "auth_required" }));
|
||||
}
|
||||
|
||||
const externalReference = `${selectedPlan.key}_${user.id}_${Date.now()}_${randomUUID().slice(0, 8)}`;
|
||||
const origin = getRequestOrigin(request);
|
||||
const returnBase = `${origin}/api/payments/checkout/return`;
|
||||
|
||||
const createdPurchase = await prisma.modulePlanPurchase.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
planKey: selectedPlan.key,
|
||||
amount: selectedPlan.monthlyPrice,
|
||||
currency: selectedPlan.currency,
|
||||
externalReference,
|
||||
requestJson: {
|
||||
uiPlan: selectedPlan.uiKey,
|
||||
name: selectedPlan.name,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const preference = await createMercadoPreference({
|
||||
externalReference,
|
||||
payerEmail: user.email,
|
||||
items: [
|
||||
{
|
||||
id: selectedPlan.uiKey,
|
||||
title: `${selectedPlan.name} Modulos ${selectedPlan.moduleStart}-${selectedPlan.moduleEnd}`,
|
||||
quantity: 1,
|
||||
unit_price: selectedPlan.monthlyPrice,
|
||||
currency_id: selectedPlan.currency,
|
||||
},
|
||||
],
|
||||
successUrl: `${returnBase}?result=success`,
|
||||
failureUrl: `${returnBase}?result=failure`,
|
||||
pendingUrl: `${returnBase}?result=pending`,
|
||||
notificationUrl: `${origin}/api/payments/mercadopago/webhook`,
|
||||
});
|
||||
|
||||
const checkoutUrl = preference.initPoint || preference.sandboxInitPoint;
|
||||
|
||||
await prisma.modulePlanPurchase.update({
|
||||
where: { id: createdPurchase.id },
|
||||
data: {
|
||||
mercadoPreferenceId: preference.id || null,
|
||||
checkoutUrl: checkoutUrl || null,
|
||||
responseJson: preference.rawPayload as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
if (!checkoutUrl) {
|
||||
return redirectToDashboard(request, { mp_error: "missing_checkout_url" });
|
||||
}
|
||||
|
||||
return NextResponse.redirect(checkoutUrl);
|
||||
} catch {
|
||||
return redirectToDashboard(request, { mp_error: "checkout_create_failed" });
|
||||
}
|
||||
}
|
||||
66
src/app/api/payments/mercadopago/webhook/route.ts
Normal file
66
src/app/api/payments/mercadopago/webhook/route.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { fetchMercadoPaymentById, hasMercadoPagoConfig } from "@/lib/payments/mercadopago";
|
||||
import { syncPurchaseFromPayment } from "@/lib/payments/subscriptions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function parseBody(body: unknown) {
|
||||
return typeof body === "object" && body !== null ? (body as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function parseWebhookPaymentId(url: URL, body: Record<string, unknown>) {
|
||||
const fromQuery = url.searchParams.get("data.id")?.trim() || url.searchParams.get("id")?.trim() || "";
|
||||
|
||||
if (fromQuery) {
|
||||
return fromQuery;
|
||||
}
|
||||
|
||||
const data = typeof body.data === "object" && body.data !== null ? (body.data as Record<string, unknown>) : null;
|
||||
return typeof data?.id === "string" || typeof data?.id === "number" ? String(data.id) : "";
|
||||
}
|
||||
|
||||
async function processWebhook(request: Request, body: Record<string, unknown>) {
|
||||
if (!hasMercadoPagoConfig()) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const requestUrl = new URL(request.url);
|
||||
const eventType = (typeof body.type === "string" ? body.type : requestUrl.searchParams.get("type") || requestUrl.searchParams.get("topic") || "").toLowerCase();
|
||||
const paymentId = parseWebhookPaymentId(requestUrl, body);
|
||||
|
||||
if (eventType && eventType !== "payment") {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
if (!paymentId) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
try {
|
||||
const payment = await fetchMercadoPaymentById(paymentId);
|
||||
|
||||
if (!payment.external_reference) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
await syncPurchaseFromPayment(prisma, {
|
||||
externalReference: payment.external_reference,
|
||||
paymentId,
|
||||
paymentStatus: payment.status,
|
||||
mercadoOrderId: payment.order?.id || null,
|
||||
rawPayload: payment,
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = parseBody(await request.json().catch(() => ({})));
|
||||
return processWebhook(request, body);
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
return processWebhook(request, {});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { requireAdminApiUserMock, readStoredProposalDocumentMock, prismaMock } = vi.hoisted(() => ({
|
||||
requireAdminApiUserMock: vi.fn(),
|
||||
readStoredProposalDocumentMock: vi.fn(),
|
||||
prismaMock: {
|
||||
proposalDocument: {
|
||||
findFirst: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/admin", () => ({
|
||||
requireAdminApiUser: requireAdminApiUserMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/proposals/storage", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/proposals/storage")>("@/lib/proposals/storage");
|
||||
return {
|
||||
...actual,
|
||||
readStoredProposalDocument: readStoredProposalDocumentMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
import { GET } from "@/app/api/proposals/[id]/documents/[documentId]/route";
|
||||
|
||||
describe("proposal document download API security", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns 404 for non-owned or missing proposal document", async () => {
|
||||
requireAdminApiUserMock.mockResolvedValue({ id: "user-1" });
|
||||
prismaMock.proposalDocument.findFirst.mockResolvedValue(null);
|
||||
|
||||
const response = await GET(new Request("http://localhost/api/proposals/p1/documents/d1"), {
|
||||
params: Promise.resolve({ id: "p1", documentId: "d1" }),
|
||||
});
|
||||
const payload = (await response.json()) as { error?: string };
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toContain("Documento no encontrado");
|
||||
expect(readStoredProposalDocumentMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 when storage path is invalid", async () => {
|
||||
requireAdminApiUserMock.mockResolvedValue({ id: "user-1" });
|
||||
prismaMock.proposalDocument.findFirst.mockResolvedValue({
|
||||
id: "d1",
|
||||
fileName: "propuesta.pdf",
|
||||
filePath: "../outside.pdf",
|
||||
mimeType: "application/pdf",
|
||||
});
|
||||
readStoredProposalDocumentMock.mockRejectedValue(new Error("PROPOSAL_DOCUMENT_INVALID_PATH"));
|
||||
|
||||
const response = await GET(new Request("http://localhost/api/proposals/p1/documents/d1"), {
|
||||
params: Promise.resolve({ id: "p1", documentId: "d1" }),
|
||||
});
|
||||
const payload = (await response.json()) as { error?: string };
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toContain("Ruta de almacenamiento invalida");
|
||||
});
|
||||
});
|
||||
145
src/app/api/proposals/[id]/documents/[documentId]/route.ts
Normal file
145
src/app/api/proposals/[id]/documents/[documentId]/route.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { readStoredProposalDocument, removeStoredProposalDocument } from "@/lib/proposals/storage";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function isSchemaNotReadyError(error: unknown) {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
|
||||
}
|
||||
|
||||
function safeContentDispositionFilename(value: string) {
|
||||
const normalized = value.trim().replace(/[\r\n"]/g, "_");
|
||||
return normalized || "documento";
|
||||
}
|
||||
|
||||
function mapDocumentReadError(error: unknown) {
|
||||
if (!(error instanceof Error)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (error.message === "PROPOSAL_DOCUMENT_NOT_FOUND") {
|
||||
return { status: 404, message: "Archivo no encontrado en almacenamiento." };
|
||||
}
|
||||
|
||||
if (error.message === "PROPOSAL_DOCUMENT_INVALID_PATH") {
|
||||
return { status: 400, message: "Ruta de almacenamiento invalida." };
|
||||
}
|
||||
|
||||
if (error.message === "PROPOSAL_DOCUMENT_EMPTY") {
|
||||
return { status: 400, message: "Archivo vacio en almacenamiento." };
|
||||
}
|
||||
|
||||
if (error.message === "PROPOSAL_DOCUMENT_TOO_LARGE") {
|
||||
return { status: 400, message: "Archivo excede el limite permitido." };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getOwnedProposalDocument(params: { userId: string; proposalId: string; documentId: string }) {
|
||||
return prisma.proposalDocument.findFirst({
|
||||
where: {
|
||||
id: params.documentId,
|
||||
proposalId: params.proposalId,
|
||||
userId: params.userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
filePath: true,
|
||||
mimeType: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(_: Request, context: { params: Promise<{ id: string; documentId: string }> }) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id, documentId } = await context.params;
|
||||
|
||||
const document = await getOwnedProposalDocument({
|
||||
userId: user.id,
|
||||
proposalId: id,
|
||||
documentId,
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
return NextResponse.json({ error: "Documento no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
let fileBuffer: Buffer;
|
||||
try {
|
||||
fileBuffer = await readStoredProposalDocument(document.filePath);
|
||||
} catch (error) {
|
||||
const mapped = mapDocumentReadError(error);
|
||||
if (mapped) {
|
||||
return NextResponse.json({ error: mapped.message }, { status: mapped.status });
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const encodedFilename = encodeURIComponent(document.fileName);
|
||||
return new NextResponse(new Uint8Array(fileBuffer), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": document.mimeType || "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="${safeContentDispositionFilename(document.fileName)}"; filename*=UTF-8''${encodedFilename}`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene las tablas de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible descargar el documento." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_: Request, context: { params: Promise<{ id: string; documentId: string }> }) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id, documentId } = await context.params;
|
||||
|
||||
const document = await getOwnedProposalDocument({
|
||||
userId: user.id,
|
||||
proposalId: id,
|
||||
documentId,
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
return NextResponse.json({ error: "Documento no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
await removeStoredProposalDocument(document.filePath);
|
||||
await prisma.proposalDocument.delete({ where: { id: document.id } });
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene las tablas de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible eliminar el documento." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
170
src/app/api/proposals/[id]/documents/route.ts
Normal file
170
src/app/api/proposals/[id]/documents/route.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
MAX_PROPOSAL_DOCUMENT_BYTES,
|
||||
isAllowedProposalMimeType,
|
||||
storeProposalDocument,
|
||||
} from "@/lib/proposals/storage";
|
||||
|
||||
function isSchemaNotReadyError(error: unknown) {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
|
||||
}
|
||||
|
||||
function toProposalPayload(
|
||||
proposal: {
|
||||
id: string;
|
||||
title: string;
|
||||
issuingEntity: string;
|
||||
summary: string;
|
||||
status: string;
|
||||
sourceLicitationId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
documents: Array<{
|
||||
id: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
createdAt: Date;
|
||||
}>;
|
||||
} & {
|
||||
currentStep?: number;
|
||||
completionPercent?: number;
|
||||
readyForSubmissionAt?: Date | null;
|
||||
},
|
||||
) {
|
||||
return {
|
||||
id: proposal.id,
|
||||
title: proposal.title,
|
||||
issuingEntity: proposal.issuingEntity,
|
||||
summary: proposal.summary,
|
||||
currentStep: proposal.currentStep ?? 1,
|
||||
completionPercent: proposal.completionPercent ?? 0,
|
||||
readyForSubmissionAt: proposal.readyForSubmissionAt ? proposal.readyForSubmissionAt.toISOString() : null,
|
||||
status: proposal.status,
|
||||
sourceLicitationId: proposal.sourceLicitationId,
|
||||
createdAt: proposal.createdAt.toISOString(),
|
||||
updatedAt: proposal.updatedAt.toISOString(),
|
||||
documents: proposal.documents.map((document) => ({
|
||||
id: document.id,
|
||||
fileName: document.fileName,
|
||||
mimeType: document.mimeType,
|
||||
sizeBytes: document.sizeBytes,
|
||||
createdAt: document.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(request: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file");
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ error: "Archivo requerido." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (file.size <= 0) {
|
||||
return NextResponse.json({ error: "El archivo esta vacio." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (file.size > MAX_PROPOSAL_DOCUMENT_BYTES) {
|
||||
return NextResponse.json({ error: "El archivo excede el limite de 20MB." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!isAllowedProposalMimeType(file.type)) {
|
||||
return NextResponse.json({ error: "Tipo de archivo no permitido (PDF, DOC, DOCX, XLSX, JPG o PNG)." }, { status: 400 });
|
||||
}
|
||||
|
||||
const proposal = await prisma.proposal.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!proposal) {
|
||||
return NextResponse.json({ error: "Propuesta no encontrada." }, { status: 404 });
|
||||
}
|
||||
|
||||
const fileBuffer = Buffer.from(await file.arrayBuffer());
|
||||
const stored = await storeProposalDocument(user.id, proposal.id, file.name, file.type, fileBuffer);
|
||||
|
||||
await prisma.proposalDocument.create({
|
||||
data: {
|
||||
proposalId: proposal.id,
|
||||
userId: user.id,
|
||||
fileName: stored.fileName,
|
||||
storedFileName: stored.storedFileName,
|
||||
filePath: stored.filePath,
|
||||
mimeType: stored.mimeType,
|
||||
sizeBytes: stored.sizeBytes,
|
||||
checksumSha256: stored.checksumSha256,
|
||||
},
|
||||
});
|
||||
|
||||
if (proposal.status === "DRAFT") {
|
||||
await prisma.proposal.update({
|
||||
where: { id: proposal.id },
|
||||
data: {
|
||||
status: "IN_PROGRESS",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await prisma.proposal.findUnique({
|
||||
where: { id: proposal.id },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
issuingEntity: true,
|
||||
summary: true,
|
||||
status: true,
|
||||
sourceLicitationId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
documents: {
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
mimeType: true,
|
||||
sizeBytes: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
return NextResponse.json({ error: "Propuesta no encontrada." }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
proposal: toProposalPayload(updated),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene las tablas de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible subir el documento." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
106
src/app/api/proposals/[id]/pdf/[kind]/route.ts
Normal file
106
src/app/api/proposals/[id]/pdf/[kind]/route.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { buildPdfFilename, generateProposalPdf, type ProposalPdfKind } from "@/lib/proposals/pdf";
|
||||
import { ProposalWorkflowStateSchema } from "@/lib/proposals/workflow-state";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function isSchemaNotReadyError(error: unknown) {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
|
||||
}
|
||||
|
||||
function parseKind(value: string): ProposalPdfKind | null {
|
||||
if (value === "technical" || value === "economic" || value === "summary") {
|
||||
return value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET(_: Request, context: { params: Promise<{ id: string; kind: string }> }) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id, kind: kindParam } = await context.params;
|
||||
const kind = parseKind(kindParam);
|
||||
if (!kind) {
|
||||
return NextResponse.json({ error: "Tipo de PDF invalido." }, { status: 400 });
|
||||
}
|
||||
|
||||
const proposal = await prisma.proposal.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
issuingEntity: true,
|
||||
summary: true,
|
||||
status: true,
|
||||
currentStep: true,
|
||||
completionPercent: true,
|
||||
updatedAt: true,
|
||||
workflowDraft: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!proposal) {
|
||||
return NextResponse.json({ error: "Propuesta no encontrada." }, { status: 404 });
|
||||
}
|
||||
|
||||
const workflowParsed = ProposalWorkflowStateSchema.safeParse(proposal.workflowDraft);
|
||||
if (!workflowParsed.success) {
|
||||
return NextResponse.json({ error: "No hay flujo persistido valido para generar PDF." }, { status: 400 });
|
||||
}
|
||||
|
||||
const buffer = await generateProposalPdf({
|
||||
kind,
|
||||
proposal: {
|
||||
title: proposal.title,
|
||||
issuingEntity: proposal.issuingEntity,
|
||||
summary: proposal.summary,
|
||||
status: proposal.status,
|
||||
currentStep: proposal.currentStep,
|
||||
completionPercent: proposal.completionPercent,
|
||||
updatedAtIso: proposal.updatedAt.toISOString(),
|
||||
},
|
||||
workflow: workflowParsed.data,
|
||||
});
|
||||
|
||||
const filename = buildPdfFilename({
|
||||
kind,
|
||||
title: proposal.title,
|
||||
});
|
||||
const body = new Uint8Array(buffer);
|
||||
|
||||
return new NextResponse(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene los campos de persistencia de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "No fue posible generar el PDF. Verifica que Playwright y Chromium esten disponibles en el entorno.",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
262
src/app/api/proposals/[id]/route.ts
Normal file
262
src/app/api/proposals/[id]/route.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { canTransitionProposalStatus, parseProposalStatus } from "@/lib/proposals/status";
|
||||
import { removeStoredProposalDocument } from "@/lib/proposals/storage";
|
||||
|
||||
function isSchemaNotReadyError(error: unknown) {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
|
||||
}
|
||||
|
||||
function parseString(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function toProposalPayload(
|
||||
proposal: {
|
||||
id: string;
|
||||
title: string;
|
||||
issuingEntity: string;
|
||||
summary: string;
|
||||
status: string;
|
||||
sourceLicitationId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
documents: Array<{
|
||||
id: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
createdAt: Date;
|
||||
filePath?: string;
|
||||
}>;
|
||||
} & {
|
||||
currentStep?: number;
|
||||
completionPercent?: number;
|
||||
readyForSubmissionAt?: Date | null;
|
||||
},
|
||||
) {
|
||||
return {
|
||||
id: proposal.id,
|
||||
title: proposal.title,
|
||||
issuingEntity: proposal.issuingEntity,
|
||||
summary: proposal.summary,
|
||||
currentStep: proposal.currentStep ?? 1,
|
||||
completionPercent: proposal.completionPercent ?? 0,
|
||||
readyForSubmissionAt: proposal.readyForSubmissionAt ? proposal.readyForSubmissionAt.toISOString() : null,
|
||||
status: proposal.status,
|
||||
sourceLicitationId: proposal.sourceLicitationId,
|
||||
createdAt: proposal.createdAt.toISOString(),
|
||||
updatedAt: proposal.updatedAt.toISOString(),
|
||||
documents: proposal.documents.map((document) => ({
|
||||
id: document.id,
|
||||
fileName: document.fileName,
|
||||
mimeType: document.mimeType,
|
||||
sizeBytes: document.sizeBytes,
|
||||
createdAt: document.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
|
||||
const proposal = await prisma.proposal.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
issuingEntity: true,
|
||||
summary: true,
|
||||
status: true,
|
||||
sourceLicitationId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
documents: {
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
mimeType: true,
|
||||
sizeBytes: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!proposal) {
|
||||
return NextResponse.json({ error: "Propuesta no encontrada." }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
proposal: toProposalPayload(proposal),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene las tablas de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible obtener la propuesta." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const body = (await request.json()) as Record<string, unknown>;
|
||||
|
||||
const proposal = await prisma.proposal.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
issuingEntity: true,
|
||||
summary: true,
|
||||
status: true,
|
||||
sourceLicitationId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
documents: {
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
mimeType: true,
|
||||
sizeBytes: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!proposal) {
|
||||
return NextResponse.json({ error: "Propuesta no encontrada." }, { status: 404 });
|
||||
}
|
||||
|
||||
const title = parseString(body.title);
|
||||
const issuingEntity = parseString(body.issuingEntity);
|
||||
const summary = parseString(body.summary);
|
||||
const nextStatus = parseProposalStatus(body.status);
|
||||
|
||||
if (body.status != null && !nextStatus) {
|
||||
return NextResponse.json({ error: "Estatus invalido." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (nextStatus && !canTransitionProposalStatus(proposal.status, nextStatus)) {
|
||||
return NextResponse.json({ error: "La transicion de estatus no es valida." }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated = await prisma.proposal.update({
|
||||
where: { id: proposal.id },
|
||||
data: {
|
||||
...(title ? { title } : {}),
|
||||
...(issuingEntity ? { issuingEntity } : {}),
|
||||
...(summary || body.summary === "" ? { summary } : {}),
|
||||
...(nextStatus ? { status: nextStatus } : {}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
issuingEntity: true,
|
||||
summary: true,
|
||||
status: true,
|
||||
sourceLicitationId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
documents: {
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
mimeType: true,
|
||||
sizeBytes: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
proposal: toProposalPayload(updated),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene las tablas de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible actualizar la propuesta." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
|
||||
const proposal = await prisma.proposal.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
documents: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!proposal) {
|
||||
return NextResponse.json({ error: "Propuesta no encontrada." }, { status: 404 });
|
||||
}
|
||||
|
||||
for (const document of proposal.documents) {
|
||||
await removeStoredProposalDocument(document.filePath);
|
||||
}
|
||||
|
||||
await prisma.proposal.delete({ where: { id: proposal.id } });
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene las tablas de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible eliminar la propuesta." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
61
src/app/api/proposals/[id]/workflow/extract/route.test.ts
Normal file
61
src/app/api/proposals/[id]/workflow/extract/route.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { requireAdminApiUserMock, prismaMock } = vi.hoisted(() => ({
|
||||
requireAdminApiUserMock: vi.fn(),
|
||||
prismaMock: {
|
||||
licitation: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
normativeAnalysisHistory: {
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
proposal: {
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/admin", () => ({
|
||||
requireAdminApiUser: requireAdminApiUserMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
import { POST } from "@/app/api/proposals/[id]/workflow/extract/route";
|
||||
|
||||
describe("M4 -> M5 extract continuity regression", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("blocks extraction when proposal has no M4 source and no uploaded PDF", async () => {
|
||||
requireAdminApiUserMock.mockResolvedValue({ id: "user-1" });
|
||||
prismaMock.proposal.findFirst.mockResolvedValue({
|
||||
id: "proposal-1",
|
||||
title: "Propuesta sin fuente",
|
||||
issuingEntity: "Entidad",
|
||||
summary: "Resumen",
|
||||
status: "DRAFT",
|
||||
sourceLicitationId: null,
|
||||
workflowDraft: null,
|
||||
});
|
||||
|
||||
const request = new Request("http://localhost/api/proposals/proposal-1/workflow/extract", {
|
||||
method: "POST",
|
||||
body: new FormData(),
|
||||
});
|
||||
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ id: "proposal-1" }),
|
||||
});
|
||||
const payload = (await response.json()) as { error?: string };
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toContain("no tiene fuente de licitacion");
|
||||
expect(prismaMock.normativeAnalysisHistory.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
740
src/app/api/proposals/[id]/workflow/extract/route.ts
Normal file
740
src/app/api/proposals/[id]/workflow/extract/route.ts
Normal file
@@ -0,0 +1,740 @@
|
||||
import {
|
||||
type NormativeConfidence,
|
||||
type NormativeDocumentType as PrismaNormativeDocumentType,
|
||||
type NormativeRiskLevel,
|
||||
Prisma,
|
||||
} from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { evaluateSignaturePolicy } from "@/lib/compliance/signature-policy";
|
||||
import { parseLicitationDocumentLinks, rankLicitationDocumentsForBundle, type LicitationBundleDocumentKind } from "@/lib/licitations/documents";
|
||||
import { type NormativeDocumentType } from "@/lib/normative-analysis/analyze";
|
||||
import { analyzeNormativeTextWithAi } from "@/lib/normative-analysis/ai-analyze";
|
||||
import { mergeNormativeBundleSources, type NormativeBundleSourceAnalysis } from "@/lib/normative-analysis/bundle";
|
||||
import { toNormativeHistoryView } from "@/lib/normative-analysis/history";
|
||||
import { analyzePdf } from "@/lib/pdf/analyzePdf";
|
||||
import { OcrFailedError, OcrUnavailableError, PdfEncryptedError, PdfNoTextDetectedError, PdfUnreadableError } from "@/lib/pdf/errors";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { canTransitionProposalStatus } from "@/lib/proposals/status";
|
||||
import { buildDefaultWorkflowDraft } from "@/lib/proposals/workflow-default";
|
||||
import { applyAiSeedsToWorkflow, buildMilestoneSeedsFromAnalysis, buildRequirementSeeds, buildTechnicalSectionSeeds, type WorkflowMilestoneSeed } from "@/lib/proposals/workflow";
|
||||
import { ProposalWorkflowStateSchema, computeWorkflowCompletionPercent, computeWorkflowGate } from "@/lib/proposals/workflow-state";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const MAX_NORMATIVE_PDF_BYTES = 20 * 1024 * 1024;
|
||||
const SOURCE_FETCH_TIMEOUT_MS = 45_000;
|
||||
|
||||
type BundleCandidate = {
|
||||
sourceId: string;
|
||||
fileName: string;
|
||||
documentType: NormativeDocumentType;
|
||||
fileBuffer: Buffer;
|
||||
};
|
||||
|
||||
function logContinuityHandoff(event: string, data: Record<string, unknown>) {
|
||||
console.info(
|
||||
"[continuity_handoff]",
|
||||
JSON.stringify({
|
||||
event,
|
||||
at: new Date().toISOString(),
|
||||
...data,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function isSchemaNotReadyError(error: unknown) {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
|
||||
}
|
||||
|
||||
function parseDocumentType(value: unknown): NormativeDocumentType {
|
||||
if (typeof value !== "string") {
|
||||
return "BASES_LICITACION";
|
||||
}
|
||||
|
||||
const normalized = value.trim().toUpperCase();
|
||||
if (normalized === "BASES_LICITACION" || normalized === "CONVOCATORIA" || normalized === "ANEXOS" || normalized === "REGLAMENTO" || normalized === "LEY") {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return "OTRO";
|
||||
}
|
||||
|
||||
function mapBundleKindToDocumentType(kind: LicitationBundleDocumentKind): NormativeDocumentType {
|
||||
if (kind === "BASES_LICITACION") {
|
||||
return "BASES_LICITACION";
|
||||
}
|
||||
|
||||
if (kind === "CONVOCATORIA") {
|
||||
return "CONVOCATORIA";
|
||||
}
|
||||
|
||||
if (kind === "ANEXOS") {
|
||||
return "ANEXOS";
|
||||
}
|
||||
|
||||
return "OTRO";
|
||||
}
|
||||
|
||||
function inferDocumentTypeFromName(fileName: string): NormativeDocumentType {
|
||||
const lower = fileName
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase();
|
||||
|
||||
if (/\bbase(s)?\b|\bpliego\b|\bterminos\b/.test(lower)) {
|
||||
return "BASES_LICITACION";
|
||||
}
|
||||
|
||||
if (/\bconvocatoria\b|\binvitacion\b/.test(lower)) {
|
||||
return "CONVOCATORIA";
|
||||
}
|
||||
|
||||
if (/\banexo(s)?\b|\bformato(s)?\b/.test(lower)) {
|
||||
return "ANEXOS";
|
||||
}
|
||||
|
||||
return "OTRO";
|
||||
}
|
||||
|
||||
function guessFileNameFromUrl(url: string) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const segments = parsed.pathname.split("/").filter(Boolean);
|
||||
const lastSegment = segments.at(-1) ?? "";
|
||||
const decoded = decodeURIComponent(lastSegment).trim();
|
||||
if (!decoded) {
|
||||
return "documento-principal.pdf";
|
||||
}
|
||||
|
||||
return decoded.toLowerCase().endsWith(".pdf") ? decoded : `${decoded}.pdf`;
|
||||
} catch {
|
||||
return "documento-principal.pdf";
|
||||
}
|
||||
}
|
||||
|
||||
function toPrismaDocumentType(value: NormativeDocumentType): PrismaNormativeDocumentType {
|
||||
if (value === "BASES_LICITACION" || value === "CONVOCATORIA" || value === "REGLAMENTO" || value === "LEY") {
|
||||
return value;
|
||||
}
|
||||
|
||||
return "OTRO";
|
||||
}
|
||||
|
||||
function toPrismaRiskLevel(value: "alto" | "medio" | "bajo"): NormativeRiskLevel {
|
||||
if (value === "alto") {
|
||||
return "ALTO";
|
||||
}
|
||||
|
||||
if (value === "medio") {
|
||||
return "MEDIO";
|
||||
}
|
||||
|
||||
return "BAJO";
|
||||
}
|
||||
|
||||
function toPrismaConfidence(value: "low" | "medium" | "high"): NormativeConfidence {
|
||||
if (value === "high") {
|
||||
return "HIGH";
|
||||
}
|
||||
|
||||
if (value === "medium") {
|
||||
return "MEDIUM";
|
||||
}
|
||||
|
||||
return "LOW";
|
||||
}
|
||||
|
||||
function hasPdfSignature(buffer: Buffer) {
|
||||
return buffer.subarray(0, 5).toString("utf8") === "%PDF-";
|
||||
}
|
||||
|
||||
async function fetchSourcePdf(url: string) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), SOURCE_FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
redirect: "follow",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`SOURCE_FETCH_FAILED_${response.status}`);
|
||||
}
|
||||
|
||||
const headerLength = response.headers.get("content-length");
|
||||
const sizeHint = headerLength ? Number.parseInt(headerLength, 10) : 0;
|
||||
if (Number.isFinite(sizeHint) && sizeHint > MAX_NORMATIVE_PDF_BYTES) {
|
||||
throw new Error("SOURCE_PDF_TOO_LARGE");
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
if (!buffer.byteLength) {
|
||||
throw new Error("SOURCE_PDF_EMPTY");
|
||||
}
|
||||
|
||||
if (buffer.byteLength > MAX_NORMATIVE_PDF_BYTES) {
|
||||
throw new Error("SOURCE_PDF_TOO_LARGE");
|
||||
}
|
||||
|
||||
if (!hasPdfSignature(buffer)) {
|
||||
throw new Error("SOURCE_PDF_INVALID");
|
||||
}
|
||||
|
||||
return buffer;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function parseDateCandidate(value: unknown) {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function toMilestoneSeeds(args: {
|
||||
openingDate: Date | null;
|
||||
closingDate: Date | null;
|
||||
eventDates: Prisma.JsonValue | null;
|
||||
}): WorkflowMilestoneSeed[] {
|
||||
const milestones: WorkflowMilestoneSeed[] = [];
|
||||
|
||||
if (args.openingDate) {
|
||||
milestones.push({
|
||||
id: "opening-date",
|
||||
title: "Apertura del proceso",
|
||||
dateIso: args.openingDate.toISOString(),
|
||||
location: "",
|
||||
note: "",
|
||||
source: "licitation",
|
||||
});
|
||||
}
|
||||
|
||||
if (args.closingDate) {
|
||||
milestones.push({
|
||||
id: "closing-date",
|
||||
title: "Presentacion de propuestas",
|
||||
dateIso: args.closingDate.toISOString(),
|
||||
location: "",
|
||||
note: "",
|
||||
source: "licitation",
|
||||
});
|
||||
}
|
||||
|
||||
if (args.eventDates && typeof args.eventDates === "object" && !Array.isArray(args.eventDates)) {
|
||||
const entries = Object.entries(args.eventDates as Record<string, unknown>);
|
||||
for (let index = 0; index < entries.length; index += 1) {
|
||||
const [key, value] = entries[index];
|
||||
const parsed = parseDateCandidate(value);
|
||||
milestones.push({
|
||||
id: `event-date-${index + 1}`,
|
||||
title: key || `Evento ${index + 1}`,
|
||||
dateIso: parsed ? parsed.toISOString() : null,
|
||||
location: "",
|
||||
note: "",
|
||||
source: "licitation",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return milestones;
|
||||
}
|
||||
|
||||
function mapAnalysisError(error: unknown) {
|
||||
if (error instanceof PdfEncryptedError) {
|
||||
return {
|
||||
status: 422,
|
||||
error: "El PDF esta protegido/encriptado. Sube una version sin bloqueo para extraer texto.",
|
||||
code: error.code,
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof PdfUnreadableError) {
|
||||
return {
|
||||
status: 422,
|
||||
error: "No fue posible leer el PDF. Verifica que el archivo no este danado.",
|
||||
code: error.code,
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof OcrUnavailableError) {
|
||||
return {
|
||||
status: 503,
|
||||
error: "No se detecto texto suficiente y OCRmyPDF no esta disponible.",
|
||||
code: error.code,
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof PdfNoTextDetectedError) {
|
||||
return {
|
||||
status: 422,
|
||||
error: "No se detecto texto util en el PDF para analizar.",
|
||||
code: error.code,
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof OcrFailedError) {
|
||||
return {
|
||||
status: 422,
|
||||
error: "No se detecto texto suficiente y el OCR fallo durante el procesamiento.",
|
||||
code: error.code,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 422,
|
||||
error: "No fue posible analizar el PDF.",
|
||||
code: "NORMATIVE_ANALYSIS_FAILED",
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(request: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const proposal = await prisma.proposal.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
issuingEntity: true,
|
||||
summary: true,
|
||||
status: true,
|
||||
sourceLicitationId: true,
|
||||
workflowDraft: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!proposal) {
|
||||
return NextResponse.json({ error: "Propuesta no encontrada." }, { status: 404 });
|
||||
}
|
||||
|
||||
const linkedSource = proposal.sourceLicitationId
|
||||
? await prisma.licitation.findUnique({
|
||||
where: { id: proposal.sourceLicitationId },
|
||||
include: {
|
||||
municipality: {
|
||||
select: {
|
||||
stateName: true,
|
||||
municipalityName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
const formData = await request.formData().catch(() => null);
|
||||
const filesFromMulti = formData
|
||||
? formData.getAll("files").filter((item): item is File => item instanceof File && item.size > 0)
|
||||
: [];
|
||||
const maybeSingleFile = formData ? formData.get("file") : null;
|
||||
const singleFile = maybeSingleFile instanceof File && maybeSingleFile.size > 0 ? maybeSingleFile : null;
|
||||
const uploadedFiles = filesFromMulti.length > 0 ? filesFromMulti : singleFile ? [singleFile] : [];
|
||||
const documentType = parseDocumentType(formData?.get("documentType"));
|
||||
const issuingEntityFromRequest = typeof formData?.get("issuingEntity") === "string" ? String(formData?.get("issuingEntity")).trim() : "";
|
||||
const issuingEntity = issuingEntityFromRequest || proposal.issuingEntity || linkedSource?.supplierAwarded || null;
|
||||
const requestedDocumentTypes = formData
|
||||
? formData
|
||||
.getAll("documentTypes")
|
||||
.map((value) => parseDocumentType(value))
|
||||
: [];
|
||||
|
||||
logContinuityHandoff("m4_to_m5_extract_requested", {
|
||||
userId: user.id,
|
||||
proposalId: proposal.id,
|
||||
sourceLicitationId: proposal.sourceLicitationId,
|
||||
uploadedFiles: uploadedFiles.length,
|
||||
});
|
||||
|
||||
let analysisResult: ReturnType<typeof toNormativeHistoryView>["result"] | null = null;
|
||||
let analysisMeta: {
|
||||
source: "module4_history" | "uploaded_pdf";
|
||||
engine: string | null;
|
||||
model: string | null;
|
||||
warnings: string[];
|
||||
historyId: string | null;
|
||||
bundle?: {
|
||||
sources: Array<{ sourceId: string; fileName: string; documentType: NormativeDocumentType; historyId: string | null }>;
|
||||
conflicts: Array<{ title: string; field: string; keptDocumentType: NormativeDocumentType; replacedDocumentType: NormativeDocumentType }>;
|
||||
};
|
||||
} = {
|
||||
source: "module4_history",
|
||||
engine: null,
|
||||
model: null,
|
||||
warnings: [],
|
||||
historyId: null,
|
||||
bundle: {
|
||||
sources: [],
|
||||
conflicts: [],
|
||||
},
|
||||
};
|
||||
|
||||
const bundleCandidates: BundleCandidate[] = [];
|
||||
|
||||
if (uploadedFiles.length > 0) {
|
||||
for (let index = 0; index < uploadedFiles.length; index += 1) {
|
||||
const file = uploadedFiles[index];
|
||||
if (!file.name.toLowerCase().endsWith(".pdf") && file.type !== "application/pdf") {
|
||||
return NextResponse.json({ error: "Solo se permiten archivos PDF para extraccion IA." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (file.size > MAX_NORMATIVE_PDF_BYTES) {
|
||||
return NextResponse.json({ error: "El archivo excede el limite de 20MB." }, { status: 400 });
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
if (!hasPdfSignature(buffer)) {
|
||||
return NextResponse.json({ error: "El archivo no parece ser un PDF valido." }, { status: 400 });
|
||||
}
|
||||
|
||||
const inferredType = inferDocumentTypeFromName(file.name);
|
||||
const resolvedType =
|
||||
requestedDocumentTypes[index] ??
|
||||
(uploadedFiles.length === 1 && documentType !== "OTRO" ? documentType : inferredType !== "OTRO" ? inferredType : "OTRO");
|
||||
|
||||
bundleCandidates.push({
|
||||
sourceId: `upload-${index + 1}`,
|
||||
fileName: file.name,
|
||||
fileBuffer: buffer,
|
||||
documentType: resolvedType,
|
||||
});
|
||||
}
|
||||
} else if (linkedSource) {
|
||||
const sourceDocuments = parseLicitationDocumentLinks(linkedSource.documents);
|
||||
const ranked = rankLicitationDocumentsForBundle(sourceDocuments, 1);
|
||||
const ordered = [...ranked.bases, ...ranked.convocatoria, ...ranked.anexos, ...ranked.extras];
|
||||
const rawSourceUrl = linkedSource.rawSourceUrl?.trim() ?? "";
|
||||
const candidateDocuments = [...ordered];
|
||||
|
||||
if (rawSourceUrl && !candidateDocuments.some((item) => item.url === rawSourceUrl)) {
|
||||
candidateDocuments.push({
|
||||
name: guessFileNameFromUrl(rawSourceUrl),
|
||||
url: rawSourceUrl,
|
||||
type: null,
|
||||
bundleKind: "OTRO",
|
||||
bundleScore: -10_000,
|
||||
});
|
||||
}
|
||||
|
||||
for (let index = 0; index < candidateDocuments.length && index < 6; index += 1) {
|
||||
const document = candidateDocuments[index];
|
||||
try {
|
||||
const buffer = await fetchSourcePdf(document.url);
|
||||
bundleCandidates.push({
|
||||
sourceId: `source-${index + 1}`,
|
||||
fileName: document.name,
|
||||
fileBuffer: buffer,
|
||||
documentType: mapBundleKindToDocumentType(document.bundleKind),
|
||||
});
|
||||
} catch (error) {
|
||||
analysisMeta.warnings.push(error instanceof Error ? `No se pudo leer ${document.name}: ${error.message}` : `No se pudo leer ${document.name}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bundleCandidates.length > 0) {
|
||||
const bundleAnalyses: NormativeBundleSourceAnalysis[] = [];
|
||||
|
||||
for (const candidate of bundleCandidates.slice(0, 3)) {
|
||||
try {
|
||||
const analyzed = await analyzePdf(candidate.fileBuffer);
|
||||
const aiAnalysis = await analyzeNormativeTextWithAi({
|
||||
text: analyzed.text,
|
||||
fileName: candidate.fileName,
|
||||
documentType: candidate.documentType,
|
||||
issuingEntity,
|
||||
methodUsed: analyzed.methodUsed,
|
||||
numPages: analyzed.numPages,
|
||||
warnings: analyzed.warnings,
|
||||
});
|
||||
|
||||
const combinedWarnings = [
|
||||
...analyzed.warnings,
|
||||
...aiAnalysis.warnings,
|
||||
`Motor de analisis: ${aiAnalysis.engine}${aiAnalysis.model ? ` (${aiAnalysis.model})` : ""}`,
|
||||
];
|
||||
|
||||
const createdHistory = await prisma.normativeAnalysisHistory.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
sourceLicitationId: linkedSource?.id ?? null,
|
||||
fileName: candidate.fileName,
|
||||
documentType: toPrismaDocumentType(candidate.documentType),
|
||||
issuingEntity,
|
||||
methodUsed: analyzed.methodUsed === "ocr" ? "OCR" : "DIRECT",
|
||||
numPages: analyzed.numPages,
|
||||
warnings: combinedWarnings,
|
||||
extractedChars: analyzed.text.length,
|
||||
confidence: toPrismaConfidence(aiAnalysis.result.confidence),
|
||||
viabilityScore: aiAnalysis.result.participationViability.score,
|
||||
riskLevel: toPrismaRiskLevel(aiAnalysis.result.risk.level),
|
||||
executiveSummary: aiAnalysis.result.executiveSummary,
|
||||
result: aiAnalysis.result,
|
||||
analyzedAt: new Date(),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
bundleAnalyses.push({
|
||||
sourceId: candidate.sourceId,
|
||||
fileName: candidate.fileName,
|
||||
documentType: candidate.documentType,
|
||||
result: aiAnalysis.result,
|
||||
});
|
||||
|
||||
analysisMeta.source = "uploaded_pdf";
|
||||
analysisMeta.engine = analysisMeta.engine ?? aiAnalysis.engine;
|
||||
analysisMeta.model = analysisMeta.model ?? aiAnalysis.model;
|
||||
analysisMeta.historyId = analysisMeta.historyId ?? createdHistory.id;
|
||||
analysisMeta.warnings.push(...combinedWarnings);
|
||||
analysisMeta.bundle?.sources.push({
|
||||
sourceId: candidate.sourceId,
|
||||
fileName: candidate.fileName,
|
||||
documentType: candidate.documentType,
|
||||
historyId: createdHistory.id,
|
||||
});
|
||||
} catch (error) {
|
||||
const mapped = mapAnalysisError(error);
|
||||
analysisMeta.warnings.push(`${candidate.fileName}: ${mapped.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (bundleAnalyses.length === 0) {
|
||||
return NextResponse.json({ error: "No fue posible analizar los documentos del bundle." }, { status: 422 });
|
||||
}
|
||||
|
||||
if (bundleAnalyses.length === 1) {
|
||||
analysisResult = bundleAnalyses[0]?.result ?? null;
|
||||
} else {
|
||||
const merged = mergeNormativeBundleSources(bundleAnalyses);
|
||||
analysisResult = merged.result;
|
||||
analysisMeta.bundle = {
|
||||
sources: merged.sources.map((item) => ({
|
||||
...item,
|
||||
historyId: analysisMeta.bundle?.sources.find((source) => source.sourceId === item.sourceId)?.historyId ?? null,
|
||||
})),
|
||||
conflicts: merged.conflicts.map((conflict) => ({
|
||||
title: conflict.title,
|
||||
field: conflict.field,
|
||||
keptDocumentType: conflict.keptDocumentType,
|
||||
replacedDocumentType: conflict.replacedDocumentType,
|
||||
})),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
if (!proposal.sourceLicitationId) {
|
||||
logContinuityHandoff("m4_to_m5_extract_blocked_no_source_context", {
|
||||
userId: user.id,
|
||||
proposalId: proposal.id,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "La propuesta no tiene fuente de licitacion vinculada de Modulo 4. Sube un PDF para extraer requisitos con IA." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const historyEntry = await prisma.normativeAnalysisHistory.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
sourceLicitationId: proposal.sourceLicitationId,
|
||||
},
|
||||
orderBy: [{ analyzedAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
sourceLicitationId: true,
|
||||
fileName: true,
|
||||
documentType: true,
|
||||
issuingEntity: true,
|
||||
methodUsed: true,
|
||||
numPages: true,
|
||||
warnings: true,
|
||||
extractedChars: true,
|
||||
analyzedAt: true,
|
||||
viabilityScore: true,
|
||||
riskLevel: true,
|
||||
confidence: true,
|
||||
result: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!historyEntry) {
|
||||
logContinuityHandoff("m4_to_m5_extract_blocked_no_history", {
|
||||
userId: user.id,
|
||||
proposalId: proposal.id,
|
||||
sourceLicitationId: proposal.sourceLicitationId,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "No hay analisis previo de Modulo 4 vinculado. Sube un PDF para extraer requisitos con IA." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const historyView = toNormativeHistoryView(historyEntry);
|
||||
analysisResult = historyView.result;
|
||||
analysisMeta = {
|
||||
source: "module4_history",
|
||||
engine: "module4_history",
|
||||
model: null,
|
||||
warnings: historyView.warnings,
|
||||
historyId: historyView.id,
|
||||
bundle: {
|
||||
sources: [
|
||||
{
|
||||
sourceId: `history-${historyView.id}`,
|
||||
fileName: historyView.fileName,
|
||||
documentType: historyView.documentType,
|
||||
historyId: historyView.id,
|
||||
},
|
||||
],
|
||||
conflicts: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!analysisResult) {
|
||||
return NextResponse.json({ error: "No se pudo recuperar resultado de analisis IA." }, { status: 400 });
|
||||
}
|
||||
|
||||
const requirementSeeds = buildRequirementSeeds(analysisResult);
|
||||
const technicalSectionSeeds = buildTechnicalSectionSeeds(analysisResult);
|
||||
const aiMilestoneSeeds = buildMilestoneSeedsFromAnalysis(analysisResult);
|
||||
const sourceMilestoneSeeds = linkedSource
|
||||
? toMilestoneSeeds({
|
||||
openingDate: linkedSource.openingDate,
|
||||
closingDate: linkedSource.closingDate,
|
||||
eventDates: linkedSource.eventDates,
|
||||
})
|
||||
: [];
|
||||
const milestoneSeeds = [...sourceMilestoneSeeds, ...aiMilestoneSeeds];
|
||||
const existingWorkflowParsed = ProposalWorkflowStateSchema.safeParse(proposal.workflowDraft);
|
||||
const defaultDraft = buildDefaultWorkflowDraft({
|
||||
proposal: {
|
||||
title: proposal.title,
|
||||
issuingEntity: proposal.issuingEntity,
|
||||
summary: proposal.summary,
|
||||
status: proposal.status,
|
||||
},
|
||||
linkedSource: linkedSource
|
||||
? {
|
||||
title: linkedSource.title,
|
||||
description: linkedSource.description,
|
||||
issuingEntity: linkedSource.supplierAwarded,
|
||||
procedureType: linkedSource.procedureType,
|
||||
stateName: linkedSource.municipality.stateName,
|
||||
municipalityName: linkedSource.municipality.municipalityName,
|
||||
sourceUrl: linkedSource.rawSourceUrl,
|
||||
milestones: milestoneSeeds,
|
||||
}
|
||||
: null,
|
||||
requirementSeeds,
|
||||
technicalSectionSeeds,
|
||||
});
|
||||
|
||||
const baseWorkflow = existingWorkflowParsed.success ? existingWorkflowParsed.data : defaultDraft;
|
||||
const mergedWorkflow = applyAiSeedsToWorkflow(baseWorkflow, {
|
||||
requirementSeeds,
|
||||
technicalSectionSeeds,
|
||||
milestoneSeeds,
|
||||
});
|
||||
const extractedDocumentType = analysisMeta.bundle?.sources[0]?.documentType ?? documentType;
|
||||
const signaturePolicy = evaluateSignaturePolicy({
|
||||
stateName: linkedSource?.municipality.stateName ?? null,
|
||||
municipalityName: linkedSource?.municipality.municipalityName ?? null,
|
||||
documentType: extractedDocumentType,
|
||||
});
|
||||
const workflow = {
|
||||
...mergedWorkflow,
|
||||
signatureCompliance: {
|
||||
...mergedWorkflow.signatureCompliance,
|
||||
policyStatus: signaturePolicy.policyStatus,
|
||||
policyName: signaturePolicy.policyName,
|
||||
jurisdictionLabel: signaturePolicy.jurisdictionLabel,
|
||||
sourceUrl: signaturePolicy.sourceUrl ?? "",
|
||||
minimumEvidence: signaturePolicy.evidenceRequired,
|
||||
notes: signaturePolicy.notes,
|
||||
},
|
||||
step: Math.max(2, mergedWorkflow.step),
|
||||
};
|
||||
|
||||
const completionPercent = computeWorkflowCompletionPercent(workflow);
|
||||
const gate = computeWorkflowGate(workflow);
|
||||
|
||||
const nextStatus =
|
||||
proposal.status === "DRAFT" && completionPercent > 0 && canTransitionProposalStatus("DRAFT", "IN_PROGRESS") ? "IN_PROGRESS" : proposal.status;
|
||||
|
||||
const updated = await prisma.proposal.update({
|
||||
where: { id: proposal.id },
|
||||
data: {
|
||||
workflowDraft: workflow as Prisma.InputJsonValue,
|
||||
currentStep: workflow.step,
|
||||
completionPercent,
|
||||
...(nextStatus !== proposal.status ? { status: nextStatus } : {}),
|
||||
...(gate.allReady ? {} : { readyForSubmissionAt: null }),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
currentStep: true,
|
||||
completionPercent: true,
|
||||
readyForSubmissionAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
logContinuityHandoff("m4_to_m5_extract_completed", {
|
||||
userId: user.id,
|
||||
proposalId: proposal.id,
|
||||
sourceLicitationId: proposal.sourceLicitationId,
|
||||
analysisSource: analysisMeta.source,
|
||||
historyId: analysisMeta.historyId,
|
||||
bundleSources: analysisMeta.bundle?.sources.length ?? 0,
|
||||
bundleConflicts: analysisMeta.bundle?.conflicts.length ?? 0,
|
||||
completionPercent,
|
||||
currentStep: updated.currentStep,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
proposal: {
|
||||
id: updated.id,
|
||||
status: updated.status,
|
||||
currentStep: updated.currentStep,
|
||||
completionPercent: updated.completionPercent,
|
||||
readyForSubmissionAt: updated.readyForSubmissionAt ? updated.readyForSubmissionAt.toISOString() : null,
|
||||
},
|
||||
workflow,
|
||||
gate,
|
||||
analysis: analysisMeta,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene los campos de persistencia de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible extraer requisitos con IA." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
131
src/app/api/proposals/[id]/workflow/mark-ready/route.ts
Normal file
131
src/app/api/proposals/[id]/workflow/mark-ready/route.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { canTransitionProposalStatus } from "@/lib/proposals/status";
|
||||
import { ProposalWorkflowStateSchema, computeWorkflowCompletionPercent, computeWorkflowGate } from "@/lib/proposals/workflow-state";
|
||||
|
||||
function isSchemaNotReadyError(error: unknown) {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
|
||||
}
|
||||
|
||||
export async function POST(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
|
||||
const proposal = await prisma.proposal.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
workflowDraft: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!proposal) {
|
||||
return NextResponse.json({ error: "Propuesta no encontrada." }, { status: 404 });
|
||||
}
|
||||
|
||||
if (proposal.status === "ARCHIVED") {
|
||||
return NextResponse.json({ error: "No puedes marcar una propuesta archivada." }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsedWorkflow = ProposalWorkflowStateSchema.safeParse(proposal.workflowDraft);
|
||||
if (!parsedWorkflow.success) {
|
||||
return NextResponse.json({ error: "No hay flujo persistido valido para validar la propuesta." }, { status: 400 });
|
||||
}
|
||||
|
||||
const workflow = parsedWorkflow.data;
|
||||
const gate = computeWorkflowGate(workflow);
|
||||
if (!gate.allReady) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "No se cumplen todos los requisitos para marcar como Lista para Envio.",
|
||||
gate,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const completionPercent = computeWorkflowCompletionPercent(workflow);
|
||||
const persistedWorkflow = {
|
||||
...workflow,
|
||||
readyMarked: true,
|
||||
step: 6,
|
||||
};
|
||||
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
if (proposal.status === "DRAFT") {
|
||||
if (!canTransitionProposalStatus("DRAFT", "IN_PROGRESS")) {
|
||||
throw new Error("Transicion invalida de estatus.");
|
||||
}
|
||||
|
||||
await tx.proposal.update({
|
||||
where: { id: proposal.id },
|
||||
data: { status: "IN_PROGRESS" },
|
||||
});
|
||||
}
|
||||
|
||||
const current = await tx.proposal.findUnique({
|
||||
where: { id: proposal.id },
|
||||
select: { status: true },
|
||||
});
|
||||
|
||||
if (!current) {
|
||||
throw new Error("Propuesta no encontrada.");
|
||||
}
|
||||
|
||||
if (current.status !== "SUBMITTED" && !canTransitionProposalStatus(current.status, "SUBMITTED")) {
|
||||
throw new Error("Transicion invalida de estatus.");
|
||||
}
|
||||
|
||||
return tx.proposal.update({
|
||||
where: { id: proposal.id },
|
||||
data: {
|
||||
status: "SUBMITTED",
|
||||
workflowDraft: persistedWorkflow as Prisma.InputJsonValue,
|
||||
completionPercent,
|
||||
currentStep: 6,
|
||||
readyForSubmissionAt: new Date(),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
currentStep: true,
|
||||
completionPercent: true,
|
||||
readyForSubmissionAt: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
proposal: {
|
||||
id: updated.id,
|
||||
status: updated.status,
|
||||
currentStep: updated.currentStep,
|
||||
completionPercent: updated.completionPercent,
|
||||
readyForSubmissionAt: updated.readyForSubmissionAt ? updated.readyForSubmissionAt.toISOString() : null,
|
||||
},
|
||||
gate,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene los campos de persistencia de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible marcar la propuesta como lista para envio." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
163
src/app/api/proposals/[id]/workflow/route.ts
Normal file
163
src/app/api/proposals/[id]/workflow/route.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { Prisma, type ProposalStatus } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { canTransitionProposalStatus } from "@/lib/proposals/status";
|
||||
import { ProposalWorkflowStateSchema, clampWorkflowStep, computeWorkflowCompletionPercent, computeWorkflowGate, type ProposalWorkflowState } from "@/lib/proposals/workflow-state";
|
||||
|
||||
function isSchemaNotReadyError(error: unknown) {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
|
||||
}
|
||||
|
||||
function parseWorkflowState(value: unknown): ProposalWorkflowState | null {
|
||||
const parsed = ProposalWorkflowStateSchema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...parsed.data,
|
||||
step: clampWorkflowStep(parsed.data.step),
|
||||
};
|
||||
}
|
||||
|
||||
function nextStatusFromDraft(progressStatus: ProposalStatus, completionPercent: number): ProposalStatus {
|
||||
if (completionPercent <= 0 || progressStatus !== "DRAFT") {
|
||||
return progressStatus;
|
||||
}
|
||||
|
||||
return canTransitionProposalStatus(progressStatus, "IN_PROGRESS") ? "IN_PROGRESS" : progressStatus;
|
||||
}
|
||||
|
||||
export async function GET(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
|
||||
const proposal = await prisma.proposal.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
currentStep: true,
|
||||
completionPercent: true,
|
||||
readyForSubmissionAt: true,
|
||||
workflowDraft: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!proposal) {
|
||||
return NextResponse.json({ error: "Propuesta no encontrada." }, { status: 404 });
|
||||
}
|
||||
|
||||
const workflow = parseWorkflowState(proposal.workflowDraft);
|
||||
const gate = workflow ? computeWorkflowGate(workflow) : null;
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
proposal: {
|
||||
id: proposal.id,
|
||||
status: proposal.status,
|
||||
currentStep: proposal.currentStep,
|
||||
completionPercent: proposal.completionPercent,
|
||||
readyForSubmissionAt: proposal.readyForSubmissionAt ? proposal.readyForSubmissionAt.toISOString() : null,
|
||||
},
|
||||
workflow,
|
||||
gate,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene los campos de persistencia de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible cargar el flujo de trabajo." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const body = (await request.json()) as Record<string, unknown>;
|
||||
const workflow = parseWorkflowState(body.workflow);
|
||||
|
||||
if (!workflow) {
|
||||
return NextResponse.json({ error: "Workflow invalido." }, { status: 400 });
|
||||
}
|
||||
|
||||
const proposal = await prisma.proposal.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!proposal) {
|
||||
return NextResponse.json({ error: "Propuesta no encontrada." }, { status: 404 });
|
||||
}
|
||||
|
||||
const gate = computeWorkflowGate(workflow);
|
||||
const completionPercent = computeWorkflowCompletionPercent(workflow);
|
||||
const nextStatus = nextStatusFromDraft(proposal.status, completionPercent);
|
||||
|
||||
const updated = await prisma.proposal.update({
|
||||
where: { id: proposal.id },
|
||||
data: {
|
||||
workflowDraft: workflow as Prisma.InputJsonValue,
|
||||
currentStep: workflow.step,
|
||||
completionPercent,
|
||||
...(proposal.status !== nextStatus ? { status: nextStatus } : {}),
|
||||
...(gate.allReady ? {} : { readyForSubmissionAt: null }),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
currentStep: true,
|
||||
completionPercent: true,
|
||||
readyForSubmissionAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
proposal: {
|
||||
id: updated.id,
|
||||
status: updated.status,
|
||||
currentStep: updated.currentStep,
|
||||
completionPercent: updated.completionPercent,
|
||||
readyForSubmissionAt: updated.readyForSubmissionAt ? updated.readyForSubmissionAt.toISOString() : null,
|
||||
},
|
||||
gate,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene los campos de persistencia de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible guardar el flujo de trabajo." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
151
src/app/api/proposals/route.ts
Normal file
151
src/app/api/proposals/route.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { listUserProposals } from "@/lib/proposals/server";
|
||||
|
||||
function isSchemaNotReadyError(error: unknown) {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
|
||||
}
|
||||
|
||||
function parseString(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function toProposalPayload(
|
||||
proposal: {
|
||||
id: string;
|
||||
title: string;
|
||||
issuingEntity: string;
|
||||
summary: string;
|
||||
status: string;
|
||||
sourceLicitationId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
documents: Array<{
|
||||
id: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
createdAt: Date;
|
||||
}>;
|
||||
} & {
|
||||
currentStep?: number;
|
||||
completionPercent?: number;
|
||||
readyForSubmissionAt?: Date | null;
|
||||
},
|
||||
) {
|
||||
return {
|
||||
id: proposal.id,
|
||||
title: proposal.title,
|
||||
issuingEntity: proposal.issuingEntity,
|
||||
summary: proposal.summary,
|
||||
currentStep: proposal.currentStep ?? 1,
|
||||
completionPercent: proposal.completionPercent ?? 0,
|
||||
readyForSubmissionAt: proposal.readyForSubmissionAt ? proposal.readyForSubmissionAt.toISOString() : null,
|
||||
status: proposal.status,
|
||||
sourceLicitationId: proposal.sourceLicitationId,
|
||||
createdAt: proposal.createdAt.toISOString(),
|
||||
updatedAt: proposal.updatedAt.toISOString(),
|
||||
documents: proposal.documents.map((document) => ({
|
||||
id: document.id,
|
||||
fileName: document.fileName,
|
||||
mimeType: document.mimeType,
|
||||
sizeBytes: document.sizeBytes,
|
||||
createdAt: document.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const q = url.searchParams.get("q") ?? "";
|
||||
const proposals = await listUserProposals(user.id, q);
|
||||
return NextResponse.json({ ok: true, proposals });
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene las tablas de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible obtener propuestas." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = (await request.json()) as Record<string, unknown>;
|
||||
const title = parseString(body.title);
|
||||
const issuingEntity = parseString(body.issuingEntity);
|
||||
const summary = parseString(body.summary);
|
||||
const sourceLicitationIdRaw = parseString(body.sourceLicitationId);
|
||||
const sourceLicitationId = sourceLicitationIdRaw || null;
|
||||
|
||||
if (!title) {
|
||||
return NextResponse.json({ error: "El titulo es requerido." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!issuingEntity) {
|
||||
return NextResponse.json({ error: "La entidad emisora es requerida." }, { status: 400 });
|
||||
}
|
||||
|
||||
const proposal = await prisma.proposal.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
title,
|
||||
issuingEntity,
|
||||
summary,
|
||||
sourceLicitationId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
issuingEntity: true,
|
||||
summary: true,
|
||||
status: true,
|
||||
sourceLicitationId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
documents: {
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
mimeType: true,
|
||||
sizeBytes: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
proposal: toProposalPayload(proposal),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene las tablas de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible crear la propuesta." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
81
src/app/api/regulations/official/route.ts
Normal file
81
src/app/api/regulations/official/route.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import {
|
||||
listOfficialNormativeSources,
|
||||
listOfficialNormativeSuggestions,
|
||||
suggestOfficialNormativeSource,
|
||||
} from "@/lib/compliance/regulations";
|
||||
import type { OfficialNormativeSourceView, OfficialNormativeSuggestionInput } from "@/lib/compliance/types";
|
||||
|
||||
function isValidSourceType(value: unknown): value is OfficialNormativeSourceView["sourceType"] {
|
||||
return value === "ley" || value === "reglamento" || value === "lineamiento" || value === "portal";
|
||||
}
|
||||
|
||||
function parseSuggestion(body: Record<string, unknown>): OfficialNormativeSuggestionInput | null {
|
||||
const stateCode = typeof body.stateCode === "string" ? body.stateCode.trim().toUpperCase() : "";
|
||||
const municipalityCode = typeof body.municipalityCode === "string" ? body.municipalityCode.trim().toUpperCase() : null;
|
||||
const authorityName = typeof body.authorityName === "string" ? body.authorityName.trim() : "";
|
||||
const title = typeof body.title === "string" ? body.title.trim() : "";
|
||||
const officialUrl = typeof body.officialUrl === "string" ? body.officialUrl.trim() : "";
|
||||
const sourceType = body.sourceType;
|
||||
const notes = typeof body.notes === "string" ? body.notes.trim() : undefined;
|
||||
|
||||
if (!stateCode || !authorityName || !title || !officialUrl || !isValidSourceType(sourceType)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
stateCode,
|
||||
municipalityCode,
|
||||
authorityName,
|
||||
title,
|
||||
officialUrl,
|
||||
sourceType,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const includeSuggestions = url.searchParams.get("includeSuggestions") === "1";
|
||||
const stateCode = url.searchParams.get("stateCode")?.trim() || null;
|
||||
const municipalityCode = url.searchParams.get("municipalityCode")?.trim() || null;
|
||||
const pilotOnly = url.searchParams.get("pilotOnly") !== "0";
|
||||
|
||||
const sources = await listOfficialNormativeSources({
|
||||
stateCode,
|
||||
municipalityCode,
|
||||
pilotOnly,
|
||||
});
|
||||
const suggestions = includeSuggestions ? await listOfficialNormativeSuggestions() : undefined;
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
sources,
|
||||
suggestions,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const suggestion = parseSuggestion(body);
|
||||
|
||||
if (!suggestion) {
|
||||
return NextResponse.json({ error: "Datos invalidos para sugerencia normativa." }, { status: 400 });
|
||||
}
|
||||
|
||||
const created = await suggestOfficialNormativeSource(suggestion);
|
||||
return NextResponse.json({ ok: true, suggestion: created }, { status: 201 });
|
||||
}
|
||||
19
src/app/api/regulations/official/verify/route.ts
Normal file
19
src/app/api/regulations/official/verify/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { verifyOfficialNormativeSources } from "@/lib/compliance/regulations";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as { sourceIds?: unknown };
|
||||
const sourceIds = Array.isArray(body.sourceIds)
|
||||
? body.sourceIds.filter((item): item is string => typeof item === "string" && item.trim().length > 0).map((item) => item.trim())
|
||||
: undefined;
|
||||
|
||||
const results = await verifyOfficialNormativeSources(sourceIds);
|
||||
return NextResponse.json({ ok: true, results });
|
||||
}
|
||||
279
src/app/api/strategic-diagnostic/ai/insights/route.ts
Normal file
279
src/app/api/strategic-diagnostic/ai/insights/route.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import { z } from "zod";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { callOpenAiJsonSchema } from "@/lib/ai/openai";
|
||||
import { storeAiSuggestionFromEnvelope } from "@/lib/ai/suggestions";
|
||||
import { getStrategicDiagnosticSnapshot } from "@/lib/strategic-diagnostic/server";
|
||||
import { STRATEGIC_SECTION_KEYS, type StrategicSectionKey } from "@/lib/strategic-diagnostic/types";
|
||||
|
||||
const M2_PROMPT_VERSION = "m2_insights_v1";
|
||||
|
||||
const M2SectionKeySchema = z.enum(STRATEGIC_SECTION_KEYS);
|
||||
|
||||
const M2InsightResponseSchema = z.object({
|
||||
sectionGaps: z
|
||||
.array(
|
||||
z.object({
|
||||
sectionKey: M2SectionKeySchema,
|
||||
gap: z.string().min(8).max(240),
|
||||
impact: z.string().min(8).max(600),
|
||||
urgency: z.enum(["alta", "media", "baja"]),
|
||||
}),
|
||||
)
|
||||
.max(12),
|
||||
priorityActions: z
|
||||
.array(
|
||||
z.object({
|
||||
title: z.string().min(8).max(220),
|
||||
description: z.string().min(8).max(900),
|
||||
priority: z.enum(["alta", "media", "baja"]),
|
||||
ownerSuggestion: z.string().min(3).max(120),
|
||||
targetDateSuggestion: z.string().min(4).max(40),
|
||||
}),
|
||||
)
|
||||
.max(12),
|
||||
suggestedEvidence: z
|
||||
.array(
|
||||
z.object({
|
||||
sectionKey: M2SectionKeySchema,
|
||||
category: z.string().min(3).max(120),
|
||||
reason: z.string().min(8).max(600),
|
||||
}),
|
||||
)
|
||||
.max(20),
|
||||
suggestedFieldValues: z
|
||||
.array(
|
||||
z.object({
|
||||
sectionKey: M2SectionKeySchema,
|
||||
fieldPath: z.string().min(4).max(120),
|
||||
suggestedValue: z.string().min(1).max(500),
|
||||
rationale: z.string().min(8).max(700),
|
||||
}),
|
||||
)
|
||||
.max(20),
|
||||
confidence: z.enum(["low", "medium", "high"]),
|
||||
});
|
||||
|
||||
const M2InsightJsonSchema = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["sectionGaps", "priorityActions", "suggestedEvidence", "suggestedFieldValues", "confidence"],
|
||||
properties: {
|
||||
sectionGaps: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["sectionKey", "gap", "impact", "urgency"],
|
||||
properties: {
|
||||
sectionKey: { type: "string", enum: STRATEGIC_SECTION_KEYS },
|
||||
gap: { type: "string" },
|
||||
impact: { type: "string" },
|
||||
urgency: { type: "string", enum: ["alta", "media", "baja"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
priorityActions: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["title", "description", "priority", "ownerSuggestion", "targetDateSuggestion"],
|
||||
properties: {
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
priority: { type: "string", enum: ["alta", "media", "baja"] },
|
||||
ownerSuggestion: { type: "string" },
|
||||
targetDateSuggestion: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
suggestedEvidence: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["sectionKey", "category", "reason"],
|
||||
properties: {
|
||||
sectionKey: { type: "string", enum: STRATEGIC_SECTION_KEYS },
|
||||
category: { type: "string" },
|
||||
reason: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
suggestedFieldValues: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["sectionKey", "fieldPath", "suggestedValue", "rationale"],
|
||||
properties: {
|
||||
sectionKey: { type: "string", enum: STRATEGIC_SECTION_KEYS },
|
||||
fieldPath: { type: "string" },
|
||||
suggestedValue: { type: "string" },
|
||||
rationale: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
confidence: { type: "string", enum: ["low", "medium", "high"] },
|
||||
},
|
||||
} as const;
|
||||
|
||||
function isSectionKey(value: string): value is StrategicSectionKey {
|
||||
return (STRATEGIC_SECTION_KEYS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function normalizeEvidenceMetadata(value: unknown, fallback: Record<StrategicSectionKey, { count: number; categories: string[] }>) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const incoming = value as Record<string, unknown>;
|
||||
const normalized = { ...fallback };
|
||||
|
||||
for (const key of STRATEGIC_SECTION_KEYS) {
|
||||
const sectionPayload = incoming[key];
|
||||
if (!sectionPayload || typeof sectionPayload !== "object" || Array.isArray(sectionPayload)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sectionRecord = sectionPayload as Record<string, unknown>;
|
||||
const countCandidate = Number(sectionRecord.count);
|
||||
const categories = Array.isArray(sectionRecord.categories)
|
||||
? sectionRecord.categories
|
||||
.filter((item): item is string => typeof item === "string")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 20)
|
||||
: [];
|
||||
|
||||
normalized[key] = {
|
||||
count: Number.isFinite(countCandidate) && countCandidate >= 0 ? Math.floor(countCandidate) : normalized[key].count,
|
||||
categories: categories.length ? categories : normalized[key].categories,
|
||||
};
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const snapshot = await getStrategicDiagnosticSnapshot(user.id);
|
||||
|
||||
if (!snapshot) {
|
||||
return NextResponse.json({ error: "No existe un perfil organizacional para este usuario." }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const data = (body.data as unknown) ?? snapshot.data;
|
||||
|
||||
const fallbackEvidenceMetadata = STRATEGIC_SECTION_KEYS.reduce(
|
||||
(accumulator, key) => {
|
||||
accumulator[key] = {
|
||||
count: snapshot.evidenceBySection[key].length,
|
||||
categories: Array.from(new Set(snapshot.evidenceBySection[key].map((item) => item.category))).slice(0, 20),
|
||||
};
|
||||
return accumulator;
|
||||
},
|
||||
{} as Record<StrategicSectionKey, { count: number; categories: string[] }>,
|
||||
);
|
||||
|
||||
const evidenceMetadata = normalizeEvidenceMetadata(body.evidenceMetadata, fallbackEvidenceMetadata);
|
||||
|
||||
const systemPrompt = [
|
||||
"Eres consultor senior de estrategia comercial para licitaciones publicas en Mexico.",
|
||||
"Analiza el diagnostico estrategico y prioriza acciones de alto impacto.",
|
||||
"No alteres puntajes deterministas: solo entrega recomendaciones asistidas.",
|
||||
"Responde exclusivamente JSON valido en espanol.",
|
||||
].join(" ");
|
||||
|
||||
const userPrompt = [
|
||||
"Diagnostico estrategico actual:",
|
||||
JSON.stringify({
|
||||
data,
|
||||
scores: snapshot.scores,
|
||||
evidenceMetadata,
|
||||
}),
|
||||
"",
|
||||
"Genera:",
|
||||
"- sectionGaps: brechas por seccion con impacto y urgencia.",
|
||||
"- priorityActions: acciones priorizadas con responsable sugerido y fecha objetivo.",
|
||||
"- suggestedEvidence: evidencia recomendada por seccion.",
|
||||
"- suggestedFieldValues: valores sugeridos para campos del formulario.",
|
||||
"",
|
||||
"Reglas para suggestedFieldValues:",
|
||||
"- sectionKey debe ser una seccion valida.",
|
||||
"- fieldPath debe usar notacion con punto (ej. technical.responseTimes).",
|
||||
"- suggestedValue debe ser texto aplicable directamente por el usuario.",
|
||||
].join("\n");
|
||||
|
||||
const envelope = await callOpenAiJsonSchema({
|
||||
promptVersion: M2_PROMPT_VERSION,
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
outputSchema: M2InsightResponseSchema,
|
||||
schemaName: "m2_strategic_insights",
|
||||
jsonSchema: M2InsightJsonSchema as unknown as Record<string, unknown>,
|
||||
model: process.env.OPENAI_M2_MODEL?.trim() || undefined,
|
||||
});
|
||||
|
||||
const safeData =
|
||||
envelope.data ??
|
||||
({
|
||||
sectionGaps: [],
|
||||
priorityActions: [],
|
||||
suggestedEvidence: [],
|
||||
suggestedFieldValues: [],
|
||||
confidence: envelope.confidence ?? "low",
|
||||
} satisfies z.infer<typeof M2InsightResponseSchema>);
|
||||
|
||||
const filteredFieldValues = safeData.suggestedFieldValues.filter((item) => {
|
||||
if (!isSectionKey(item.sectionKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return item.fieldPath.startsWith(`${item.sectionKey}.`);
|
||||
});
|
||||
|
||||
const payload = {
|
||||
sectionGaps: safeData.sectionGaps,
|
||||
priorityActions: safeData.priorityActions,
|
||||
suggestedEvidence: safeData.suggestedEvidence,
|
||||
suggestedFieldValues: filteredFieldValues,
|
||||
confidence: safeData.confidence,
|
||||
};
|
||||
|
||||
const persisted = await storeAiSuggestionFromEnvelope({
|
||||
userId: user.id,
|
||||
moduleKey: "M2",
|
||||
featureKey: "strategic_insights",
|
||||
subjectType: "organization",
|
||||
subjectId: snapshot.organizationId,
|
||||
inputForHash: {
|
||||
organizationId: snapshot.organizationId,
|
||||
data,
|
||||
evidenceMetadata,
|
||||
scores: snapshot.scores,
|
||||
},
|
||||
envelope,
|
||||
responsePayload: payload,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
...payload,
|
||||
suggestionId: persisted.suggestionId,
|
||||
meta: {
|
||||
engine: envelope.engine,
|
||||
model: envelope.model,
|
||||
usage: envelope.usage,
|
||||
warnings: envelope.warnings,
|
||||
confidence: envelope.confidence,
|
||||
},
|
||||
});
|
||||
}
|
||||
118
src/app/candados/page.tsx
Normal file
118
src/app/candados/page.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import Link from "next/link";
|
||||
import { hasModuleAccess } from "@/lib/auth/module-access";
|
||||
import { requireOnboardedUser } from "@/lib/auth/user";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { NormativeAnalysisView } from "@/components/app/normative-analysis-view";
|
||||
import { PageShell } from "@/components/app/page-shell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
type CandadosPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
function getParam(params: Record<string, string | string[] | undefined>, key: string) {
|
||||
const value = params[key];
|
||||
return Array.isArray(value) ? value[0] ?? "" : (value ?? "");
|
||||
}
|
||||
|
||||
export default async function CandadosPage({ searchParams }: CandadosPageProps) {
|
||||
const user = await requireOnboardedUser();
|
||||
const hasPaidModulesAccess = await hasModuleAccess(user, 6);
|
||||
|
||||
if (!hasPaidModulesAccess) {
|
||||
return (
|
||||
<PageShell title="Modulo 6: Candados" description="Acceso restringido al modulo premium.">
|
||||
<Card>
|
||||
<CardContent className="space-y-3 py-6">
|
||||
<h2 className="text-2xl font-semibold text-[#1f2a40]">Acceso restringido</h2>
|
||||
<p className="text-sm text-[#64718a]">Necesitas un plan con acceso premium para ingresar al Modulo 6.</p>
|
||||
<Link href="/dashboard#modulos">
|
||||
<Button>Ver modulos y planes</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const proposalId = getParam(resolvedSearchParams, "proposalId").trim();
|
||||
const sourceIdFromQuery = getParam(resolvedSearchParams, "sourceId").trim();
|
||||
|
||||
const linkedProposal = proposalId
|
||||
? await prisma.proposal.findFirst({
|
||||
where: {
|
||||
id: proposalId,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
issuingEntity: true,
|
||||
sourceLicitationId: true,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
const sourceId = (linkedProposal?.sourceLicitationId ?? sourceIdFromQuery) || null;
|
||||
const linkedSource = sourceId
|
||||
? await prisma.licitation.findUnique({
|
||||
where: { id: sourceId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
supplierAwarded: true,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
const backHref = linkedProposal ? `/gestion-licitaciones/${encodeURIComponent(linkedProposal.id)}` : "/gestion-licitaciones";
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title="Modulo 6: Candados"
|
||||
headerMode="module"
|
||||
headerBackHref={backHref}
|
||||
headerBackLabel="Volver a M5"
|
||||
headerNextHref="/compliance-alerts"
|
||||
headerNextLabel="M7: Cumplimiento"
|
||||
headerShowManual
|
||||
headerPlanBadgeLabel="Plan Premium"
|
||||
showPageHeading={false}
|
||||
contentWidth="wide"
|
||||
className="space-y-6"
|
||||
>
|
||||
<section className="flex items-start gap-4">
|
||||
<span className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#dce5f2] text-3xl text-[#274676]">◨</span>
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold text-[#132549] [font-family:var(--font-display)] md:text-5xl">Detector de Candados y Riesgos</h1>
|
||||
<p className="mt-2 text-xl text-[#4f6588] md:text-2xl">Modulo 6: detecta direccionamiento, favoritismo y candados en bases de licitacion</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<NormativeAnalysisView
|
||||
linkedProposal={
|
||||
linkedProposal
|
||||
? {
|
||||
id: linkedProposal.id,
|
||||
title: linkedProposal.title,
|
||||
issuingEntity: linkedProposal.issuingEntity,
|
||||
}
|
||||
: null
|
||||
}
|
||||
linkedSource={
|
||||
linkedSource
|
||||
? {
|
||||
id: linkedSource.id,
|
||||
title: linkedSource.title,
|
||||
issuingEntity: linkedSource.supplierAwarded,
|
||||
}
|
||||
: null
|
||||
}
|
||||
autoAnalyzeOnLoad={Boolean(linkedProposal || linkedSource)}
|
||||
moduleContext="candados"
|
||||
/>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
66
src/app/compliance-alerts/page.tsx
Normal file
66
src/app/compliance-alerts/page.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import Link from "next/link";
|
||||
import { hasModuleAccess } from "@/lib/auth/module-access";
|
||||
import { requireOnboardedUser } from "@/lib/auth/user";
|
||||
import { getM7DatasetForUser } from "@/lib/compliance/server";
|
||||
import { ComplianceAlertsView } from "@/components/app/compliance-alerts-view";
|
||||
import { PageShell } from "@/components/app/page-shell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export default async function ComplianceAlertsPage() {
|
||||
const user = await requireOnboardedUser();
|
||||
const hasPaidModulesAccess = await hasModuleAccess(user, 7);
|
||||
|
||||
if (!hasPaidModulesAccess) {
|
||||
return (
|
||||
<PageShell
|
||||
title="Modulo 7: Alertas de Cumplimiento"
|
||||
description="Este modulo esta protegido por suscripcion de pago."
|
||||
action={<span className="rounded-full border border-[#d5ddec] bg-[#edf2fb] px-4 py-1 text-sm font-semibold text-[#5a6a87]">Bloqueado</span>}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="space-y-3 py-6">
|
||||
<h2 className="text-2xl font-semibold text-[#1f2a40]">Acceso restringido</h2>
|
||||
<p className="text-sm text-[#64718a]">Modulo 7 forma parte de la ruta premium de modulos pagados.</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href="/dashboard#modulos">
|
||||
<Button>Ver modulos y planes</Button>
|
||||
</Link>
|
||||
<Link href="/gestion-licitaciones">
|
||||
<Button variant="secondary">Ir a Modulo 5</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const dataset = await getM7DatasetForUser(user.id);
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title="Modulo 7: Alertas de Cumplimiento"
|
||||
headerMode="module"
|
||||
headerBackHref="/dashboard"
|
||||
headerBackLabel="Volver al Dashboard"
|
||||
headerNextHref="/gestion-contratos"
|
||||
headerNextLabel="M8: Contratos"
|
||||
headerShowManual
|
||||
headerPlanBadgeLabel="Plan Premium"
|
||||
showPageHeading={false}
|
||||
contentWidth="wide"
|
||||
className="space-y-6"
|
||||
>
|
||||
<section className="flex items-start gap-4">
|
||||
<span className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#dce5f2] text-3xl text-[#274676]">◨</span>
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold text-[#132549] [font-family:var(--font-display)] md:text-5xl">Modulo 7: Alertas de Cumplimiento</h1>
|
||||
<p className="mt-2 text-xl text-[#4f6588] md:text-2xl">Monitorea plazos, riesgos, checklist y vigencia normativa desde una sola vista</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ComplianceAlertsView dataset={dataset} />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import Link from "next/link";
|
||||
import type { ModulePlanKey } from "@prisma/client";
|
||||
import { isAdminIdentity } from "@/lib/auth/admin";
|
||||
import { getActivePlanKeysForUser } from "@/lib/auth/module-access";
|
||||
import { PageShell } from "@/components/app/page-shell";
|
||||
import { DashboardMaturitySection } from "@/components/app/dashboard-maturity-section";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { requireOnboardedUser } from "@/lib/auth/user";
|
||||
import { hasMercadoPagoConfig } from "@/lib/payments/mercadopago";
|
||||
import { getModulePlanByUiKey } from "@/lib/payments/plans";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { recomputeAssessmentResults } from "@/lib/scoring";
|
||||
import { getTalleresSnapshot } from "@/lib/talleres/server";
|
||||
@@ -57,6 +61,7 @@ const planGroups: PlanGroup[] = [
|
||||
id: "M04",
|
||||
title: "Analisis Normativo",
|
||||
description: "Analiza las bases de licitacion que te interesen con IA.",
|
||||
href: "/normative-analysis",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -75,16 +80,19 @@ const planGroups: PlanGroup[] = [
|
||||
id: "M05",
|
||||
title: "Gestion Integral de Licitaciones",
|
||||
description: "Herramienta completa para preparar, revisar y enviar propuestas con checklist.",
|
||||
href: "/gestion-licitaciones",
|
||||
},
|
||||
{
|
||||
id: "M06",
|
||||
title: "Detector de Candados y Riesgos",
|
||||
description: "Analisis automatico de bases para identificar requisitos excluyentes o riesgos.",
|
||||
href: "/candados",
|
||||
},
|
||||
{
|
||||
id: "M07",
|
||||
title: "Alertas de Cumplimiento",
|
||||
description: "Notificaciones proactivas sobre plazos, documentos vencidos y cambios normativos.",
|
||||
href: "/compliance-alerts",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -103,16 +111,19 @@ const planGroups: PlanGroup[] = [
|
||||
id: "M08",
|
||||
title: "Gestion Estrategica de Contratos",
|
||||
description: "Dashboard para administrar contratos activos, entregables y pagos.",
|
||||
href: "/gestion-contratos",
|
||||
},
|
||||
{
|
||||
id: "M09",
|
||||
title: "Proteccion Legal",
|
||||
description: "Guia legal y plantillas ante incumplimientos, retenciones o sanciones.",
|
||||
href: "/proteccion-legal",
|
||||
},
|
||||
{
|
||||
id: "M10",
|
||||
title: "Simulador de Auditorias",
|
||||
description: "Autoevaluacion que prepara a tu empresa para auditorias gubernamentales.",
|
||||
href: "/expediente-preventivo",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -165,11 +176,28 @@ function LockIcon({ className }: { className?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default async function DashboardPage() {
|
||||
type DashboardPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
function getParam(params: Record<string, string | string[] | undefined>, key: string) {
|
||||
const value = params[key];
|
||||
return Array.isArray(value) ? value[0] ?? "" : (value ?? "");
|
||||
}
|
||||
|
||||
export default async function DashboardPage({ searchParams }: DashboardPageProps) {
|
||||
const user = await requireOnboardedUser();
|
||||
const snapshot = await recomputeAssessmentResults(user.id);
|
||||
const talleresSnapshot = await getTalleresSnapshot(user.id, { assessmentSnapshot: snapshot });
|
||||
const hasPaidModulesAccess = isAdminIdentity(user.email, user.role);
|
||||
const isAdminUser = isAdminIdentity(user.email, user.role);
|
||||
const activePlanKeys = isAdminUser
|
||||
? new Set<ModulePlanKey>(["PLAN_2_4", "PLAN_5_7", "PLAN_8_10"])
|
||||
: await getActivePlanKeysForUser(user.id);
|
||||
const hasPaidModulesAccess = activePlanKeys.size > 0;
|
||||
const paymentConfigured = hasMercadoPagoConfig();
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const checkoutResult = getParam(resolvedSearchParams, "mp_result");
|
||||
const checkoutError = getParam(resolvedSearchParams, "mp_error");
|
||||
const roundedOverallScore = Math.round(snapshot.overallScore);
|
||||
const readinessLabel = getReadinessLabel(roundedOverallScore);
|
||||
const strongest = snapshot.strongestModule;
|
||||
@@ -212,11 +240,11 @@ export default async function DashboardPage() {
|
||||
<Card className="border-[#a8dbc8]">
|
||||
<CardContent className="py-8">
|
||||
<div className="mx-auto max-w-4xl text-center">
|
||||
<span className="inline-flex rounded-full bg-[#24aa77] px-8 py-2 text-2xl font-semibold text-white">Nivel {readinessLabel}</span>
|
||||
<h2 className="mt-6 text-4xl font-semibold text-[#0f2142] [font-family:var(--font-display)] md:text-5xl">
|
||||
<span className="inline-flex rounded-full bg-[#24aa77] px-7 py-2 text-xl font-semibold text-white">Nivel {readinessLabel}</span>
|
||||
<h2 className="mt-6 text-3xl font-semibold text-[#0f2142] [font-family:var(--font-display)] md:text-4xl">
|
||||
Puntaje Global: {roundedOverallScore}%
|
||||
</h2>
|
||||
<p className="mx-auto mt-4 max-w-3xl text-2xl leading-relaxed text-[#4d6285] md:text-[35px] md:leading-tight">
|
||||
<p className="mx-auto mt-4 max-w-3xl text-xl leading-relaxed text-[#4d6285] md:text-2xl">
|
||||
{getReadinessDescription(roundedOverallScore)}
|
||||
</p>
|
||||
|
||||
@@ -251,13 +279,29 @@ export default async function DashboardPage() {
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-[#1a2b48] [font-family:var(--font-display)] md:text-3xl">Modulos y planes</h2>
|
||||
<p className="mt-1 text-sm text-[#5f7293]">Ruta de crecimiento: Modulo 1 (gratis) + Modulos 2-20 por suscripcion.</p>
|
||||
<p className="mt-1 text-sm text-[#5f7293]">Ruta de crecimiento: Modulo 1 (gratis) + Modulos 2-10 por suscripcion.</p>
|
||||
</div>
|
||||
<span className={cn("inline-flex items-center gap-2 rounded-full border px-4 py-1.5 text-sm font-semibold", hasPaidModulesAccess ? "border-[#a9d8c8] bg-[#e8f6ef] text-[#1e8f67]" : "border-[#d7dceb] bg-[#eff3fb] text-[#5a6b88]")}>
|
||||
{hasPaidModulesAccess ? "Plan con acceso activo" : "Cuenta en plan gratuito"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{checkoutResult ? (
|
||||
<div className={cn("rounded-xl border px-4 py-3 text-sm", checkoutResult === "success" ? "border-[#a9d8c8] bg-[#ecf9f2] text-[#1f8b63]" : "border-[#e7d7ac] bg-[#fff8ea] text-[#8b6b1f]")}>
|
||||
{checkoutResult === "success"
|
||||
? "Mercado Pago reporto checkout exitoso. Estamos validando tu pago y activando el plan."
|
||||
: checkoutResult === "failure"
|
||||
? "El pago no se completo. Puedes intentarlo nuevamente."
|
||||
: "Tu pago esta pendiente de confirmacion. Te avisaremos cuando se active el plan."}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{checkoutError ? (
|
||||
<div className="rounded-xl border border-[#f1c7cc] bg-[#fff1f3] px-4 py-3 text-sm text-[#a03d4b]">
|
||||
No fue posible iniciar el checkout ({checkoutError}). Verifica la configuracion de Mercado Pago.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Card className="border-[#a9d9c7] bg-[#f3fbf7]">
|
||||
<CardContent className="flex flex-wrap items-center justify-between gap-3 py-4">
|
||||
<div>
|
||||
@@ -275,6 +319,11 @@ export default async function DashboardPage() {
|
||||
|
||||
{planGroups.map((group) => (
|
||||
<article key={group.key} className={cn("rounded-2xl border p-3 md:p-4", group.frameClassName)}>
|
||||
{(() => {
|
||||
const plan = getModulePlanByUiKey(group.key);
|
||||
const planActive = plan ? activePlanKeys.has(plan.key) : false;
|
||||
return (
|
||||
<>
|
||||
<header className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-white/60 bg-white/50 px-3 py-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={cn("mt-1.5 h-3 w-3 rounded-full", group.dotClassName)} />
|
||||
@@ -288,13 +337,13 @@ export default async function DashboardPage() {
|
||||
|
||||
<span className={cn("inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-sm font-semibold", group.badgeClassName)}>
|
||||
<LockIcon className="h-4 w-4" />
|
||||
{hasPaidModulesAccess ? "Disponible" : "Bloqueado"}
|
||||
{planActive ? "Disponible" : "Bloqueado"}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-3">
|
||||
{group.modules.map((moduleItem) => {
|
||||
const isLocked = !hasPaidModulesAccess || !moduleItem.href;
|
||||
const isLocked = !planActive || !moduleItem.href;
|
||||
|
||||
return (
|
||||
<article
|
||||
@@ -335,12 +384,33 @@ export default async function DashboardPage() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!planActive ? (
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-white/70 bg-white/55 px-3 py-3">
|
||||
<p className="text-sm text-[#5a6f90]">
|
||||
Desbloquea Modulos {plan?.moduleStart}-{plan?.moduleEnd} con {group.name}.
|
||||
</p>
|
||||
{paymentConfigured ? (
|
||||
<Link href={`/api/payments/checkout?plan=${encodeURIComponent(group.key)}`}>
|
||||
<Button size="sm" className="rounded-xl">
|
||||
Comprar {group.name}
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="rounded-full border border-[#d8dfea] bg-[#eef2f8] px-3 py-1 text-xs font-semibold text-[#64738c]">
|
||||
Configura `MP_ACCESS_TOKEN`
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</article>
|
||||
))}
|
||||
|
||||
<div className="rounded-2xl border border-dashed border-[#d4dceb] bg-[#f8fbff] px-5 py-5 text-center">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.08em] text-[#607290]">Ruta de expansion</p>
|
||||
<p className="mt-2 text-base text-[#536a8e]">Modulos 11-20 se habilitaran en siguientes etapas de la plataforma.</p>
|
||||
<p className="mt-2 text-base text-[#536a8e]">Actualmente la plataforma contempla 10 modulos.</p>
|
||||
<Link href="/#planes" className="mt-4 inline-flex">
|
||||
<Button size="sm" className="rounded-xl bg-[#e1af1c] text-[#243351] hover:bg-[#cf9f16]">
|
||||
Ver todos los planes
|
||||
|
||||
89
src/app/expediente-preventivo/page.tsx
Normal file
89
src/app/expediente-preventivo/page.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import Link from "next/link";
|
||||
import { hasModuleAccess } from "@/lib/auth/module-access";
|
||||
import { requireOnboardedUser } from "@/lib/auth/user";
|
||||
import {
|
||||
getInstitutionalDossierForUser,
|
||||
getAuditKpisForUser,
|
||||
listAuditSimulationsForUser,
|
||||
} from "@/lib/audits/server";
|
||||
import { PreventiveDossierView } from "@/components/app/preventive-dossier-view";
|
||||
import { PageShell } from "@/components/app/page-shell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import type { InstitutionalDossierLoadStrategy } from "@/lib/audits/types";
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
function getParam(params: Record<string, string | string[] | undefined>, key: string) {
|
||||
const value = params[key];
|
||||
return Array.isArray(value) ? value[0] ?? "" : (value ?? "");
|
||||
}
|
||||
|
||||
export default async function ExpedientePreventivoPage({ searchParams }: PageProps) {
|
||||
const user = await requireOnboardedUser();
|
||||
const hasPaidModulesAccess = await hasModuleAccess(user, 10);
|
||||
|
||||
if (!hasPaidModulesAccess) {
|
||||
return (
|
||||
<PageShell
|
||||
title="Modulo 10: Expediente Preventivo"
|
||||
description="Este modulo esta protegido por suscripcion de pago."
|
||||
action={<span className="rounded-full border border-[#d5ddec] bg-[#edf2fb] px-4 py-1 text-sm font-semibold text-[#5a6a87]">Bloqueado</span>}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="space-y-3 py-6">
|
||||
<h2 className="text-2xl font-semibold text-[#1f2a40]">Acceso restringido</h2>
|
||||
<p className="text-sm text-[#64718a]">Modulo 10 forma parte de la ruta premium de modulos pagados.</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href="/dashboard#modulos">
|
||||
<Button>Ver modulos y planes</Button>
|
||||
</Link>
|
||||
<Link href="/proteccion-legal">
|
||||
<Button variant="secondary">Ir a Modulo 9</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const strategy = (getParam(resolvedSearchParams, "strategy") === "snapshot" ? "snapshot" : "live") satisfies InstitutionalDossierLoadStrategy;
|
||||
|
||||
const [simulations, kpis, dossierState] = await Promise.all([
|
||||
listAuditSimulationsForUser(user.id),
|
||||
getAuditKpisForUser(user.id),
|
||||
getInstitutionalDossierForUser(user.id, strategy),
|
||||
]);
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title="Modulo 10: Expediente Preventivo"
|
||||
headerMode="module"
|
||||
headerBackHref="/proteccion-legal"
|
||||
headerBackLabel="M9: Legal"
|
||||
headerPlanBadgeLabel="Plan Premium"
|
||||
showPageHeading={false}
|
||||
contentWidth="wide"
|
||||
className="space-y-6"
|
||||
>
|
||||
<section className="flex items-start gap-4">
|
||||
<span className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#dce5f2] text-3xl text-[#274676]">◫</span>
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold text-[#132549] [font-family:var(--font-display)] md:text-5xl">Expediente Preventivo</h1>
|
||||
<p className="mt-2 text-xl text-[#4f6588] md:text-2xl">Simula auditorias y consolida evidencia institucional desde M5, M8, M9 y M7</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<PreventiveDossierView
|
||||
initialSimulations={simulations}
|
||||
initialKpis={kpis}
|
||||
initialDossier={dossierState.dossier}
|
||||
initialFreshness={dossierState.freshness}
|
||||
/>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
237
src/app/gestion-contratos/page.tsx
Normal file
237
src/app/gestion-contratos/page.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { hasModuleAccess } from "@/lib/auth/module-access";
|
||||
import { requireOnboardedUser } from "@/lib/auth/user";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getContractsKpisForUser, listContractsForUser } from "@/lib/contracts/server";
|
||||
import {
|
||||
ensureProposalPdfLinkedToContract,
|
||||
getLatestProposalPdfSourceForUser,
|
||||
isPdfProposalDocument,
|
||||
} from "@/lib/contracts/proposal-continuity";
|
||||
import { ContractsManagementView } from "@/components/app/contracts-management-view";
|
||||
import { PageShell } from "@/components/app/page-shell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
function getParam(params: Record<string, string | string[] | undefined>, key: string) {
|
||||
const value = params[key];
|
||||
return Array.isArray(value) ? value[0] ?? "" : (value ?? "");
|
||||
}
|
||||
|
||||
function logContinuityHandoff(event: string, data: Record<string, unknown>) {
|
||||
console.info(
|
||||
"[continuity_handoff]",
|
||||
JSON.stringify({
|
||||
event,
|
||||
at: new Date().toISOString(),
|
||||
...data,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export default async function GestionContratosPage({ searchParams }: PageProps) {
|
||||
const user = await requireOnboardedUser();
|
||||
const hasPaidModulesAccess = await hasModuleAccess(user, 8);
|
||||
|
||||
if (!hasPaidModulesAccess) {
|
||||
return (
|
||||
<PageShell
|
||||
title="Modulo 8: Gestion Estrategica de Contratos"
|
||||
description="Este modulo esta protegido por suscripcion de pago."
|
||||
action={<span className="rounded-full border border-[#d5ddec] bg-[#edf2fb] px-4 py-1 text-sm font-semibold text-[#5a6a87]">Bloqueado</span>}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="space-y-3 py-6">
|
||||
<h2 className="text-2xl font-semibold text-[#1f2a40]">Acceso restringido</h2>
|
||||
<p className="text-sm text-[#64718a]">Modulo 8 forma parte de la ruta premium de modulos pagados.</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href="/dashboard#modulos">
|
||||
<Button>Ver modulos y planes</Button>
|
||||
</Link>
|
||||
<Link href="/compliance-alerts">
|
||||
<Button variant="secondary">Ir a Modulo 7</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const proposalId = getParam(resolvedSearchParams, "proposalId") || null;
|
||||
const shouldAutoCreate = getParam(resolvedSearchParams, "autocreate") === "1";
|
||||
|
||||
if (proposalId || shouldAutoCreate) {
|
||||
logContinuityHandoff("m5_to_m8_autocreate_requested", {
|
||||
userId: user.id,
|
||||
proposalId,
|
||||
shouldAutoCreate,
|
||||
});
|
||||
}
|
||||
|
||||
if (proposalId && shouldAutoCreate) {
|
||||
const ownedProposal = await prisma.proposal.findFirst({
|
||||
where: {
|
||||
id: proposalId,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
issuingEntity: true,
|
||||
summary: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (ownedProposal) {
|
||||
logContinuityHandoff("m5_to_m8_autocreate_proposal_resolved", {
|
||||
userId: user.id,
|
||||
proposalId: ownedProposal.id,
|
||||
});
|
||||
|
||||
const existing = await prisma.contractRecord.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
sourceProposalId: ownedProposal.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
let targetContractId = existing?.id ?? null;
|
||||
if (!targetContractId) {
|
||||
const created = await prisma.contractRecord.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
sourceProposalId: ownedProposal.id,
|
||||
title: ownedProposal.title,
|
||||
counterpartyEntity: ownedProposal.issuingEntity,
|
||||
contractType: "Desde propuesta adjudicada",
|
||||
description: ownedProposal.summary,
|
||||
status: "ACTIVE",
|
||||
currency: "MXN",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
targetContractId = created.id;
|
||||
}
|
||||
logContinuityHandoff("m5_to_m8_autocreate_contract_resolved", {
|
||||
userId: user.id,
|
||||
proposalId: ownedProposal.id,
|
||||
contractId: targetContractId,
|
||||
mode: existing ? "reuse" : "create",
|
||||
});
|
||||
|
||||
if (targetContractId) {
|
||||
try {
|
||||
const proposalPdfLookup = await getLatestProposalPdfSourceForUser(user.id, ownedProposal.id);
|
||||
logContinuityHandoff("m5_to_m8_autocreate_proposal_pdf_lookup", {
|
||||
userId: user.id,
|
||||
proposalId: ownedProposal.id,
|
||||
contractId: targetContractId,
|
||||
lookupStatus: proposalPdfLookup.status,
|
||||
});
|
||||
|
||||
if (proposalPdfLookup.status === "ok") {
|
||||
const linked = await ensureProposalPdfLinkedToContract({
|
||||
userId: user.id,
|
||||
contractId: targetContractId,
|
||||
source: proposalPdfLookup.source,
|
||||
});
|
||||
logContinuityHandoff("m5_to_m8_autocreate_pdf_linked", {
|
||||
userId: user.id,
|
||||
proposalId: ownedProposal.id,
|
||||
contractId: targetContractId,
|
||||
created: linked.created,
|
||||
documentId: linked.documentId,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
logContinuityHandoff("m5_to_m8_autocreate_pdf_link_failed", {
|
||||
userId: user.id,
|
||||
proposalId: ownedProposal.id,
|
||||
contractId: targetContractId,
|
||||
});
|
||||
// Preserve autocreate flow even if continuity copy fails unexpectedly.
|
||||
}
|
||||
}
|
||||
|
||||
redirect("/gestion-contratos");
|
||||
}
|
||||
}
|
||||
|
||||
const [contracts, kpis, proposals] = await Promise.all([
|
||||
listContractsForUser(user.id),
|
||||
getContractsKpisForUser(user.id),
|
||||
prisma.proposal.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
orderBy: [{ updatedAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
documents: {
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
mimeType: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take: 200,
|
||||
}),
|
||||
]);
|
||||
const proposalPdfOptions = proposals
|
||||
.map((proposal) => {
|
||||
const latestPdf = proposal.documents.find((document) => isPdfProposalDocument(document)) ?? null;
|
||||
if (!latestPdf) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
proposalId: proposal.id,
|
||||
proposalTitle: proposal.title,
|
||||
documentId: latestPdf.id,
|
||||
fileName: latestPdf.fileName,
|
||||
createdAt: latestPdf.createdAt.toISOString(),
|
||||
};
|
||||
})
|
||||
.filter((item): item is { proposalId: string; proposalTitle: string; documentId: string; fileName: string; createdAt: string } => Boolean(item));
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title="Modulo 8: Gestion Estrategica de Contratos"
|
||||
headerMode="module"
|
||||
headerBackHref="/compliance-alerts"
|
||||
headerBackLabel="M7: Alertas"
|
||||
headerNextHref="/proteccion-legal"
|
||||
headerNextLabel="M9: Legal"
|
||||
headerPlanBadgeLabel="Plan Premium"
|
||||
showPageHeading={false}
|
||||
contentWidth="wide"
|
||||
className="space-y-6"
|
||||
>
|
||||
<section className="flex items-start gap-4">
|
||||
<span className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#dce5f2] text-3xl text-[#274676]">◧</span>
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold text-[#132549] [font-family:var(--font-display)] md:text-5xl">Gestion Estrategica de Contratos</h1>
|
||||
<p className="mt-2 text-xl text-[#4f6588] md:text-2xl">Registra contratos, entregables, pagos y evidencia documental con trazabilidad completa</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ContractsManagementView initialContracts={contracts} initialKpis={kpis} proposalPdfOptions={proposalPdfOptions} />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
305
src/app/gestion-licitaciones/[id]/page.tsx
Normal file
305
src/app/gestion-licitaciones/[id]/page.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { hasModuleAccess } from "@/lib/auth/module-access";
|
||||
import { requireOnboardedUser } from "@/lib/auth/user";
|
||||
import { toNormativeHistoryView } from "@/lib/normative-analysis/history";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { buildRequirementSeeds, buildTechnicalSectionSeeds, type WorkflowMilestoneSeed } from "@/lib/proposals/workflow";
|
||||
import { PageShell } from "@/components/app/page-shell";
|
||||
import { ProposalWorkflowView } from "@/components/app/proposal-workflow-view";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
type SourceDocumentLink = {
|
||||
name: string;
|
||||
url: string;
|
||||
type: string | null;
|
||||
};
|
||||
|
||||
function toSourceDocumentLinks(value: unknown): SourceDocumentLink[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = item as Record<string, unknown>;
|
||||
const name = typeof record.name === "string" && record.name.trim() ? record.name.trim() : "Documento";
|
||||
const url = typeof record.url === "string" && record.url.trim() ? record.url.trim() : "";
|
||||
const type = typeof record.type === "string" && record.type.trim() ? record.type.trim() : null;
|
||||
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { name, url, type };
|
||||
})
|
||||
.filter((item): item is SourceDocumentLink => Boolean(item));
|
||||
}
|
||||
|
||||
function parseDateCandidate(value: unknown) {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function toMilestoneSeeds(args: {
|
||||
openingDate: Date | null;
|
||||
closingDate: Date | null;
|
||||
eventDates: Prisma.JsonValue | null;
|
||||
}): WorkflowMilestoneSeed[] {
|
||||
const milestones: WorkflowMilestoneSeed[] = [];
|
||||
|
||||
if (args.openingDate) {
|
||||
milestones.push({
|
||||
id: "opening-date",
|
||||
title: "Apertura del proceso",
|
||||
dateIso: args.openingDate.toISOString(),
|
||||
location: "",
|
||||
note: "",
|
||||
source: "licitation",
|
||||
});
|
||||
}
|
||||
|
||||
if (args.closingDate) {
|
||||
milestones.push({
|
||||
id: "closing-date",
|
||||
title: "Presentacion de propuestas",
|
||||
dateIso: args.closingDate.toISOString(),
|
||||
location: "",
|
||||
note: "",
|
||||
source: "licitation",
|
||||
});
|
||||
}
|
||||
|
||||
if (args.eventDates && typeof args.eventDates === "object" && !Array.isArray(args.eventDates)) {
|
||||
const entries = Object.entries(args.eventDates as Record<string, unknown>);
|
||||
for (let index = 0; index < entries.length; index += 1) {
|
||||
const [key, value] = entries[index];
|
||||
const parsed = parseDateCandidate(value);
|
||||
milestones.push({
|
||||
id: `event-date-${index + 1}`,
|
||||
title: key || `Evento ${index + 1}`,
|
||||
dateIso: parsed ? parsed.toISOString() : null,
|
||||
location: "",
|
||||
note: "",
|
||||
source: "licitation",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const dedupe = new Set<string>();
|
||||
return milestones.filter((item) => {
|
||||
const signature = `${item.title.toLowerCase()}|${item.dateIso ?? ""}`;
|
||||
if (dedupe.has(signature)) {
|
||||
return false;
|
||||
}
|
||||
dedupe.add(signature);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function getParam(params: Record<string, string | string[] | undefined>, key: string) {
|
||||
const value = params[key];
|
||||
return Array.isArray(value) ? value[0] ?? "" : (value ?? "");
|
||||
}
|
||||
|
||||
export default async function GestionLicitacionDetailPage({ params, searchParams }: PageProps) {
|
||||
const user = await requireOnboardedUser();
|
||||
const hasPaidModulesAccess = await hasModuleAccess(user, 5);
|
||||
|
||||
if (!hasPaidModulesAccess) {
|
||||
return (
|
||||
<PageShell
|
||||
title="Modulo 5: Gestion de Licitaciones"
|
||||
description="Este modulo esta protegido por suscripcion de pago."
|
||||
action={<span className="rounded-full border border-[#d5ddec] bg-[#edf2fb] px-4 py-1 text-sm font-semibold text-[#5a6a87]">Bloqueado</span>}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="space-y-3 py-6">
|
||||
<h2 className="text-2xl font-semibold text-[#1f2a40]">Acceso restringido</h2>
|
||||
<p className="text-sm text-[#64718a]">Modulo 5 forma parte de la ruta premium de modulos pagados.</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href="/dashboard#modulos">
|
||||
<Button>Ver modulos y planes</Button>
|
||||
</Link>
|
||||
<Link href="/normative-analysis">
|
||||
<Button variant="secondary">Ir a Modulo 4</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const autoExtractOnLoad = getParam(resolvedSearchParams, "autofill") === "1";
|
||||
|
||||
const proposal = await prisma.proposal.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
issuingEntity: true,
|
||||
summary: true,
|
||||
status: true,
|
||||
sourceLicitationId: true,
|
||||
updatedAt: true,
|
||||
documents: {
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
mimeType: true,
|
||||
sizeBytes: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!proposal) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const linkedSource = proposal.sourceLicitationId
|
||||
? await prisma.licitation.findUnique({
|
||||
where: { id: proposal.sourceLicitationId },
|
||||
include: {
|
||||
municipality: {
|
||||
select: {
|
||||
stateName: true,
|
||||
municipalityName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
const historyEntry = proposal.sourceLicitationId
|
||||
? await prisma.normativeAnalysisHistory.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
sourceLicitationId: proposal.sourceLicitationId,
|
||||
},
|
||||
orderBy: [{ analyzedAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
sourceLicitationId: true,
|
||||
fileName: true,
|
||||
documentType: true,
|
||||
issuingEntity: true,
|
||||
methodUsed: true,
|
||||
numPages: true,
|
||||
warnings: true,
|
||||
extractedChars: true,
|
||||
analyzedAt: true,
|
||||
viabilityScore: true,
|
||||
riskLevel: true,
|
||||
confidence: true,
|
||||
result: true,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
const historyView = historyEntry ? toNormativeHistoryView(historyEntry) : null;
|
||||
const requirementSeeds = historyView ? buildRequirementSeeds(historyView.result) : [];
|
||||
const technicalSeeds = historyView ? buildTechnicalSectionSeeds(historyView.result) : [];
|
||||
const milestoneSeeds = linkedSource
|
||||
? toMilestoneSeeds({
|
||||
openingDate: linkedSource.openingDate,
|
||||
closingDate: linkedSource.closingDate,
|
||||
eventDates: linkedSource.eventDates,
|
||||
})
|
||||
: [];
|
||||
const sourceDocumentLinks = linkedSource ? toSourceDocumentLinks(linkedSource.documents) : [];
|
||||
const candadosHref =
|
||||
`/candados?proposalId=${encodeURIComponent(proposal.id)}` +
|
||||
(proposal.sourceLicitationId ? `&sourceId=${encodeURIComponent(proposal.sourceLicitationId)}` : "");
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title="Modulo 5: Gestion de Licitaciones"
|
||||
headerMode="module"
|
||||
headerBackHref="/gestion-licitaciones"
|
||||
headerBackLabel="M5: Lista"
|
||||
headerNextHref={candadosHref}
|
||||
headerNextLabel="M6: Candados"
|
||||
headerPlanBadgeLabel="Plan Premium"
|
||||
showPageHeading={false}
|
||||
contentWidth="wide"
|
||||
className="space-y-6"
|
||||
>
|
||||
<ProposalWorkflowView
|
||||
proposal={{
|
||||
id: proposal.id,
|
||||
title: proposal.title,
|
||||
issuingEntity: proposal.issuingEntity,
|
||||
summary: proposal.summary,
|
||||
status: proposal.status,
|
||||
sourceLicitationId: proposal.sourceLicitationId,
|
||||
updatedAt: proposal.updatedAt.toISOString(),
|
||||
documents: proposal.documents.map((document) => ({
|
||||
id: document.id,
|
||||
fileName: document.fileName,
|
||||
mimeType: document.mimeType,
|
||||
sizeBytes: document.sizeBytes,
|
||||
createdAt: document.createdAt.toISOString(),
|
||||
})),
|
||||
}}
|
||||
linkedSource={
|
||||
linkedSource
|
||||
? {
|
||||
id: linkedSource.id,
|
||||
title: linkedSource.title,
|
||||
description: linkedSource.description ?? "",
|
||||
issuingEntity: linkedSource.supplierAwarded ?? null,
|
||||
procedureType: linkedSource.procedureType,
|
||||
stateName: linkedSource.municipality.stateName,
|
||||
municipalityName: linkedSource.municipality.municipalityName,
|
||||
sourceUrl: linkedSource.rawSourceUrl ?? null,
|
||||
documentLinks: sourceDocumentLinks,
|
||||
milestones: milestoneSeeds,
|
||||
}
|
||||
: null
|
||||
}
|
||||
normativeContext={
|
||||
historyView
|
||||
? {
|
||||
id: historyView.id,
|
||||
analyzedAt: historyView.analyzedAt,
|
||||
executiveSummary: historyView.result.executiveSummary,
|
||||
confidence: historyView.confidence,
|
||||
requirementSeeds,
|
||||
technicalSeeds,
|
||||
}
|
||||
: null
|
||||
}
|
||||
autoExtractOnLoad={autoExtractOnLoad}
|
||||
/>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
133
src/app/gestion-licitaciones/page.tsx
Normal file
133
src/app/gestion-licitaciones/page.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { hasModuleAccess } from "@/lib/auth/module-access";
|
||||
import { requireOnboardedUser } from "@/lib/auth/user";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { listUserProposals } from "@/lib/proposals/server";
|
||||
import { PageShell } from "@/components/app/page-shell";
|
||||
import { ProposalsManagementView } from "@/components/app/proposals-management-view";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
function getParam(params: Record<string, string | string[] | undefined>, key: string) {
|
||||
const value = params[key];
|
||||
return Array.isArray(value) ? value[0] ?? "" : (value ?? "");
|
||||
}
|
||||
|
||||
export default async function GestionLicitacionesPage({ searchParams }: PageProps) {
|
||||
const user = await requireOnboardedUser();
|
||||
const hasPaidModulesAccess = await hasModuleAccess(user, 5);
|
||||
|
||||
if (!hasPaidModulesAccess) {
|
||||
return (
|
||||
<PageShell
|
||||
title="Modulo 5: Gestion de Licitaciones"
|
||||
description="Este modulo esta protegido por suscripcion de pago."
|
||||
action={<span className="rounded-full border border-[#d5ddec] bg-[#edf2fb] px-4 py-1 text-sm font-semibold text-[#5a6a87]">Bloqueado</span>}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="space-y-3 py-6">
|
||||
<h2 className="text-2xl font-semibold text-[#1f2a40]">Acceso restringido</h2>
|
||||
<p className="text-sm text-[#64718a]">Modulo 5 forma parte de la ruta premium de modulos pagados.</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href="/dashboard#modulos">
|
||||
<Button>Ver modulos y planes</Button>
|
||||
</Link>
|
||||
<Link href="/normative-analysis">
|
||||
<Button variant="secondary">Ir a Modulo 4</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const sourceLicitationId = getParam(resolvedSearchParams, "sourceId") || null;
|
||||
const shouldAutoCreate = getParam(resolvedSearchParams, "autocreate") === "1";
|
||||
const shouldAutoFill = getParam(resolvedSearchParams, "autofill") === "1";
|
||||
|
||||
if (sourceLicitationId && shouldAutoCreate) {
|
||||
const existing = await prisma.proposal.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
sourceLicitationId,
|
||||
},
|
||||
orderBy: [{ updatedAt: "desc" }],
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
let targetId = existing?.id ?? null;
|
||||
|
||||
if (!targetId) {
|
||||
const source = await prisma.licitation.findUnique({
|
||||
where: { id: sourceLicitationId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
supplierAwarded: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (source) {
|
||||
const created = await prisma.proposal.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
sourceLicitationId: source.id,
|
||||
title: source.title,
|
||||
issuingEntity: source.supplierAwarded?.trim() || "Entidad no especificada",
|
||||
summary: source.description ?? "",
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
targetId = created.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetId) {
|
||||
redirect(`/gestion-licitaciones/${encodeURIComponent(targetId)}${shouldAutoFill ? "?autofill=1" : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
const proposals = await listUserProposals(user.id, "");
|
||||
const latestProposal = proposals[0] ?? null;
|
||||
const candadosHref =
|
||||
latestProposal
|
||||
? `/candados?proposalId=${encodeURIComponent(latestProposal.id)}${
|
||||
latestProposal.sourceLicitationId ? `&sourceId=${encodeURIComponent(latestProposal.sourceLicitationId)}` : ""
|
||||
}`
|
||||
: sourceLicitationId
|
||||
? `/candados?sourceId=${encodeURIComponent(sourceLicitationId)}`
|
||||
: "/candados";
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title="Modulo 5: Gestion de Licitaciones"
|
||||
headerMode="module"
|
||||
headerBackHref="/normative-analysis"
|
||||
headerBackLabel="M4: Analisis"
|
||||
headerNextHref={candadosHref}
|
||||
headerNextLabel="M6: Candados"
|
||||
headerPlanBadgeLabel="Plan Premium"
|
||||
showPageHeading={false}
|
||||
contentWidth="wide"
|
||||
className="space-y-6"
|
||||
>
|
||||
<section className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold text-[#132549] [font-family:var(--font-display)] md:text-5xl">Gestion de Licitaciones</h1>
|
||||
<p className="mt-2 text-xl text-[#4f6588] md:text-2xl">Prepara tus propuestas de licitacion paso a paso</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ProposalsManagementView initialProposals={proposals} initialSourceLicitationId={sourceLicitationId} />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { isAdminIdentity } from "@/lib/auth/admin";
|
||||
import { hasModuleAccess } from "@/lib/auth/module-access";
|
||||
import { requireOnboardedUser } from "@/lib/auth/user";
|
||||
import { isLikelyPdfUrl, parseLicitationDocumentLinks, pickPrimaryLicitationPdfDocument } from "@/lib/licitations/documents";
|
||||
import { getCategoryLabel, getProcedureTypeLabel, getSourceLabel } from "@/lib/licitations/labels";
|
||||
import { type LicitationReviewStatusView } from "@/lib/licitations/preferences";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { LicitationCardActions } from "@/components/app/licitation-card-actions";
|
||||
import { PageShell } from "@/components/app/page-shell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
@@ -25,22 +28,10 @@ function formatDate(value: Date | null) {
|
||||
}
|
||||
|
||||
function toDocumentArray(value: unknown) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [] as Array<{ name: string; url: string; type?: string }>;
|
||||
}
|
||||
|
||||
return value
|
||||
.filter((item) => item && typeof item === "object")
|
||||
.map((item) => {
|
||||
const entry = item as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
name: typeof entry.name === "string" ? entry.name : "Documento",
|
||||
url: typeof entry.url === "string" ? entry.url : "",
|
||||
type: typeof entry.type === "string" ? entry.type : undefined,
|
||||
};
|
||||
})
|
||||
.filter((item) => item.url.length > 0);
|
||||
return parseLicitationDocumentLinks(value).map((item) => ({
|
||||
...item,
|
||||
type: item.type ?? undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
function toEventDates(value: unknown) {
|
||||
@@ -60,7 +51,7 @@ function toEventDates(value: unknown) {
|
||||
|
||||
export default async function LicitationDetailPage({ params }: LicitationDetailPageProps) {
|
||||
const user = await requireOnboardedUser();
|
||||
const hasPaidModulesAccess = isAdminIdentity(user.email, user.role);
|
||||
const hasPaidModulesAccess = await hasModuleAccess(user, 3);
|
||||
|
||||
if (!hasPaidModulesAccess) {
|
||||
return (
|
||||
@@ -99,16 +90,56 @@ export default async function LicitationDetailPage({ params }: LicitationDetailP
|
||||
}
|
||||
|
||||
const documents = toDocumentArray(licitation.documents);
|
||||
const primaryPdfDocument =
|
||||
pickPrimaryLicitationPdfDocument(parseLicitationDocumentLinks(licitation.documents)) ??
|
||||
(isLikelyPdfUrl(licitation.rawSourceUrl)
|
||||
? {
|
||||
name: "Documento principal",
|
||||
url: licitation.rawSourceUrl as string,
|
||||
type: "PDF",
|
||||
}
|
||||
: null);
|
||||
const timeline = toEventDates(licitation.eventDates);
|
||||
const preference = await prisma.licitationUserPreference.findUnique({
|
||||
where: {
|
||||
userId_licitationId: {
|
||||
userId: user.id,
|
||||
licitationId: licitation.id,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
const reviewStatus = (preference?.status as LicitationReviewStatusView | undefined) ?? "NEW";
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title="Detalle de licitacion"
|
||||
description={`${licitation.municipality.municipalityName}, ${licitation.municipality.stateName}`}
|
||||
headerMode="module"
|
||||
headerBackHref="/licitations"
|
||||
headerBackLabel="Volver a Oportunidades"
|
||||
headerShowManual
|
||||
headerPlanBadgeLabel="Plan Premium"
|
||||
contentWidth="wide"
|
||||
action={
|
||||
<Link href="/licitations">
|
||||
<Button variant="secondary">Volver a resultados</Button>
|
||||
</Link>
|
||||
<div className="flex gap-2">
|
||||
<Link href={`/normative-analysis?sourceId=${encodeURIComponent(licitation.id)}`}>
|
||||
<Button>Analizar en Modulo 4</Button>
|
||||
</Link>
|
||||
<Link href={`/gestion-licitaciones?sourceId=${encodeURIComponent(licitation.id)}&autocreate=1&autofill=1`}>
|
||||
<Button variant="secondary">Crear propuesta (M5)</Button>
|
||||
</Link>
|
||||
{primaryPdfDocument ? (
|
||||
<a href={primaryPdfDocument.url} target="_blank" rel="noreferrer">
|
||||
<Button variant="secondary">Ver PDF principal</Button>
|
||||
</a>
|
||||
) : null}
|
||||
<Link href="/licitations">
|
||||
<Button variant="secondary">Volver a resultados</Button>
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Card>
|
||||
@@ -129,6 +160,12 @@ export default async function LicitationDetailPage({ params }: LicitationDetailP
|
||||
<span className="rounded-full border border-[#d8e1ef] px-2 py-1">Cierre: {formatDate(licitation.closingDate)}</span>
|
||||
</div>
|
||||
|
||||
<LicitationCardActions
|
||||
licitationId={licitation.id}
|
||||
initialStatus={reviewStatus}
|
||||
normativeAnalysisHref={`/normative-analysis?sourceId=${encodeURIComponent(licitation.id)}`}
|
||||
/>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="rounded-xl border border-[#d8e1ef] p-3">
|
||||
<p className="text-xs font-semibold uppercase text-[#60718f]">Monto</p>
|
||||
@@ -170,6 +207,16 @@ export default async function LicitationDetailPage({ params }: LicitationDetailP
|
||||
<h3 className="text-lg font-semibold text-[#1f2a40]">Documentos</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{primaryPdfDocument ? (
|
||||
<a
|
||||
href={primaryPdfDocument.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block rounded-xl border border-[#c6d8f4] bg-[#ebf3ff] p-3 text-sm font-semibold text-[#1f3f84]"
|
||||
>
|
||||
PDF principal: {primaryPdfDocument.name}
|
||||
</a>
|
||||
) : null}
|
||||
{documents.length === 0 ? (
|
||||
<p className="text-sm text-[#64718a]">No hay documentos asociados.</p>
|
||||
) : (
|
||||
|
||||
@@ -1,22 +1,64 @@
|
||||
import Link from "next/link";
|
||||
import { LicitationProcedureType } from "@prisma/client";
|
||||
import { isAdminIdentity } from "@/lib/auth/admin";
|
||||
import type { LicitationSource } from "@prisma/client";
|
||||
import { hasModuleAccess } from "@/lib/auth/module-access";
|
||||
import { requireOnboardedUser } from "@/lib/auth/user";
|
||||
import { getCategoryLabel, getProcedureTypeLabel, getSourceLabel } from "@/lib/licitations/labels";
|
||||
import { isLikelyPdfUrl, parseLicitationDocumentLinks, pickPrimaryLicitationPdfDocument } from "@/lib/licitations/documents";
|
||||
import { getCategoryLabel, getProcedureTypeLabel } from "@/lib/licitations/labels";
|
||||
import { getLicitationReviewStatusLabel, type LicitationReviewStatusView } from "@/lib/licitations/preferences";
|
||||
import { listMunicipalities, searchLicitations } from "@/lib/licitations/query";
|
||||
import { getLicitationRecommendationsForUser } from "@/lib/licitations/recommendations";
|
||||
import { getAiEnhancedLicitationRecommendationsForUser } from "@/lib/licitations/ai-recommendations";
|
||||
import { computeModule3Summary, getViabilityLevel } from "@/lib/licitations/module3";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { LicitationCardActions } from "@/components/app/licitation-card-actions";
|
||||
import { LicitationsSyncButton } from "@/components/app/licitations-sync-button";
|
||||
import { PageShell } from "@/components/app/page-shell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
type LicitationsPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
type ViabilityFilter = "all" | "alta" | "media" | "baja";
|
||||
type SortFilter = "compat_desc" | "compat_asc" | "closing_soon" | "recent";
|
||||
type StatusFilter = "all" | "open" | "closed";
|
||||
type ReviewFilter = "all" | "consulted" | "interested" | "active";
|
||||
|
||||
function getParam(params: Record<string, string | string[] | undefined>, key: string) {
|
||||
const value = params[key];
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
return Array.isArray(value) ? value[0] ?? "" : (value ?? "");
|
||||
}
|
||||
|
||||
function parseViability(value: string): ViabilityFilter {
|
||||
if (value === "alta" || value === "media" || value === "baja") {
|
||||
return value;
|
||||
}
|
||||
|
||||
return "all";
|
||||
}
|
||||
|
||||
function parseSort(value: string): SortFilter {
|
||||
if (value === "compat_asc" || value === "closing_soon" || value === "recent") {
|
||||
return value;
|
||||
}
|
||||
|
||||
return "compat_desc";
|
||||
}
|
||||
|
||||
function parseStatus(value: string): StatusFilter {
|
||||
if (value === "open" || value === "closed") {
|
||||
return value;
|
||||
}
|
||||
|
||||
return "all";
|
||||
}
|
||||
|
||||
function parseReview(value: string): ReviewFilter {
|
||||
if (value === "consulted" || value === "interested" || value === "active") {
|
||||
return value;
|
||||
}
|
||||
|
||||
return "all";
|
||||
}
|
||||
|
||||
function formatDate(value: Date | null) {
|
||||
@@ -26,11 +68,18 @@ function formatDate(value: Date | null) {
|
||||
|
||||
return value.toLocaleDateString("es-MX", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
month: "long",
|
||||
day: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateShort(value: Date) {
|
||||
return value.toLocaleDateString("es-MX", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
});
|
||||
}
|
||||
|
||||
function formatAmount(amount: unknown, currency: string | null) {
|
||||
if (amount == null) {
|
||||
return "Monto no disponible";
|
||||
@@ -42,12 +91,67 @@ function formatAmount(amount: unknown, currency: string | null) {
|
||||
return "Monto no disponible";
|
||||
}
|
||||
|
||||
return `${currency ?? "MXN"} ${numeric.toLocaleString("es-MX", { maximumFractionDigits: 2 })}`;
|
||||
return `${currency ?? "MXN"} ${numeric.toLocaleString("es-MX", { maximumFractionDigits: 0 })}`;
|
||||
}
|
||||
|
||||
function getSourceShortLabel(source: LicitationSource) {
|
||||
if (source === "MUNICIPAL_OPEN_PORTAL") {
|
||||
return "Portal municipal";
|
||||
}
|
||||
|
||||
if (source === "MUNICIPAL_BACKUP") {
|
||||
return "Portal respaldo";
|
||||
}
|
||||
|
||||
if (source === "LICITAYA") {
|
||||
return "LicitaYa API";
|
||||
}
|
||||
|
||||
return "PNT";
|
||||
}
|
||||
|
||||
function getDaysUntilDate(value: Date | null) {
|
||||
if (!value) {
|
||||
return "Sin cierre";
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const difference = value.getTime() - now.getTime();
|
||||
const dayCount = Math.max(0, Math.ceil(difference / (1000 * 60 * 60 * 24)));
|
||||
return `${dayCount} dias`;
|
||||
}
|
||||
|
||||
function ViabilityBadge({ score }: { score: number }) {
|
||||
const level = getViabilityLevel(score);
|
||||
|
||||
const label = level === "alta" ? "Alta Viabilidad" : level === "media" ? "Media Viabilidad" : "Baja Viabilidad";
|
||||
const className =
|
||||
level === "alta"
|
||||
? "border-[#b8e3d0] bg-[#ecf9f2] text-[#1f8b63]"
|
||||
: level === "media"
|
||||
? "border-[#f0deb0] bg-[#fff8e9] text-[#9d6d08]"
|
||||
: "border-[#f2cccc] bg-[#fff1f1] text-[#af3a3a]";
|
||||
|
||||
return <span className={`inline-flex rounded-full border px-3 py-1 text-sm font-semibold ${className}`}>{label}</span>;
|
||||
}
|
||||
|
||||
function ReviewStatusBadge({ status }: { status: LicitationReviewStatusView }) {
|
||||
const label = getLicitationReviewStatusLabel(status);
|
||||
const className =
|
||||
status === "REVIEWED"
|
||||
? "border-[#d4dbe8] bg-[#f2f5fb] text-[#42577c]"
|
||||
: status === "INTERESTED"
|
||||
? "border-[#b9e6cd] bg-[#eaf9f1] text-[#1f8b63]"
|
||||
: status === "DISCARDED"
|
||||
? "border-[#f0ccd2] bg-[#fff1f4] text-[#a74150]"
|
||||
: "border-[#d4e2ff] bg-[#e8f0ff] text-[#2f58c1]";
|
||||
|
||||
return <span className={`inline-flex rounded-full border px-3 py-1 text-sm font-semibold ${className}`}>{label}</span>;
|
||||
}
|
||||
|
||||
export default async function LicitationsPage({ searchParams }: LicitationsPageProps) {
|
||||
const user = await requireOnboardedUser();
|
||||
const hasPaidModulesAccess = isAdminIdentity(user.email, user.role);
|
||||
const hasPaidModulesAccess = await hasModuleAccess(user, 3);
|
||||
|
||||
if (!hasPaidModulesAccess) {
|
||||
return (
|
||||
@@ -57,10 +161,8 @@ export default async function LicitationsPage({ searchParams }: LicitationsPageP
|
||||
action={<span className="rounded-full border border-[#d5ddec] bg-[#edf2fb] px-4 py-1 text-sm font-semibold text-[#5a6a87]">Bloqueado</span>}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold text-[#1f2a40]">Acceso restringido</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<CardContent className="space-y-3 py-6">
|
||||
<h2 className="text-2xl font-semibold text-[#1f2a40]">Acceso restringido</h2>
|
||||
<p className="text-sm text-[#64718a]">
|
||||
El Modulo 3 forma parte de la ruta premium. Tu cuenta actual puede completar el Diagnostico (Modulo 1) y ver los planes en la seccion Modulos.
|
||||
</p>
|
||||
@@ -74,14 +176,18 @@ export default async function LicitationsPage({ searchParams }: LicitationsPageP
|
||||
}
|
||||
|
||||
const params = await searchParams;
|
||||
const q = getParam(params, "q") ?? "";
|
||||
const state = getParam(params, "state") ?? "";
|
||||
const municipality = getParam(params, "municipality") ?? "";
|
||||
const procedureType = getParam(params, "procedure_type") ?? "";
|
||||
const minAmount = getParam(params, "min_amount") ?? "";
|
||||
const maxAmount = getParam(params, "max_amount") ?? "";
|
||||
const dateFrom = getParam(params, "date_from") ?? "";
|
||||
const dateTo = getParam(params, "date_to") ?? "";
|
||||
const q = getParam(params, "q");
|
||||
const state = getParam(params, "state");
|
||||
const municipality = getParam(params, "municipality");
|
||||
const procedureType = getParam(params, "procedure_type");
|
||||
const minAmount = getParam(params, "min_amount");
|
||||
const maxAmount = getParam(params, "max_amount");
|
||||
const dateFrom = getParam(params, "date_from");
|
||||
const dateTo = getParam(params, "date_to");
|
||||
const viabilityFilter = parseViability(getParam(params, "viability"));
|
||||
const statusFilter = parseStatus(getParam(params, "status"));
|
||||
const reviewFilter = parseReview(getParam(params, "review"));
|
||||
const sortFilter = parseSort(getParam(params, "sort"));
|
||||
|
||||
const [municipalities, records, recommendations] = await Promise.all([
|
||||
listMunicipalities(),
|
||||
@@ -96,166 +202,522 @@ export default async function LicitationsPage({ searchParams }: LicitationsPageP
|
||||
dateTo,
|
||||
take: 100,
|
||||
}),
|
||||
getLicitationRecommendationsForUser(user.id),
|
||||
getAiEnhancedLicitationRecommendationsForUser(user.id),
|
||||
]);
|
||||
|
||||
const preferenceRecords = records.records.length
|
||||
? await prisma.licitationUserPreference.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
licitationId: {
|
||||
in: records.records.map((record) => record.id),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
licitationId: true,
|
||||
status: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
const recommendationsById = new Map(recommendations.results.map((item) => [item.id, item]));
|
||||
const preferencesById = new Map(preferenceRecords.map((item) => [item.licitationId, item.status as LicitationReviewStatusView]));
|
||||
const activeProposalRecords = records.records.length
|
||||
? await prisma.proposal.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
sourceLicitationId: {
|
||||
in: records.records.map((record) => record.id),
|
||||
},
|
||||
status: {
|
||||
not: "ARCHIVED",
|
||||
},
|
||||
},
|
||||
select: {
|
||||
sourceLicitationId: true,
|
||||
status: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
orderBy: [{ updatedAt: "desc" }],
|
||||
})
|
||||
: [];
|
||||
const activeLicitationIds = new Set(activeProposalRecords.map((item) => item.sourceLicitationId).filter((value): value is string => Boolean(value)));
|
||||
|
||||
const baseRows = records.records
|
||||
.map((record) => {
|
||||
const recommendation = recommendationsById.get(record.id);
|
||||
const deterministicScore = recommendation?.deterministicScore ?? recommendation?.score ?? 0;
|
||||
const aiFitScore = recommendation?.aiScore ?? deterministicScore;
|
||||
const blendedScore = recommendation?.blendedScore ?? deterministicScore;
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
title: record.title,
|
||||
description: record.description,
|
||||
entity: record.supplierAwarded ?? "Entidad no especificada",
|
||||
municipalityName: record.municipality.municipalityName,
|
||||
stateName: record.municipality.stateName,
|
||||
amountLabel: formatAmount(record.amount, record.currency),
|
||||
daysToClose: getDaysUntilDate(record.closingDate),
|
||||
procedureLabel: getProcedureTypeLabel(record.procedureType),
|
||||
categoryLabel: getCategoryLabel(record.category),
|
||||
publishDate: record.publishDate,
|
||||
closingDate: record.closingDate,
|
||||
sourceLabel: getSourceShortLabel(record.source),
|
||||
deterministicScore,
|
||||
aiFitScore,
|
||||
compatibilityScore: blendedScore,
|
||||
blendedScore,
|
||||
reasons: recommendation?.reasons ?? [],
|
||||
aiReasons: recommendation?.aiReasons ?? [],
|
||||
aiRisks: recommendation?.aiRisks ?? [],
|
||||
nextStep: recommendation?.nextStep ?? null,
|
||||
suggestionId: recommendation?.suggestionId ?? null,
|
||||
isOpen: record.isOpen,
|
||||
reviewStatus: preferencesById.get(record.id) ?? "NEW",
|
||||
isActive: activeLicitationIds.has(record.id),
|
||||
primaryPdfUrl:
|
||||
pickPrimaryLicitationPdfDocument(parseLicitationDocumentLinks(record.documents))?.url ??
|
||||
(isLikelyPdfUrl(record.rawSourceUrl) ? record.rawSourceUrl : null),
|
||||
};
|
||||
})
|
||||
.filter((row) => {
|
||||
if (statusFilter === "open") {
|
||||
return row.isOpen;
|
||||
}
|
||||
|
||||
if (statusFilter === "closed") {
|
||||
return !row.isOpen;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.filter((row) => {
|
||||
if (viabilityFilter === "all") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return getViabilityLevel(row.compatibilityScore) === viabilityFilter;
|
||||
});
|
||||
|
||||
const rows = baseRows.filter((row) => {
|
||||
if (reviewFilter === "consulted") {
|
||||
return row.reviewStatus === "REVIEWED";
|
||||
}
|
||||
|
||||
if (reviewFilter === "interested") {
|
||||
return row.reviewStatus === "INTERESTED";
|
||||
}
|
||||
|
||||
if (reviewFilter === "active") {
|
||||
return row.isActive;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const sortedRows = [...rows].sort((left, right) => {
|
||||
if (sortFilter === "compat_asc") {
|
||||
return left.blendedScore - right.blendedScore;
|
||||
}
|
||||
|
||||
if (sortFilter === "closing_soon") {
|
||||
const leftTime = left.closingDate?.getTime() ?? Number.MAX_SAFE_INTEGER;
|
||||
const rightTime = right.closingDate?.getTime() ?? Number.MAX_SAFE_INTEGER;
|
||||
return leftTime - rightTime;
|
||||
}
|
||||
|
||||
if (sortFilter === "recent") {
|
||||
const leftTime = left.publishDate?.getTime() ?? 0;
|
||||
const rightTime = right.publishDate?.getTime() ?? 0;
|
||||
return rightTime - leftTime;
|
||||
}
|
||||
|
||||
return right.blendedScore - left.blendedScore;
|
||||
});
|
||||
|
||||
const uniqueStates = Array.from(new Map(municipalities.map((item) => [item.stateCode, item.stateName])).entries()).map(([code, name]) => ({
|
||||
code,
|
||||
name,
|
||||
}));
|
||||
|
||||
const filteredMunicipalities = state ? municipalities.filter((item) => item.stateCode === state) : municipalities;
|
||||
const now = new Date();
|
||||
const summary = computeModule3Summary(
|
||||
sortedRows.length,
|
||||
sortedRows.map((row) => ({
|
||||
id: row.id,
|
||||
score: row.compatibilityScore,
|
||||
publishDate: row.publishDate?.toISOString() ?? null,
|
||||
})),
|
||||
now,
|
||||
);
|
||||
const newToReviewCount = sortedRows.filter((row) => row.reviewStatus === "NEW").length;
|
||||
const consultedCount = baseRows.filter((row) => row.reviewStatus === "REVIEWED").length;
|
||||
const interestedCount = baseRows.filter((row) => row.reviewStatus === "INTERESTED").length;
|
||||
const activeCount = baseRows.filter((row) => row.isActive).length;
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title="Modulo 3: Deteccion de Oportunidades"
|
||||
description="Encuentra licitaciones compatibles con tu perfil empresarial"
|
||||
action={<LicitationsSyncButton />}
|
||||
headerMode="module"
|
||||
headerBackHref="/dashboard"
|
||||
headerBackLabel="Volver al Dashboard"
|
||||
headerShowManual
|
||||
headerPlanBadgeLabel="Plan Premium"
|
||||
showPageHeading={false}
|
||||
contentWidth="wide"
|
||||
className="space-y-6"
|
||||
>
|
||||
<section className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#dee5f1] text-3xl text-[#264574]">✧</span>
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold text-[#132549] [font-family:var(--font-display)] md:text-5xl">Modulo 3: Deteccion de Oportunidades</h1>
|
||||
<p className="mt-2 text-xl text-[#4f6588] md:text-2xl">Encuentra licitaciones compatibles con tu perfil empresarial</p>
|
||||
</div>
|
||||
</div>
|
||||
<LicitationsSyncButton />
|
||||
</section>
|
||||
|
||||
<Card className="border-[#d2dbe9] bg-[#f7f9fd]">
|
||||
<CardContent className="flex items-center justify-between py-5">
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-[#1b3158] [font-family:var(--font-display)] md:text-3xl">Como usar este modulo?</p>
|
||||
<p className="text-base text-[#566d92] md:text-lg">Guia rapida para encontrar oportunidades</p>
|
||||
</div>
|
||||
<span className="text-2xl text-[#273f67]" aria-hidden>
|
||||
˅
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-4xl font-semibold text-[#12274d] md:text-5xl">{summary.opportunities}</p>
|
||||
<p className="text-lg text-[#60718f] md:text-xl">Oportunidades</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-4xl font-semibold text-[#12274d] md:text-5xl">{summary.highViability}</p>
|
||||
<p className="text-lg text-[#60718f] md:text-xl">Alta viabilidad</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-4xl font-semibold text-[#12274d] md:text-5xl">{summary.mediumViability}</p>
|
||||
<p className="text-lg text-[#60718f] md:text-xl">Media viabilidad</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-4xl font-semibold text-[#12274d] md:text-5xl">{summary.averageCompatibility}%</p>
|
||||
<p className="text-lg text-[#60718f] md:text-xl">Compatibilidad promedio</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-3 md:grid-cols-3">
|
||||
<Link href="/licitations?review=consulted" className="block">
|
||||
<Card className={reviewFilter === "consulted" ? "border-[#b8c9eb] bg-[#edf3ff]" : undefined}>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-3xl font-semibold text-[#15305d]">{consultedCount}</p>
|
||||
<p className="text-sm font-semibold text-[#5f7090]">Consultadas</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link href="/licitations?review=interested" className="block">
|
||||
<Card className={reviewFilter === "interested" ? "border-[#b9e6cd] bg-[#edf9f2]" : undefined}>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-3xl font-semibold text-[#155540]">{interestedCount}</p>
|
||||
<p className="text-sm font-semibold text-[#5f7090]">Me interesa</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link href="/licitations?review=active" className="block">
|
||||
<Card className={reviewFilter === "active" ? "border-[#f0deb0] bg-[#fff9ea]" : undefined}>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-3xl font-semibold text-[#704f05]">{activeCount}</p>
|
||||
<p className="text-sm font-semibold text-[#5f7090]">Activas</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
<p className="text-base text-[#4d6488] md:text-lg">
|
||||
Ultimo analisis: {formatDateShort(now)}, {now.toLocaleTimeString("es-MX", { hour: "2-digit", minute: "2-digit" })} ·
|
||||
<span className="ml-1 font-semibold text-[#1c3768]">{newToReviewCount} nuevas por revisar</span>
|
||||
</p>
|
||||
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<form method="get" className="grid gap-3 md:grid-cols-3">
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c] md:col-span-2">
|
||||
Buscar oportunidades
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
placeholder="Palabra clave, proveedor, concepto..."
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<form method="get" className="space-y-4">
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_220px_220px_220px_220px]">
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c] lg:col-span-1">
|
||||
Buscar oportunidades
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
placeholder="Buscar oportunidades..."
|
||||
className="h-12 w-full rounded-xl border border-[#cfd8e6] px-4 text-base"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
|
||||
Tipo de procedimiento
|
||||
<select name="procedure_type" defaultValue={procedureType} className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm">
|
||||
<option value="">Todos</option>
|
||||
<option value={LicitationProcedureType.LICITACION_PUBLICA}>Licitacion publica</option>
|
||||
<option value={LicitationProcedureType.INVITACION_RESTRINGIDA}>Invitacion restringida</option>
|
||||
<option value={LicitationProcedureType.ADJUDICACION_DIRECTA}>Adjudicacion directa</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
|
||||
Viabilidad
|
||||
<select name="viability" defaultValue={viabilityFilter} className="h-12 w-full rounded-xl border border-[#cfd8e6] px-3 text-base">
|
||||
<option value="all">Todas</option>
|
||||
<option value="alta">Alta</option>
|
||||
<option value="media">Media</option>
|
||||
<option value="baja">Baja</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
|
||||
Estado
|
||||
<select name="state" defaultValue={state} className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm">
|
||||
<option value="">Todos los estados</option>
|
||||
{uniqueStates.map((stateOption) => (
|
||||
<option key={stateOption.code} value={stateOption.code}>
|
||||
{stateOption.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
|
||||
Estatus
|
||||
<select name="status" defaultValue={statusFilter} className="h-12 w-full rounded-xl border border-[#cfd8e6] px-3 text-base">
|
||||
<option value="all">Todos</option>
|
||||
<option value="open">Abiertas</option>
|
||||
<option value="closed">Cerradas</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
|
||||
Municipio
|
||||
<select name="municipality" defaultValue={municipality} className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm">
|
||||
<option value="">Todos</option>
|
||||
{filteredMunicipalities.map((municipalityOption) => (
|
||||
<option key={municipalityOption.id} value={municipalityOption.municipalityCode}>
|
||||
{municipalityOption.municipalityName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
|
||||
Seguimiento M3
|
||||
<select name="review" defaultValue={reviewFilter} className="h-12 w-full rounded-xl border border-[#cfd8e6] px-3 text-base">
|
||||
<option value="all">Todos</option>
|
||||
<option value="consulted">Consultadas</option>
|
||||
<option value="interested">Me interesa</option>
|
||||
<option value="active">Activas</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
|
||||
Monto minimo
|
||||
<input type="number" step="0.01" name="min_amount" defaultValue={minAmount} className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm" />
|
||||
</label>
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
|
||||
Ordenar por
|
||||
<select name="sort" defaultValue={sortFilter} className="h-12 w-full rounded-xl border border-[#cfd8e6] px-3 text-base">
|
||||
<option value="compat_desc">Mayor compatibilidad</option>
|
||||
<option value="compat_asc">Menor compatibilidad</option>
|
||||
<option value="closing_soon">Cierre proximo</option>
|
||||
<option value="recent">Mas recientes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
|
||||
Monto maximo
|
||||
<input type="number" step="0.01" name="max_amount" defaultValue={maxAmount} className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm" />
|
||||
</label>
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-6">
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
|
||||
Procedimiento
|
||||
<select name="procedure_type" defaultValue={procedureType} className="h-12 w-full rounded-xl border border-[#cfd8e6] px-3 text-base">
|
||||
<option value="">Todos</option>
|
||||
<option value="LICITACION_PUBLICA">Licitacion publica</option>
|
||||
<option value="INVITACION_RESTRINGIDA">Invitacion restringida</option>
|
||||
<option value="ADJUDICACION_DIRECTA">Adjudicacion directa</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
|
||||
Fecha desde
|
||||
<input type="date" name="date_from" defaultValue={dateFrom} className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm" />
|
||||
</label>
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c] lg:col-span-2">
|
||||
Estado
|
||||
<select name="state" defaultValue={state} className="h-12 w-full rounded-xl border border-[#cfd8e6] px-3 text-base">
|
||||
<option value="">Todos los estados</option>
|
||||
{uniqueStates.map((stateOption) => (
|
||||
<option key={stateOption.code} value={stateOption.code}>
|
||||
{stateOption.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
|
||||
Fecha hasta
|
||||
<input type="date" name="date_to" defaultValue={dateTo} className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm" />
|
||||
</label>
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c] lg:col-span-2">
|
||||
Municipio
|
||||
<select name="municipality" defaultValue={municipality} className="h-12 w-full rounded-xl border border-[#cfd8e6] px-3 text-base">
|
||||
<option value="">Todos</option>
|
||||
{filteredMunicipalities.map((municipalityOption) => (
|
||||
<option key={municipalityOption.id} value={municipalityOption.municipalityCode}>
|
||||
{municipalityOption.municipalityName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="md:col-span-3 flex gap-2">
|
||||
<Button type="submit">Aplicar filtros</Button>
|
||||
<Link href="/licitations">
|
||||
<Button type="button" variant="secondary">
|
||||
Limpiar
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
|
||||
Monto minimo
|
||||
<input type="number" step="0.01" name="min_amount" defaultValue={minAmount} className="h-12 w-full rounded-xl border border-[#cfd8e6] px-3 text-base" />
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
|
||||
Monto maximo
|
||||
<input type="number" step="0.01" name="max_amount" defaultValue={maxAmount} className="h-12 w-full rounded-xl border border-[#cfd8e6] px-3 text-base" />
|
||||
</label>
|
||||
|
||||
<input type="hidden" name="date_from" value={dateFrom} />
|
||||
<input type="hidden" name="date_to" value={dateTo} />
|
||||
|
||||
<div className="flex items-end gap-2 lg:col-span-2">
|
||||
<Button type="submit" className="h-12 rounded-xl px-5">
|
||||
Aplicar
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/licitations" className="inline-flex">
|
||||
<Button type="button" variant="secondary" className="h-12 rounded-xl px-5">
|
||||
Limpiar
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold text-[#1f2a40]">Recomendaciones para tu empresa</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{recommendations.results.slice(0, 5).length ? (
|
||||
recommendations.results.slice(0, 5).map((item) => (
|
||||
<div key={item.id} className="rounded-xl border border-[#d8e1ef] bg-white p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm font-semibold text-[#1f2f4f]">{item.title}</p>
|
||||
<span className="rounded-full bg-[#e8eefc] px-2 py-1 text-xs font-semibold text-[#1a3f8d]">Score {item.score}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-[#5c6f8e]">{item.municipalityName}, {item.stateName}</p>
|
||||
<p className="mt-1 text-xs text-[#5c6f8e]">{item.reasons.join(" ")}</p>
|
||||
<Link href={`/licitations/${item.id}`} className="mt-2 inline-flex text-sm font-semibold text-[#1f3f84]">
|
||||
Ver detalle
|
||||
</Link>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-[#64718a]">Aun no hay recomendaciones por perfil. Completa tu perfil y ejecuta sincronizacion.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold text-[#1f2a40]">Resultados ({records.total})</h2>
|
||||
<p className="text-sm text-[#60718f]">Mostrando oportunidades abiertas por defecto.</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{records.records.length === 0 ? (
|
||||
<p className="text-sm text-[#64718a]">No se encontraron oportunidades con los filtros seleccionados.</p>
|
||||
) : (
|
||||
records.records.map((record) => (
|
||||
<article key={record.id} className="rounded-xl border border-[#d8e1ef] bg-white p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase text-[#60718f]">{record.municipality.stateName} / {record.municipality.municipalityName}</p>
|
||||
<h3 className="text-lg font-semibold text-[#1f2a40]">{record.title}</h3>
|
||||
<p className="text-sm text-[#60718f]">{record.description ?? "Sin descripcion"}</p>
|
||||
<section className="grid gap-4 xl:grid-cols-2">
|
||||
{sortedRows.length === 0 ? (
|
||||
<Card className="xl:col-span-2">
|
||||
<CardContent className="py-14 text-center">
|
||||
<p className="text-2xl font-semibold text-[#1b3158] [font-family:var(--font-display)] md:text-3xl">No hay oportunidades con estos filtros</p>
|
||||
<p className="mt-2 text-base text-[#5f7090] md:text-lg">Ajusta los filtros o ejecuta una nueva sincronizacion.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
sortedRows.map((row) => (
|
||||
<article
|
||||
key={row.id}
|
||||
className={`rounded-2xl border border-[#d7dfea] p-6 shadow-[0_1px_2px_rgba(16,24,40,0.06)] ${row.reviewStatus === "DISCARDED" ? "bg-[#fafbfe] opacity-85" : "bg-white"}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ReviewStatusBadge status={row.reviewStatus} />
|
||||
<ViabilityBadge score={row.blendedScore} />
|
||||
{row.isActive ? (
|
||||
<span className="inline-flex rounded-full border border-[#f0deb0] bg-[#fff8e9] px-3 py-1 text-sm font-semibold text-[#8d6308]">Activa en M5</span>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="rounded-full bg-[#eef3ff] px-2 py-1 text-xs font-semibold text-[#1f3f84]">{getProcedureTypeLabel(record.procedureType)}</span>
|
||||
<div className="text-right">
|
||||
<p className="text-3xl font-semibold text-[#12306a] md:text-4xl">{row.blendedScore}%</p>
|
||||
<p className="text-base text-[#5f7290] md:text-lg">score combinado</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-[#5e7190]">
|
||||
<span className={`rounded-full border px-2 py-1 ${record.isOpen ? "border-[#acd8c7] bg-[#e8f7ef] text-[#1c8f67]" : "border-[#d4dced]"}`}>
|
||||
{record.isOpen ? "Abierta" : "Cerrada"}
|
||||
<h3 className="mt-4 text-2xl font-semibold leading-tight text-[#162748] [font-family:var(--font-display)] md:text-3xl">{row.title}</h3>
|
||||
|
||||
<div className="mt-4 h-4 w-full overflow-hidden rounded-full bg-[#d7e6ef]">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-[#1f3f84] via-[#2576a8] to-[#2bb67f]"
|
||||
style={{ width: `${Math.max(8, Math.min(100, row.blendedScore))}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-2 text-sm text-[#5d7090] md:grid-cols-3">
|
||||
<p>
|
||||
Determinista: <span className="font-semibold text-[#17315a]">{row.deterministicScore}%</span>
|
||||
</p>
|
||||
<p>
|
||||
AI fit: <span className="font-semibold text-[#17315a]">{row.aiFitScore}%</span>
|
||||
</p>
|
||||
<p>
|
||||
Combinado: <span className="font-semibold text-[#17315a]">{row.blendedScore}%</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-2 text-base text-[#596f93] md:grid-cols-2 md:text-lg">
|
||||
<p>{row.entity}</p>
|
||||
<p>{row.municipalityName}</p>
|
||||
<p>{row.amountLabel}</p>
|
||||
<p>{row.daysToClose}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2 text-sm">
|
||||
<p className="font-semibold text-[#0f8a56]">Por que es compatible:</p>
|
||||
{row.aiReasons.length ? (
|
||||
row.aiReasons.slice(0, 2).map((reason) => (
|
||||
<span key={reason} className="mr-2 inline-flex rounded-full border border-[#b9e6cd] bg-[#eaf9f1] px-3 py-1 font-semibold text-[#1c865e]">
|
||||
{reason}
|
||||
</span>
|
||||
))
|
||||
) : row.reasons.length ? (
|
||||
row.reasons.slice(0, 2).map((reason) => (
|
||||
<span key={reason} className="mr-2 inline-flex rounded-full border border-[#dbe4ef] bg-[#f4f7fb] px-3 py-1 font-semibold text-[#576b8f]">
|
||||
{reason}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="inline-flex rounded-full border border-[#dbe4ef] bg-[#f4f7fb] px-3 py-1 font-semibold text-[#576b8f]">
|
||||
Completa mas datos de perfil para mejorar recomendaciones.
|
||||
</span>
|
||||
<span className="rounded-full border border-[#d4dced] px-2 py-1">{getCategoryLabel(record.category)}</span>
|
||||
<span className="rounded-full border border-[#d4dced] px-2 py-1">{formatAmount(record.amount, record.currency)}</span>
|
||||
<span className="rounded-full border border-[#d4dced] px-2 py-1">Publicacion: {formatDate(record.publishDate)}</span>
|
||||
<span className="rounded-full border border-[#d4dced] px-2 py-1">Cierre: {formatDate(record.closingDate)}</span>
|
||||
<span className="rounded-full border border-[#d4dced] px-2 py-1">Fuente: {getSourceLabel(record.source)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link href={`/licitations/${record.id}`} className="mt-3 inline-flex text-sm font-semibold text-[#1f3f84]">
|
||||
Ver detalle
|
||||
</Link>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{row.aiRisks.length ? (
|
||||
<div className="mt-2 space-y-1 text-sm">
|
||||
<p className="font-semibold text-[#8d6308]">Riesgos detectados por IA:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{row.aiRisks.slice(0, 2).map((risk) => (
|
||||
<span key={risk} className="inline-flex rounded-full border border-[#f0deb0] bg-[#fff8e9] px-3 py-1 font-semibold text-[#8d6308]">
|
||||
{risk}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{row.nextStep ? <p className="mt-2 text-sm font-semibold text-[#264574]">Siguiente paso sugerido: {row.nextStep}</p> : null}
|
||||
|
||||
<details className="mt-4 rounded-xl border border-[#dce4ef] bg-[#f9fbff] p-3">
|
||||
<summary className="cursor-pointer text-lg font-semibold text-[#1f3f84] md:text-xl">Ver resumen rapido</summary>
|
||||
<div className="mt-3 space-y-2 text-base text-[#5d7090] md:text-lg">
|
||||
<p>Procedimiento: {row.procedureLabel}</p>
|
||||
<p>Categoria: {row.categoryLabel}</p>
|
||||
<p>Fuente: {row.sourceLabel}</p>
|
||||
<p>Publicacion: {formatDate(row.publishDate)}</p>
|
||||
<p>Cierre: {formatDate(row.closingDate)}</p>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<LicitationCardActions
|
||||
licitationId={row.id}
|
||||
initialStatus={row.reviewStatus}
|
||||
normativeAnalysisHref={`/normative-analysis?sourceId=${encodeURIComponent(row.id)}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Link href={`/licitations/${encodeURIComponent(row.id)}`}>
|
||||
<Button size="sm">Ir al detalle</Button>
|
||||
</Link>
|
||||
<Link href={`/normative-analysis?sourceId=${encodeURIComponent(row.id)}`}>
|
||||
<Button size="sm" variant="secondary">
|
||||
Analizar en M4
|
||||
</Button>
|
||||
</Link>
|
||||
{row.primaryPdfUrl ? (
|
||||
<a href={row.primaryPdfUrl} target="_blank" rel="noreferrer">
|
||||
<Button size="sm" variant="secondary">
|
||||
Ver PDF principal
|
||||
</Button>
|
||||
</a>
|
||||
) : null}
|
||||
<Link href={`/gestion-licitaciones?sourceId=${encodeURIComponent(row.id)}&autocreate=1&autofill=1`}>
|
||||
<Button size="sm" variant="secondary">
|
||||
Crear propuesta (M5)
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</details>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-[#c9dfd2] bg-[#f0faf4] px-6 py-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-[#17315a] [font-family:var(--font-display)] md:text-3xl">Te interesa una oportunidad?</p>
|
||||
<p className="text-base text-[#5c7395] md:text-lg">Descarga las bases de licitacion y continua en Analisis Normativo (M4).</p>
|
||||
</div>
|
||||
<Link href="/normative-analysis" className="inline-flex">
|
||||
<Button size="lg" className="rounded-2xl px-7">
|
||||
Ir a Analisis Normativo (M4)
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
113
src/app/normative-analysis/page.tsx
Normal file
113
src/app/normative-analysis/page.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import Link from "next/link";
|
||||
import { hasModuleAccess } from "@/lib/auth/module-access";
|
||||
import { requireOnboardedUser } from "@/lib/auth/user";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { NormativeAnalysisView } from "@/components/app/normative-analysis-view";
|
||||
import { PageShell } from "@/components/app/page-shell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
type NormativeAnalysisPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
function getParam(params: Record<string, string | string[] | undefined>, key: string) {
|
||||
const value = params[key];
|
||||
return Array.isArray(value) ? value[0] ?? "" : (value ?? "");
|
||||
}
|
||||
|
||||
export default async function NormativeAnalysisPage({ searchParams }: NormativeAnalysisPageProps) {
|
||||
const user = await requireOnboardedUser();
|
||||
const hasPaidModulesAccess = await hasModuleAccess(user, 4);
|
||||
|
||||
if (!hasPaidModulesAccess) {
|
||||
return (
|
||||
<PageShell
|
||||
title="Modulo 4: Analisis Normativo"
|
||||
description="Este modulo esta protegido por suscripcion de pago."
|
||||
action={<span className="rounded-full border border-[#d5ddec] bg-[#edf2fb] px-4 py-1 text-sm font-semibold text-[#5a6a87]">Bloqueado</span>}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="space-y-3 py-6">
|
||||
<h2 className="text-2xl font-semibold text-[#1f2a40]">Acceso restringido</h2>
|
||||
<p className="text-sm text-[#64718a]">
|
||||
El Modulo 4 forma parte de la ruta premium. Tu cuenta actual puede completar el Diagnostico (Modulo 1) y revisar los planes en la seccion Modulos.
|
||||
</p>
|
||||
<Link href="/dashboard#modulos">
|
||||
<Button>Ver modulos y planes</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const params = await searchParams;
|
||||
const sourceId = getParam(params, "sourceId").trim();
|
||||
const linkedSource = sourceId
|
||||
? await prisma.licitation.findUnique({
|
||||
where: { id: sourceId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
supplierAwarded: true,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title="Modulo 4: Analisis Normativo"
|
||||
headerMode="module"
|
||||
headerBackHref="/dashboard"
|
||||
headerBackLabel="Volver al Dashboard"
|
||||
headerShowManual
|
||||
headerPlanBadgeLabel="Plan Premium"
|
||||
showPageHeading={false}
|
||||
contentWidth="wide"
|
||||
className="space-y-6"
|
||||
>
|
||||
<section className="flex items-start gap-4">
|
||||
<span className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#dce5f2] text-3xl text-[#274676]">◨</span>
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold text-[#132549] [font-family:var(--font-display)] md:text-5xl">Modulo 4: Analisis Normativo</h1>
|
||||
<p className="mt-2 text-xl text-[#4f6588] md:text-2xl">Analiza bases de licitacion, reglamentos y leyes con IA</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<NormativeAnalysisView
|
||||
linkedSource={
|
||||
linkedSource
|
||||
? {
|
||||
id: linkedSource.id,
|
||||
title: linkedSource.title,
|
||||
issuingEntity: linkedSource.supplierAwarded,
|
||||
}
|
||||
: null
|
||||
}
|
||||
autoAnalyzeOnLoad={Boolean(linkedSource)}
|
||||
/>
|
||||
|
||||
<section className="rounded-2xl border border-[#d8e0ec] bg-[#f4f7fb] px-6 py-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-3xl font-semibold text-[#17315a] [font-family:var(--font-display)]">Navegacion</p>
|
||||
<p className="text-lg text-[#5c7395]">Regresa al Modulo 3 para ver mas oportunidades o vuelve al dashboard.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href="/licitations">
|
||||
<Button size="lg" className="rounded-2xl px-6">
|
||||
Volver a Oportunidades
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/dashboard">
|
||||
<Button size="lg" variant="secondary" className="rounded-2xl px-6">
|
||||
Ir al Dashboard
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
103
src/app/proteccion-legal/page.tsx
Normal file
103
src/app/proteccion-legal/page.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import Link from "next/link";
|
||||
import { hasModuleAccess } from "@/lib/auth/module-access";
|
||||
import { requireOnboardedUser } from "@/lib/auth/user";
|
||||
import { listLegalTemplates } from "@/lib/legal/ai-documents";
|
||||
import { getLegalKpisForUser, listLegalCasesForUser, listLegalDirectory } from "@/lib/legal/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { LegalProtectionView } from "@/components/app/legal-protection-view";
|
||||
import { PageShell } from "@/components/app/page-shell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
function getParam(params: Record<string, string | string[] | undefined>, key: string) {
|
||||
const value = params[key];
|
||||
return Array.isArray(value) ? value[0] ?? "" : (value ?? "");
|
||||
}
|
||||
|
||||
export default async function ProteccionLegalPage({ searchParams }: PageProps) {
|
||||
const user = await requireOnboardedUser();
|
||||
const hasPaidModulesAccess = await hasModuleAccess(user, 9);
|
||||
|
||||
if (!hasPaidModulesAccess) {
|
||||
return (
|
||||
<PageShell
|
||||
title="Modulo 9: Proteccion Legal"
|
||||
description="Este modulo esta protegido por suscripcion de pago."
|
||||
action={<span className="rounded-full border border-[#d5ddec] bg-[#edf2fb] px-4 py-1 text-sm font-semibold text-[#5a6a87]">Bloqueado</span>}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="space-y-3 py-6">
|
||||
<h2 className="text-2xl font-semibold text-[#1f2a40]">Acceso restringido</h2>
|
||||
<p className="text-sm text-[#64718a]">Modulo 9 forma parte de la ruta premium de modulos pagados.</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href="/dashboard#modulos">
|
||||
<Button>Ver modulos y planes</Button>
|
||||
</Link>
|
||||
<Link href="/gestion-contratos">
|
||||
<Button variant="secondary">Ir a Modulo 8</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const contractId = getParam(resolvedSearchParams, "contractId") || null;
|
||||
|
||||
const [cases, kpis, directory, contracts] = await Promise.all([
|
||||
listLegalCasesForUser(user.id),
|
||||
getLegalKpisForUser(user.id),
|
||||
listLegalDirectory({}),
|
||||
prisma.contractRecord.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: [{ updatedAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
contractNumber: true,
|
||||
counterpartyEntity: true,
|
||||
status: true,
|
||||
},
|
||||
take: 200,
|
||||
}),
|
||||
]);
|
||||
const templates = listLegalTemplates();
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title="Modulo 9: Proteccion Legal"
|
||||
headerMode="module"
|
||||
headerBackHref="/gestion-contratos"
|
||||
headerBackLabel="M8: Contratos"
|
||||
headerNextHref="/expediente-preventivo"
|
||||
headerNextLabel="M10: Auditorias"
|
||||
headerPlanBadgeLabel="Plan Premium"
|
||||
showPageHeading={false}
|
||||
contentWidth="wide"
|
||||
className="space-y-6"
|
||||
>
|
||||
<section className="flex items-start gap-4">
|
||||
<span className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#dce5f2] text-3xl text-[#274676]">◪</span>
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold text-[#132549] [font-family:var(--font-display)] md:text-5xl">Proteccion Legal</h1>
|
||||
<p className="mt-2 text-xl text-[#4f6588] md:text-2xl">Diagnostico guiado, gestion de casos, escalada y generacion de escritos con trazabilidad</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<LegalProtectionView
|
||||
initialCases={cases}
|
||||
initialKpis={kpis}
|
||||
initialDirectory={directory}
|
||||
initialTemplates={templates}
|
||||
availableContracts={contracts}
|
||||
prefilledContractId={contractId}
|
||||
/>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { isAdminIdentity } from "@/lib/auth/admin";
|
||||
import { hasModuleAccess } from "@/lib/auth/module-access";
|
||||
import { StrategicDiagnosticWizard } from "@/components/app/strategic-diagnostic-wizard";
|
||||
import { PageShell } from "@/components/app/page-shell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -9,7 +9,7 @@ import { getStrategicDiagnosticSnapshot } from "@/lib/strategic-diagnostic/serve
|
||||
|
||||
export default async function StrategicDiagnosticPage() {
|
||||
const user = await requireOnboardedUser();
|
||||
const hasPaidModulesAccess = isAdminIdentity(user.email, user.role);
|
||||
const hasPaidModulesAccess = await hasModuleAccess(user, 2);
|
||||
|
||||
if (!hasPaidModulesAccess) {
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { isAdminIdentity } from "@/lib/auth/admin";
|
||||
import { hasAnyPaidModuleAccess } from "@/lib/auth/module-access";
|
||||
import { requireOnboardedUser } from "@/lib/auth/user";
|
||||
import { PageShell } from "@/components/app/page-shell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -27,7 +27,7 @@ function parseDimension(searchParams: Record<string, string | string[] | undefin
|
||||
|
||||
export default async function TalleresDesarrolloPage({ searchParams }: PageProps) {
|
||||
const user = await requireOnboardedUser();
|
||||
const hasPaidModulesAccess = isAdminIdentity(user.email, user.role);
|
||||
const hasPaidModulesAccess = await hasAnyPaidModuleAccess(user.id, user.email, user.role);
|
||||
|
||||
if (!hasPaidModulesAccess) {
|
||||
return (
|
||||
|
||||
114
src/components/app/__tests__/compliance-alerts-view.test.tsx
Normal file
114
src/components/app/__tests__/compliance-alerts-view.test.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { M7Dataset } from "@/lib/compliance/types";
|
||||
import { ComplianceAlertsView } from "@/components/app/compliance-alerts-view";
|
||||
|
||||
function buildDataset(overrides?: Partial<M7Dataset>): M7Dataset {
|
||||
return {
|
||||
generatedAt: "2026-04-02T10:00:00.000Z",
|
||||
kpis: {
|
||||
activeLicitations: 2,
|
||||
completionRate: 64,
|
||||
criticalPending: 1,
|
||||
upcoming7Days: 3,
|
||||
},
|
||||
m3States: {
|
||||
consulted: 1,
|
||||
interested: 2,
|
||||
active: 1,
|
||||
new: 5,
|
||||
},
|
||||
tabs: {
|
||||
plazos: [
|
||||
{
|
||||
id: "plazo-1",
|
||||
proposalId: "p-1",
|
||||
licitationId: "l-1",
|
||||
title: "Cierre de convocatoria",
|
||||
description: "Fecha limite",
|
||||
dueAt: "2026-04-03T12:00:00.000Z",
|
||||
source: "milestone",
|
||||
status: "upcoming",
|
||||
},
|
||||
],
|
||||
alertas: [
|
||||
{
|
||||
id: "alerta-1",
|
||||
proposalId: "p-1",
|
||||
licitationId: "l-1",
|
||||
title: "Vence pronto",
|
||||
description: "Requisito critico",
|
||||
severity: "high",
|
||||
kind: "deadline_soon",
|
||||
dueAt: "2026-04-03T12:00:00.000Z",
|
||||
createdAt: "2026-04-02T10:00:00.000Z",
|
||||
},
|
||||
],
|
||||
checklist: [
|
||||
{
|
||||
proposalId: "p-1",
|
||||
proposalTitle: "Propuesta 1",
|
||||
mandatoryResolved: 4,
|
||||
mandatoryTotal: 6,
|
||||
completionRate: 67,
|
||||
signaturePolicyStatus: "condicionado",
|
||||
},
|
||||
],
|
||||
panelKpi: [
|
||||
{
|
||||
id: "panel-1",
|
||||
label: "Licitaciones activas",
|
||||
value: "2",
|
||||
tone: "success",
|
||||
},
|
||||
],
|
||||
},
|
||||
...(overrides ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("ComplianceAlertsView UI contract", () => {
|
||||
it("renders the four KPI cards and tab labels", () => {
|
||||
const html = renderToStaticMarkup(<ComplianceAlertsView dataset={buildDataset()} />);
|
||||
|
||||
expect(html).toContain("Licitaciones activas");
|
||||
expect(html).toContain("Tasa de completitud");
|
||||
expect(html).toContain("Pendientes criticos");
|
||||
expect(html).toContain("Proximos 7 dias");
|
||||
expect(html).toContain("Plazos");
|
||||
expect(html).toContain("Alertas");
|
||||
expect(html).toContain("Checklist");
|
||||
expect(html).toContain("Panel KPI");
|
||||
});
|
||||
|
||||
it("renders empty state when there is no active compliance data", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ComplianceAlertsView
|
||||
dataset={buildDataset({
|
||||
kpis: {
|
||||
activeLicitations: 0,
|
||||
completionRate: 0,
|
||||
criticalPending: 0,
|
||||
upcoming7Days: 0,
|
||||
},
|
||||
tabs: {
|
||||
plazos: [],
|
||||
alertas: [],
|
||||
checklist: [],
|
||||
panelKpi: [],
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Sin alertas de cumplimiento");
|
||||
});
|
||||
|
||||
it("keeps responsive layout classes for desktop and mobile grids", () => {
|
||||
const html = renderToStaticMarkup(<ComplianceAlertsView dataset={buildDataset()} />);
|
||||
|
||||
expect(html).toContain("md:grid-cols-2");
|
||||
expect(html).toContain("xl:grid-cols-4");
|
||||
expect(html).toContain("md:grid-cols-4");
|
||||
});
|
||||
});
|
||||
388
src/components/app/compliance-alerts-view.tsx
Normal file
388
src/components/app/compliance-alerts-view.tsx
Normal file
@@ -0,0 +1,388 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { M7Dataset } from "@/lib/compliance/types";
|
||||
|
||||
type ComplianceAlertsViewProps = {
|
||||
dataset: M7Dataset;
|
||||
};
|
||||
|
||||
type M7AiPlaybook = {
|
||||
predictedIncidents: {
|
||||
title: string;
|
||||
likelihood: "alta" | "media" | "baja";
|
||||
impact: "alto" | "medio" | "bajo";
|
||||
timeHorizon: string;
|
||||
}[];
|
||||
priorityOrder: string[];
|
||||
preventiveActions: {
|
||||
action: string;
|
||||
ownerSuggestion: string;
|
||||
targetDate: string;
|
||||
}[];
|
||||
escalationAdvice: string[];
|
||||
confidence: "low" | "medium" | "high";
|
||||
};
|
||||
|
||||
function toneClass(tone: "neutral" | "success" | "warning" | "danger") {
|
||||
if (tone === "success") {
|
||||
return "border-[#b9e6cd] bg-[#eaf9f1] text-[#1f8b63]";
|
||||
}
|
||||
|
||||
if (tone === "warning") {
|
||||
return "border-[#f0deb0] bg-[#fff8e9] text-[#8d6308]";
|
||||
}
|
||||
|
||||
if (tone === "danger") {
|
||||
return "border-[#f1c7ce] bg-[#fff1f4] text-[#b03f4f]";
|
||||
}
|
||||
|
||||
return "border-[#d8e1ef] bg-[#f4f7fb] text-[#51688f]";
|
||||
}
|
||||
|
||||
function severityClass(severity: "high" | "medium" | "low") {
|
||||
if (severity === "high") {
|
||||
return "border-[#f1c7ce] bg-[#fff1f4] text-[#b03f4f]";
|
||||
}
|
||||
|
||||
if (severity === "medium") {
|
||||
return "border-[#f0deb0] bg-[#fff8e9] text-[#8d6308]";
|
||||
}
|
||||
|
||||
return "border-[#b9e6cd] bg-[#eaf9f1] text-[#1f8b63]";
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return "Sin fecha";
|
||||
}
|
||||
|
||||
return parsed.toLocaleString("es-MX", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function ComplianceAlertsView({ dataset }: ComplianceAlertsViewProps) {
|
||||
const [isLoadingAiPlaybook, setIsLoadingAiPlaybook] = useState(false);
|
||||
const [aiPlaybook, setAiPlaybook] = useState<M7AiPlaybook | null>(null);
|
||||
const [aiSuggestionId, setAiSuggestionId] = useState<string | null>(null);
|
||||
const [aiFeedback, setAiFeedback] = useState<string | null>(null);
|
||||
const [aiError, setAiError] = useState<string | null>(null);
|
||||
|
||||
async function setSuggestionDecision(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 generateAiPlaybook() {
|
||||
setIsLoadingAiPlaybook(true);
|
||||
setAiError(null);
|
||||
setAiFeedback(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/compliance/m7/ai/playbook", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ dataset }),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as {
|
||||
ok?: boolean;
|
||||
error?: string;
|
||||
predictedIncidents?: M7AiPlaybook["predictedIncidents"];
|
||||
priorityOrder?: M7AiPlaybook["priorityOrder"];
|
||||
preventiveActions?: M7AiPlaybook["preventiveActions"];
|
||||
escalationAdvice?: M7AiPlaybook["escalationAdvice"];
|
||||
confidence?: M7AiPlaybook["confidence"];
|
||||
suggestionId?: string;
|
||||
};
|
||||
|
||||
if (!response.ok || !payload.ok) {
|
||||
setAiError(payload.error ?? "No fue posible generar plan IA.");
|
||||
return;
|
||||
}
|
||||
|
||||
setAiPlaybook({
|
||||
predictedIncidents: payload.predictedIncidents ?? [],
|
||||
priorityOrder: payload.priorityOrder ?? [],
|
||||
preventiveActions: payload.preventiveActions ?? [],
|
||||
escalationAdvice: payload.escalationAdvice ?? [],
|
||||
confidence: payload.confidence ?? "low",
|
||||
});
|
||||
setAiSuggestionId(payload.suggestionId ?? null);
|
||||
setAiFeedback("Plan IA generado.");
|
||||
} catch {
|
||||
setAiError("No fue posible generar plan IA.");
|
||||
} finally {
|
||||
setIsLoadingAiPlaybook(false);
|
||||
}
|
||||
}
|
||||
|
||||
const isEmpty =
|
||||
dataset.kpis.activeLicitations === 0 &&
|
||||
dataset.tabs.plazos.length === 0 &&
|
||||
dataset.tabs.alertas.length === 0 &&
|
||||
dataset.tabs.checklist.length === 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<section className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Licitaciones activas</p>
|
||||
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{dataset.kpis.activeLicitations}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Tasa de completitud</p>
|
||||
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{dataset.kpis.completionRate}%</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Pendientes criticos</p>
|
||||
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{dataset.kpis.criticalPending}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Proximos 7 dias</p>
|
||||
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{dataset.kpis.upcoming7Days}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-3 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Consultadas</p>
|
||||
<p className="mt-2 text-3xl font-semibold text-[#15294d]">{dataset.m3States.consulted}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Me interesa</p>
|
||||
<p className="mt-2 text-3xl font-semibold text-[#15294d]">{dataset.m3States.interested}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Activas M5</p>
|
||||
<p className="mt-2 text-3xl font-semibold text-[#15294d]">{dataset.m3States.active}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Nuevas M3</p>
|
||||
<p className="mt-2 text-3xl font-semibold text-[#15294d]">{dataset.m3States.new}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{isEmpty ? (
|
||||
<Card className="border-dashed border-[#cfd8e6] bg-[#fbfdff]">
|
||||
<CardContent className="py-16 text-center">
|
||||
<p className="text-3xl font-semibold text-[#162748] [font-family:var(--font-display)]">Sin alertas de cumplimiento</p>
|
||||
<p className="mt-2 text-base text-[#5f7090]">Cuando tengas procesos activos en M5 y fechas detectadas, este tablero mostrara riesgos y vencimientos.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{aiError ? <p className="rounded-lg border border-[#f1c7ce] bg-[#fff1f4] px-3 py-2 text-sm text-[#b03f4f]">{aiError}</p> : null}
|
||||
{aiFeedback ? <p className="rounded-lg border border-[#b9e6cd] bg-[#eaf9f1] px-3 py-2 text-sm text-[#1f8b63]">{aiFeedback}</p> : null}
|
||||
|
||||
<Tabs
|
||||
defaultTab="plazos"
|
||||
items={[
|
||||
{
|
||||
id: "plazos",
|
||||
label: "Plazos",
|
||||
content: (
|
||||
<div className="space-y-2">
|
||||
{dataset.tabs.plazos.length === 0 ? <p className="text-sm text-[#60718f]">Sin plazos detectables para los proximos dias.</p> : null}
|
||||
{dataset.tabs.plazos.map((item) => (
|
||||
<article key={item.id} className="rounded-xl border border-[#d8e1ef] bg-white p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">{item.title}</p>
|
||||
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-semibold ${item.status === "overdue" ? "border-[#f1c7ce] bg-[#fff1f4] text-[#b03f4f]" : "border-[#d8e1ef] bg-[#f4f7fb] text-[#51688f]"}`}>
|
||||
{item.status === "overdue" ? "Vencido" : "Programado"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[#60718f]">{item.description}</p>
|
||||
<p className="mt-1 text-xs font-semibold text-[#334d77]">{formatDateTime(item.dueAt)}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "alertas",
|
||||
label: "Alertas",
|
||||
content: (
|
||||
<div className="space-y-2">
|
||||
{dataset.tabs.alertas.length === 0 ? <p className="text-sm text-[#60718f]">Sin alertas activas.</p> : null}
|
||||
{dataset.tabs.alertas.map((item) => (
|
||||
<article key={item.id} className="rounded-xl border border-[#d8e1ef] bg-white p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">{item.title}</p>
|
||||
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-semibold ${severityClass(item.severity)}`}>{item.severity}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[#60718f]">{item.description}</p>
|
||||
{item.dueAt ? <p className="mt-1 text-xs font-semibold text-[#334d77]">{formatDateTime(item.dueAt)}</p> : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "checklist",
|
||||
label: "Checklist",
|
||||
content: (
|
||||
<div className="space-y-2">
|
||||
{dataset.tabs.checklist.length === 0 ? <p className="text-sm text-[#60718f]">No hay procesos activos para checklist.</p> : null}
|
||||
{dataset.tabs.checklist.map((item) => (
|
||||
<article key={item.proposalId} className="rounded-xl border border-[#d8e1ef] bg-white p-3">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">{item.proposalTitle}</p>
|
||||
<p className="mt-1 text-xs text-[#60718f]">
|
||||
Obligatorios resueltos: {item.mandatoryResolved}/{item.mandatoryTotal} ({item.completionRate}%)
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[#60718f]">Firma: {item.signaturePolicyStatus.replaceAll("_", " ")}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "panel-kpi",
|
||||
label: "Panel KPI",
|
||||
content: (
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{dataset.tabs.panelKpi.map((item) => (
|
||||
<article key={item.id} className={`rounded-xl border p-3 ${toneClass(item.tone)}`}>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em]">{item.label}</p>
|
||||
<p className="mt-1 text-2xl font-semibold">{item.value}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "plan-ia",
|
||||
label: "Plan IA",
|
||||
content: (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="secondary" disabled={isLoadingAiPlaybook} onClick={() => void generateAiPlaybook()}>
|
||||
{isLoadingAiPlaybook ? "Generando plan IA..." : "Generar plan IA"}
|
||||
</Button>
|
||||
{aiPlaybook ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
void setSuggestionDecision("dismiss");
|
||||
setAiPlaybook(null);
|
||||
setAiFeedback("Plan IA descartado.");
|
||||
}}
|
||||
>
|
||||
Descartar plan IA
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!aiPlaybook ? <p className="text-sm text-[#60718f]">Sin plan IA generado todavia.</p> : null}
|
||||
|
||||
{aiPlaybook ? (
|
||||
<>
|
||||
<article className="rounded-xl border border-[#d8e1ef] bg-white p-3">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">Incidentes predichos</p>
|
||||
<ul className="mt-2 space-y-2 text-xs text-[#60718f]">
|
||||
{aiPlaybook.predictedIncidents.map((incident) => (
|
||||
<li key={`${incident.title}-${incident.timeHorizon}`} className="rounded-lg border border-[#e4ebf6] px-2 py-1">
|
||||
<p className="font-semibold text-[#233b62]">{incident.title}</p>
|
||||
<p>
|
||||
Probabilidad {incident.likelihood} · Impacto {incident.impact} · Horizonte {incident.timeHorizon}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article className="rounded-xl border border-[#d8e1ef] bg-white p-3">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">Orden de prioridad</p>
|
||||
<ol className="mt-2 space-y-1 text-xs text-[#60718f]">
|
||||
{aiPlaybook.priorityOrder.map((item, index) => (
|
||||
<li key={`${item}-${index}`}>
|
||||
{index + 1}. {item}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</article>
|
||||
|
||||
<article className="rounded-xl border border-[#d8e1ef] bg-white p-3">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">Acciones preventivas</p>
|
||||
<ul className="mt-2 space-y-2 text-xs text-[#60718f]">
|
||||
{aiPlaybook.preventiveActions.map((action) => (
|
||||
<li key={`${action.action}-${action.ownerSuggestion}`} className="rounded-lg border border-[#e4ebf6] px-2 py-1">
|
||||
<p className="font-semibold text-[#233b62]">{action.action}</p>
|
||||
<p>
|
||||
Responsable sugerido: {action.ownerSuggestion} · Fecha objetivo: {action.targetDate}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article className="rounded-xl border border-[#d8e1ef] bg-white p-3">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">Recomendaciones de escalamiento</p>
|
||||
<ul className="mt-2 space-y-1 text-xs text-[#60718f]">
|
||||
{aiPlaybook.escalationAdvice.map((item) => (
|
||||
<li key={item}>- {item}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void setSuggestionDecision("accept");
|
||||
setAiFeedback("Plan IA marcado como aceptado.");
|
||||
}}
|
||||
>
|
||||
Aceptar plan IA
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1046
src/components/app/contracts-management-view.tsx
Normal file
1046
src/components/app/contracts-management-view.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -43,13 +43,13 @@ export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnaps
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-3xl font-semibold text-[#142447] [font-family:var(--font-display)]">Tu Indice de Madurez</h3>
|
||||
<h3 className="text-2xl font-semibold text-[#142447] [font-family:var(--font-display)] md:text-3xl">Tu Indice de Madurez</h3>
|
||||
<p className="text-sm text-[#60718f]">Visualiza tu progreso hacia el siguiente nivel de preparacion.</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="text-center">
|
||||
<p className="text-7xl font-semibold text-[#20a777]">{Math.round(snapshot.overallMaturity)}%</p>
|
||||
<p className="text-2xl font-semibold text-[#213252]">Puntaje Global</p>
|
||||
<p className="text-5xl font-semibold text-[#20a777] md:text-6xl">{Math.round(snapshot.overallMaturity)}%</p>
|
||||
<p className="text-xl font-semibold text-[#213252]">Puntaje Global</p>
|
||||
<span className="mt-2 inline-flex rounded-full bg-[#24a977] px-4 py-1 text-sm font-semibold text-white">
|
||||
Nivel {snapshot.maturityLevel.label}
|
||||
</span>
|
||||
@@ -69,7 +69,7 @@ export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnaps
|
||||
|
||||
<div className="rounded-2xl border border-[#d6e0ee] bg-[#f5faf7] p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-3xl font-semibold text-[#18305f]">
|
||||
<p className="text-2xl font-semibold text-[#18305f] md:text-3xl">
|
||||
Proximo nivel: <span className="text-[#d39b1c]">{snapshot.nextLevel?.label ?? "Avanzado"}</span>
|
||||
</p>
|
||||
{snapshot.nextLevel ? (
|
||||
@@ -101,7 +101,7 @@ export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnaps
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-4xl font-semibold text-[#152647] [font-family:var(--font-display)]">Tu RADAR Empresarial</h3>
|
||||
<h3 className="text-2xl font-semibold text-[#152647] [font-family:var(--font-display)] md:text-3xl">Tu RADAR Empresarial</h3>
|
||||
<p className="text-sm text-[#60718f]">Visualiza tus fortalezas y areas de mejora en 5 dimensiones.</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -116,7 +116,7 @@ export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnaps
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-4xl font-semibold text-[#152647] [font-family:var(--font-display)]">Puntaje por Dimension</h3>
|
||||
<h3 className="text-2xl font-semibold text-[#152647] [font-family:var(--font-display)] md:text-3xl">Puntaje por Dimension</h3>
|
||||
<p className="text-sm text-[#60718f]">Cada respuesta afirmativa suma 20 puntos. Maximo 100 por dimension.</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -125,7 +125,7 @@ export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnaps
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-bold text-[#1f2f4c]">{statusIcon(dimension.gapLevel)}</span>
|
||||
<p className="text-3xl font-semibold text-[#1a2c4b]">{dimension.moduleName}</p>
|
||||
<p className="text-xl font-semibold text-[#1a2c4b] md:text-2xl">{dimension.moduleName}</p>
|
||||
<Link href={`/talleres-desarrollo?dimension=${dimension.moduleKey}`}>
|
||||
<Button size="sm" className="h-8 rounded-full bg-[#25a878] px-4 text-sm hover:bg-[#1f9368]">
|
||||
Ver talleres
|
||||
@@ -134,7 +134,7 @@ export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnaps
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`rounded-full border px-3 py-1 text-sm font-semibold ${gapTone(dimension.gapLevel)}`}>{dimension.gapLabel}</span>
|
||||
<span className="text-2xl font-semibold text-[#152747]">{Math.round(dimension.displayScore)}%</span>
|
||||
<span className="text-xl font-semibold text-[#152747]">{Math.round(dimension.displayScore)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-4 overflow-hidden rounded-full bg-[#d9e2ee]">
|
||||
@@ -152,7 +152,7 @@ export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnaps
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-4xl font-semibold text-[#152647] [font-family:var(--font-display)]">Analisis de Brechas por Dimension</h3>
|
||||
<h3 className="text-2xl font-semibold text-[#152647] [font-family:var(--font-display)] md:text-3xl">Analisis de Brechas por Dimension</h3>
|
||||
<p className="text-sm text-[#60718f]">Identificacion de riesgos asociados a cada area de tu empresa.</p>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
@@ -193,13 +193,13 @@ export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnaps
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-4xl font-semibold text-[#152647] [font-family:var(--font-display)]">Recomendaciones Estrategicas Generales</h3>
|
||||
<h3 className="text-2xl font-semibold text-[#152647] [font-family:var(--font-display)] md:text-3xl">Recomendaciones Estrategicas Generales</h3>
|
||||
<p className="text-sm text-[#60718f]">3 acciones prioritarias para mejorar tu posicion competitiva.</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{snapshot.recommendations.map((recommendation, index) => (
|
||||
<article key={recommendation.id} className="rounded-2xl border border-[#d4dceb] bg-[#fdfefe] p-4">
|
||||
<p className="text-4xl font-semibold text-[#102345]">
|
||||
<p className="text-2xl font-semibold text-[#102345] md:text-3xl">
|
||||
{index + 1}. {recommendation.title}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[#5f7395]">{recommendation.description}</p>
|
||||
@@ -211,19 +211,19 @@ export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnaps
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="space-y-2 py-5">
|
||||
<p className="text-4xl font-semibold text-[#132447] [font-family:var(--font-display)]">Que es RADAR?</p>
|
||||
<p className="text-2xl font-semibold text-[#132447] [font-family:var(--font-display)] md:text-3xl">Que es RADAR?</p>
|
||||
<p className="text-sm text-[#60718f]">El diagnostico RADAR evalua 5 dimensiones clave para identificar tu nivel de preparacion.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="space-y-2 py-5">
|
||||
<p className="text-4xl font-semibold text-[#132447] [font-family:var(--font-display)]">Que es CRECE?</p>
|
||||
<p className="text-2xl font-semibold text-[#132447] [font-family:var(--font-display)] md:text-3xl">Que es CRECE?</p>
|
||||
<p className="text-sm text-[#60718f]">Es tu ruta de fortalecimiento empresarial con capacitacion, rediseno y preparacion.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="space-y-2 py-5">
|
||||
<p className="text-4xl font-semibold text-[#132447] [font-family:var(--font-display)]">Indice de Madurez</p>
|
||||
<p className="text-2xl font-semibold text-[#132447] [font-family:var(--font-display)] md:text-3xl">Indice de Madurez</p>
|
||||
<p className="text-sm text-[#60718f]">Mide tu evolucion de Inicial a En Desarrollo, Preparado y Avanzado.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
891
src/components/app/legal-protection-view.tsx
Normal file
891
src/components/app/legal-protection-view.tsx
Normal file
@@ -0,0 +1,891 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type {
|
||||
DiagnosisRecommendation,
|
||||
LegalCaseView,
|
||||
LegalContractOptionView,
|
||||
LegalDirectoryEntityView,
|
||||
LegalKpiSnapshot,
|
||||
LegalTemplateView,
|
||||
} from "@/lib/legal/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
|
||||
type LegalProtectionViewProps = {
|
||||
initialCases: LegalCaseView[];
|
||||
initialKpis: LegalKpiSnapshot;
|
||||
initialDirectory: LegalDirectoryEntityView[];
|
||||
initialTemplates: LegalTemplateView[];
|
||||
availableContracts: LegalContractOptionView[];
|
||||
prefilledContractId?: string | null;
|
||||
};
|
||||
|
||||
type ApiPayload = {
|
||||
ok?: boolean;
|
||||
error?: string;
|
||||
kpis?: LegalKpiSnapshot;
|
||||
cases?: LegalCaseView[];
|
||||
case?: LegalCaseView | null;
|
||||
diagnosis?: {
|
||||
id: string;
|
||||
legalCaseId: string | null;
|
||||
recommendation: DiagnosisRecommendation;
|
||||
};
|
||||
escalation?: {
|
||||
caseId: string;
|
||||
steps: Array<{
|
||||
key: string;
|
||||
title: string;
|
||||
estimatedWindow: string;
|
||||
checklist: string[];
|
||||
requiresLawyer: boolean;
|
||||
dependsOn: string | null;
|
||||
completedAt: string | null;
|
||||
notes: string;
|
||||
isBlocked: boolean;
|
||||
}>;
|
||||
};
|
||||
templates?: LegalTemplateView[];
|
||||
entities?: LegalDirectoryEntityView[];
|
||||
document?: {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
};
|
||||
generation?: {
|
||||
warnings?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
function formatDate(value: string | null) {
|
||||
if (!value) {
|
||||
return "Sin fecha";
|
||||
}
|
||||
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return "Sin fecha";
|
||||
}
|
||||
|
||||
return parsed.toLocaleString("es-MX", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function severityTone(severity: LegalCaseView["severity"]) {
|
||||
if (severity === "HIGH") {
|
||||
return "border-[#f1c7ce] bg-[#fff1f4] text-[#b03f4f]";
|
||||
}
|
||||
|
||||
if (severity === "MEDIUM") {
|
||||
return "border-[#f0deb0] bg-[#fff8e9] text-[#8d6308]";
|
||||
}
|
||||
|
||||
return "border-[#b9e6cd] bg-[#eaf9f1] text-[#1f8b63]";
|
||||
}
|
||||
|
||||
function formatContractOptionLabel(contract: LegalContractOptionView) {
|
||||
const numberLabel = contract.contractNumber ? `#${contract.contractNumber}` : "Sin numero";
|
||||
return `${contract.title} - ${contract.counterpartyEntity} (${numberLabel})`;
|
||||
}
|
||||
|
||||
export function LegalProtectionView({
|
||||
initialCases,
|
||||
initialKpis,
|
||||
initialDirectory,
|
||||
initialTemplates,
|
||||
availableContracts,
|
||||
prefilledContractId,
|
||||
}: LegalProtectionViewProps) {
|
||||
const [cases, setCases] = useState(initialCases);
|
||||
const [kpis, setKpis] = useState(initialKpis);
|
||||
const [directory, setDirectory] = useState(initialDirectory);
|
||||
const [templates, setTemplates] = useState(initialTemplates);
|
||||
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const [diagnosisStep, setDiagnosisStep] = useState(1);
|
||||
const [diagnosisId, setDiagnosisId] = useState<string | null>(null);
|
||||
const [diagnosisRecommendation, setDiagnosisRecommendation] = useState<DiagnosisRecommendation | null>(null);
|
||||
const [diagnosisAnswers, setDiagnosisAnswers] = useState({
|
||||
breachType: "contract_breach",
|
||||
processStage: "none",
|
||||
evidenceLevel: "basic",
|
||||
urgency: "medium",
|
||||
objective: "payment",
|
||||
amountAtRisk: "",
|
||||
counterparty: "",
|
||||
description: "",
|
||||
});
|
||||
const [diagnosisContractId, setDiagnosisContractId] = useState(prefilledContractId ?? "");
|
||||
|
||||
const [newCase, setNewCase] = useState({
|
||||
contractId: prefilledContractId ?? "",
|
||||
caseType: "CONTRACT_BREACH",
|
||||
severity: "MEDIUM",
|
||||
counterparty: "",
|
||||
description: "",
|
||||
amountAtRisk: "",
|
||||
status: "OPEN",
|
||||
});
|
||||
|
||||
const [selectedEscalationCaseId, setSelectedEscalationCaseId] = useState("");
|
||||
const [escalation, setEscalation] = useState<ApiPayload["escalation"] | null>(null);
|
||||
|
||||
const [documentDraft, setDocumentDraft] = useState({
|
||||
legalCaseId: "",
|
||||
caseType: "CONTRACT_BREACH",
|
||||
severity: "MEDIUM",
|
||||
templateKey: "",
|
||||
counterparty: "",
|
||||
companyName: "",
|
||||
amountAtRisk: "",
|
||||
description: "",
|
||||
objective: "",
|
||||
title: "",
|
||||
});
|
||||
const [generatedDocuments, setGeneratedDocuments] = useState<Array<{ id: string; title: string; content: string; createdAt: string }>>([]);
|
||||
|
||||
const [directoryFilters, setDirectoryFilters] = useState({
|
||||
jurisdictionLevel: "",
|
||||
q: "",
|
||||
});
|
||||
|
||||
const currentEscalationCase = useMemo(
|
||||
() => cases.find((item) => item.id === selectedEscalationCaseId) ?? null,
|
||||
[cases, selectedEscalationCaseId],
|
||||
);
|
||||
const contractOptions = useMemo(() => {
|
||||
const options = availableContracts.map((item) => ({
|
||||
id: item.id,
|
||||
label: formatContractOptionLabel(item),
|
||||
isPrefilled: item.id === prefilledContractId,
|
||||
}));
|
||||
|
||||
if (prefilledContractId && !options.some((item) => item.id === prefilledContractId)) {
|
||||
options.unshift({
|
||||
id: prefilledContractId,
|
||||
label: `${prefilledContractId} (prefill)`,
|
||||
isPrefilled: true,
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
}, [availableContracts, prefilledContractId]);
|
||||
|
||||
async function reloadDataset() {
|
||||
const [kpisResponse, casesResponse, templatesResponse] = await Promise.all([
|
||||
fetch("/api/legal/kpis"),
|
||||
fetch("/api/legal/cases"),
|
||||
fetch("/api/legal/templates"),
|
||||
]);
|
||||
|
||||
const kpisPayload = (await kpisResponse.json().catch(() => ({}))) as ApiPayload;
|
||||
const casesPayload = (await casesResponse.json().catch(() => ({}))) as ApiPayload;
|
||||
const templatesPayload = (await templatesResponse.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (kpisResponse.ok && kpisPayload.ok && kpisPayload.kpis) {
|
||||
setKpis(kpisPayload.kpis);
|
||||
}
|
||||
|
||||
if (casesResponse.ok && casesPayload.ok && casesPayload.cases) {
|
||||
setCases(casesPayload.cases);
|
||||
}
|
||||
|
||||
if (templatesResponse.ok && templatesPayload.ok && templatesPayload.templates) {
|
||||
setTemplates(templatesPayload.templates);
|
||||
}
|
||||
}
|
||||
|
||||
async function startDiagnosis() {
|
||||
setIsBusy(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/legal/diagnosis/start", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
contractId: diagnosisContractId || null,
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (!response.ok || !payload.ok || !payload.diagnosis) {
|
||||
setErrorMessage(payload.error ?? "No fue posible iniciar diagnostico.");
|
||||
return;
|
||||
}
|
||||
|
||||
setDiagnosisId(payload.diagnosis.id);
|
||||
setDiagnosisStep(1);
|
||||
setDiagnosisRecommendation(payload.diagnosis.recommendation);
|
||||
setMessage("Diagnostico iniciado.");
|
||||
} catch {
|
||||
setErrorMessage("No fue posible iniciar diagnostico.");
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitDiagnosisStep(finalize = false) {
|
||||
if (!diagnosisId) {
|
||||
await startDiagnosis();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBusy(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/legal/diagnosis/answer", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
diagnosisId,
|
||||
stepIndex: diagnosisStep,
|
||||
finalize,
|
||||
answers: {
|
||||
breachType: diagnosisAnswers.breachType,
|
||||
processStage: diagnosisAnswers.processStage,
|
||||
evidenceLevel: diagnosisAnswers.evidenceLevel,
|
||||
urgency: diagnosisAnswers.urgency,
|
||||
objective: diagnosisAnswers.objective,
|
||||
amountAtRisk: diagnosisAnswers.amountAtRisk ? Number(diagnosisAnswers.amountAtRisk) : null,
|
||||
counterparty: diagnosisAnswers.counterparty,
|
||||
description: diagnosisAnswers.description,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (!response.ok || !payload.ok || !payload.diagnosis) {
|
||||
setErrorMessage(payload.error ?? "No fue posible registrar respuesta del diagnostico.");
|
||||
return;
|
||||
}
|
||||
|
||||
setDiagnosisRecommendation(payload.diagnosis.recommendation);
|
||||
if (finalize) {
|
||||
setMessage("Diagnostico finalizado y caso legal generado.");
|
||||
setDiagnosisStep(4);
|
||||
await reloadDataset();
|
||||
} else {
|
||||
setDiagnosisStep((prev) => Math.min(4, prev + 1));
|
||||
}
|
||||
} catch {
|
||||
setErrorMessage("No fue posible registrar respuesta del diagnostico.");
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function createCase() {
|
||||
setIsBusy(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/legal/cases", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
contractId: newCase.contractId || null,
|
||||
caseType: newCase.caseType,
|
||||
severity: newCase.severity,
|
||||
counterparty: newCase.counterparty,
|
||||
description: newCase.description,
|
||||
amountAtRisk: newCase.amountAtRisk ? Number(newCase.amountAtRisk) : null,
|
||||
status: newCase.status,
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (!response.ok || !payload.ok) {
|
||||
setErrorMessage(payload.error ?? "No fue posible crear el caso legal.");
|
||||
return;
|
||||
}
|
||||
|
||||
setMessage("Caso legal creado.");
|
||||
setNewCase({
|
||||
contractId: prefilledContractId ?? "",
|
||||
caseType: "CONTRACT_BREACH",
|
||||
severity: "MEDIUM",
|
||||
counterparty: "",
|
||||
description: "",
|
||||
amountAtRisk: "",
|
||||
status: "OPEN",
|
||||
});
|
||||
await reloadDataset();
|
||||
} catch {
|
||||
setErrorMessage("No fue posible crear el caso legal.");
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateCaseStatus(caseId: string, status: LegalCaseView["status"]) {
|
||||
setErrorMessage(null);
|
||||
|
||||
const resolvedAt = status === "RESOLVED" || status === "CLOSED" ? new Date().toISOString() : null;
|
||||
const response = await fetch(`/api/legal/cases/${encodeURIComponent(caseId)}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ status, resolvedAt }),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (!response.ok || !payload.ok) {
|
||||
setErrorMessage(payload.error ?? "No fue posible actualizar el caso.");
|
||||
return;
|
||||
}
|
||||
|
||||
setMessage("Caso actualizado.");
|
||||
await reloadDataset();
|
||||
}
|
||||
|
||||
async function loadEscalation(caseId: string) {
|
||||
if (!caseId) {
|
||||
setEscalation(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/legal/escalation/${encodeURIComponent(caseId)}`);
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (!response.ok || !payload.ok || !payload.escalation) {
|
||||
setErrorMessage(payload.error ?? "No fue posible cargar la ruta de escalada.");
|
||||
return;
|
||||
}
|
||||
|
||||
setEscalation(payload.escalation);
|
||||
}
|
||||
|
||||
async function toggleEscalationStep(caseId: string, stepKey: string, completed: boolean, notes: string) {
|
||||
const response = await fetch(`/api/legal/escalation/${encodeURIComponent(caseId)}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
routeStepKey: stepKey,
|
||||
completed,
|
||||
notes,
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (!response.ok || !payload.ok || !payload.escalation) {
|
||||
setErrorMessage(payload.error ?? "No fue posible actualizar la escalada.");
|
||||
return;
|
||||
}
|
||||
|
||||
setEscalation(payload.escalation);
|
||||
setMessage("Escalada actualizada.");
|
||||
}
|
||||
|
||||
async function generateDocument() {
|
||||
setIsBusy(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/legal/documents/generate", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
legalCaseId: documentDraft.legalCaseId || null,
|
||||
caseType: documentDraft.caseType,
|
||||
severity: documentDraft.severity,
|
||||
templateKey: documentDraft.templateKey || null,
|
||||
counterparty: documentDraft.counterparty,
|
||||
companyName: documentDraft.companyName,
|
||||
amountAtRisk: documentDraft.amountAtRisk ? Number(documentDraft.amountAtRisk) : null,
|
||||
description: documentDraft.description,
|
||||
objective: documentDraft.objective,
|
||||
title: documentDraft.title || null,
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (!response.ok || !payload.ok || !payload.document) {
|
||||
setErrorMessage(payload.error ?? "No fue posible generar el escrito.");
|
||||
return;
|
||||
}
|
||||
|
||||
setGeneratedDocuments((prev) => [payload.document as { id: string; title: string; content: string; createdAt: string }, ...prev]);
|
||||
const warningText = payload.generation?.warnings?.join(" | ");
|
||||
setMessage(warningText ? `Escrito generado. ${warningText}` : "Escrito generado.");
|
||||
} catch {
|
||||
setErrorMessage("No fue posible generar el escrito.");
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadDirectory() {
|
||||
const params = new URLSearchParams();
|
||||
if (directoryFilters.jurisdictionLevel) {
|
||||
params.set("jurisdictionLevel", directoryFilters.jurisdictionLevel);
|
||||
}
|
||||
if (directoryFilters.q.trim()) {
|
||||
params.set("q", directoryFilters.q.trim());
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/legal/directory${params.toString() ? `?${params.toString()}` : ""}`);
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (!response.ok || !payload.ok || !payload.entities) {
|
||||
setErrorMessage(payload.error ?? "No fue posible cargar el directorio.");
|
||||
return;
|
||||
}
|
||||
|
||||
setDirectory(payload.entities);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedEscalationCaseId) {
|
||||
void loadEscalation(selectedEscalationCaseId);
|
||||
}
|
||||
}, [selectedEscalationCaseId]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<section className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Casos abiertos</p>
|
||||
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{kpis.openCases}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Alta severidad</p>
|
||||
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{kpis.highSeverityCases}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Monto en riesgo</p>
|
||||
<p className="mt-2 text-3xl font-semibold text-[#15294d]">{kpis.amountAtRisk.toLocaleString("es-MX")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Casos resueltos</p>
|
||||
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{kpis.resolvedCases}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{errorMessage ? <p className="rounded-xl border border-[#efc4c4] bg-[#fff1f1] px-3 py-2 text-sm text-[#ad3f3f]">{errorMessage}</p> : null}
|
||||
{message ? <p className="rounded-xl border border-[#bde5ce] bg-[#ecf9f1] px-3 py-2 text-sm text-[#1f7f4f]">{message}</p> : null}
|
||||
|
||||
<Tabs
|
||||
defaultTab="diagnosis"
|
||||
items={[
|
||||
{
|
||||
id: "diagnosis",
|
||||
label: "Diagnostico",
|
||||
content: (
|
||||
<Card>
|
||||
<CardContent className="space-y-3 py-5">
|
||||
<p className="text-sm text-[#60718f]">Wizard de 4 pasos para sugerir severidad, tipo de caso y ruta de escalada.</p>
|
||||
<p className="text-xs font-semibold text-[#415a82]">Paso {diagnosisStep} de 4</p>
|
||||
<label className="space-y-1 text-xs font-semibold text-[#314764]">
|
||||
Contrato vinculado (opcional)
|
||||
<select
|
||||
value={diagnosisContractId}
|
||||
onChange={(event) => setDiagnosisContractId(event.target.value)}
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
|
||||
disabled={Boolean(diagnosisId)}
|
||||
>
|
||||
<option value="">Sin contrato</option>
|
||||
{contractOptions.map((contract) => (
|
||||
<option key={contract.id} value={contract.id}>
|
||||
{contract.isPrefilled ? `${contract.label} (prefill)` : contract.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<label className="space-y-1 text-xs font-semibold text-[#314764]">
|
||||
Tipo de incumplimiento
|
||||
<select
|
||||
value={diagnosisAnswers.breachType}
|
||||
onChange={(event) => setDiagnosisAnswers((prev) => ({ ...prev, breachType: event.target.value }))}
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
|
||||
>
|
||||
<option value="contract_breach">Incumplimiento contractual</option>
|
||||
<option value="payment_retention">Retencion de pagos</option>
|
||||
<option value="unjust_sanction">Sancion injusta</option>
|
||||
<option value="contract_dispute">Disputa contractual</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-xs font-semibold text-[#314764]">
|
||||
Estado procesal
|
||||
<select
|
||||
value={diagnosisAnswers.processStage}
|
||||
onChange={(event) => setDiagnosisAnswers((prev) => ({ ...prev, processStage: event.target.value }))}
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
|
||||
>
|
||||
<option value="none">Sin proceso</option>
|
||||
<option value="informal_notice">Aviso informal</option>
|
||||
<option value="formal_notice">Requerimiento formal</option>
|
||||
<option value="ongoing_claim">Reclamacion en curso</option>
|
||||
<option value="trial">Via jurisdiccional</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-xs font-semibold text-[#314764]">
|
||||
Evidencia disponible
|
||||
<select
|
||||
value={diagnosisAnswers.evidenceLevel}
|
||||
onChange={(event) => setDiagnosisAnswers((prev) => ({ ...prev, evidenceLevel: event.target.value }))}
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
|
||||
>
|
||||
<option value="none">Sin evidencia</option>
|
||||
<option value="basic">Basica</option>
|
||||
<option value="solid">Solida</option>
|
||||
<option value="robust">Robusta</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-xs font-semibold text-[#314764]">
|
||||
Urgencia
|
||||
<select
|
||||
value={diagnosisAnswers.urgency}
|
||||
onChange={(event) => setDiagnosisAnswers((prev) => ({ ...prev, urgency: event.target.value }))}
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
|
||||
>
|
||||
<option value="low">Baja</option>
|
||||
<option value="medium">Media</option>
|
||||
<option value="high">Alta</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-xs font-semibold text-[#314764]">
|
||||
Objetivo legal
|
||||
<select
|
||||
value={diagnosisAnswers.objective}
|
||||
onChange={(event) => setDiagnosisAnswers((prev) => ({ ...prev, objective: event.target.value }))}
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
|
||||
>
|
||||
<option value="payment">Recuperar pago</option>
|
||||
<option value="correction">Correccion</option>
|
||||
<option value="challenge">Impugnacion</option>
|
||||
<option value="mediation">Mediacion</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-xs font-semibold text-[#314764]">
|
||||
Monto en riesgo
|
||||
<input
|
||||
type="number"
|
||||
value={diagnosisAnswers.amountAtRisk}
|
||||
onChange={(event) => setDiagnosisAnswers((prev) => ({ ...prev, amountAtRisk: event.target.value }))}
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<input
|
||||
value={diagnosisAnswers.counterparty}
|
||||
onChange={(event) => setDiagnosisAnswers((prev) => ({ ...prev, counterparty: event.target.value }))}
|
||||
placeholder="Contraparte"
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
|
||||
/>
|
||||
|
||||
<textarea
|
||||
value={diagnosisAnswers.description}
|
||||
onChange={(event) => setDiagnosisAnswers((prev) => ({ ...prev, description: event.target.value }))}
|
||||
placeholder="Descripcion del caso"
|
||||
className="min-h-[110px] w-full rounded-lg border border-[#cfd8e7] px-3 py-2 text-sm"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" onClick={() => submitDiagnosisStep(false)} disabled={isBusy}>
|
||||
Guardar paso
|
||||
</Button>
|
||||
<Button onClick={() => submitDiagnosisStep(true)} disabled={isBusy}>
|
||||
Finalizar y abrir caso
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{diagnosisRecommendation ? (
|
||||
<div className="rounded-xl border border-[#ced9ec] bg-[#f4f8ff] p-3 text-sm text-[#2d4b7b]">
|
||||
<p>
|
||||
Sugerencia: {diagnosisRecommendation.suggestedCaseType} / {diagnosisRecommendation.suggestedSeverity}
|
||||
</p>
|
||||
<p className="mt-1">Ruta: {diagnosisRecommendation.suggestedRoute.map((item) => item.title).join(" -> ")}</p>
|
||||
<p className="mt-1">Escritos: {diagnosisRecommendation.recommendedTemplates.join(", ")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "cases",
|
||||
label: "Casos",
|
||||
content: (
|
||||
<div className="space-y-3">
|
||||
<Dialog triggerLabel="Nuevo Caso" title="Nuevo Caso" description="Registro manual de caso legal">
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
value={newCase.contractId}
|
||||
onChange={(event) => setNewCase((prev) => ({ ...prev, contractId: event.target.value }))}
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
|
||||
>
|
||||
<option value="">Sin contrato</option>
|
||||
{contractOptions.map((contract) => (
|
||||
<option key={contract.id} value={contract.id}>
|
||||
{contract.isPrefilled ? `${contract.label} (prefill)` : contract.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
value={newCase.counterparty}
|
||||
onChange={(event) => setNewCase((prev) => ({ ...prev, counterparty: event.target.value }))}
|
||||
placeholder="Contraparte"
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
|
||||
/>
|
||||
<textarea
|
||||
value={newCase.description}
|
||||
onChange={(event) => setNewCase((prev) => ({ ...prev, description: event.target.value }))}
|
||||
placeholder="Descripcion"
|
||||
className="min-h-[90px] w-full rounded-lg border border-[#cfd8e7] px-3 py-2 text-sm"
|
||||
/>
|
||||
<Button onClick={createCase} disabled={isBusy}>
|
||||
Guardar caso
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{cases.length === 0 ? <p className="text-sm text-[#60718f]">Sin casos registrados.</p> : null}
|
||||
|
||||
{cases.map((item) => (
|
||||
<article key={item.id} className="rounded-xl border border-[#d8e1ef] bg-white p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">{item.caseType}</p>
|
||||
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-semibold ${severityTone(item.severity)}`}>{item.severity}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[#60718f]">{item.counterparty}</p>
|
||||
<p className="mt-1 text-xs text-[#60718f]">{item.description}</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={item.status}
|
||||
onChange={(event) => updateCaseStatus(item.id, event.target.value as LegalCaseView["status"])}
|
||||
className="h-8 rounded-lg border border-[#cfd8e7] px-2 text-xs"
|
||||
>
|
||||
<option value="OPEN">OPEN</option>
|
||||
<option value="IN_PROGRESS">IN_PROGRESS</option>
|
||||
<option value="ESCALATED">ESCALATED</option>
|
||||
<option value="RESOLVED">RESOLVED</option>
|
||||
<option value="CLOSED">CLOSED</option>
|
||||
</select>
|
||||
<span className="text-xs text-[#60718f]">Abierto: {formatDate(item.openedAt)}</span>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "escalation",
|
||||
label: "Escalada",
|
||||
content: (
|
||||
<div className="space-y-3">
|
||||
<select
|
||||
value={selectedEscalationCaseId}
|
||||
onChange={(event) => setSelectedEscalationCaseId(event.target.value)}
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] bg-white px-3 text-sm"
|
||||
>
|
||||
<option value="">Selecciona caso</option>
|
||||
{cases.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.caseType} - {item.counterparty}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{currentEscalationCase && !escalation ? <Button variant="secondary" onClick={() => loadEscalation(currentEscalationCase.id)}>Cargar ruta</Button> : null}
|
||||
|
||||
{escalation?.steps.map((step) => (
|
||||
<article key={step.key} className="rounded-xl border border-[#d8e1ef] bg-white p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[#1d335a]">{step.title}</p>
|
||||
<p className="text-xs text-[#60718f]">Ventana estimada: {step.estimatedWindow}</p>
|
||||
{step.requiresLawyer ? <p className="text-xs font-semibold text-[#9b4f2c]">Requiere abogado</p> : null}
|
||||
</div>
|
||||
<label className="inline-flex items-center gap-2 text-xs font-semibold text-[#314764]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(step.completedAt)}
|
||||
disabled={step.isBlocked}
|
||||
onChange={(event) => {
|
||||
if (!selectedEscalationCaseId) {
|
||||
return;
|
||||
}
|
||||
void toggleEscalationStep(selectedEscalationCaseId, step.key, event.target.checked, step.notes ?? "");
|
||||
}}
|
||||
/>
|
||||
Completado
|
||||
</label>
|
||||
</div>
|
||||
<ul className="mt-2 list-disc pl-5 text-xs text-[#60718f]">
|
||||
{step.checklist.map((item) => (
|
||||
<li key={`${step.key}-${item}`}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "documents",
|
||||
label: "Escritos",
|
||||
content: (
|
||||
<div className="space-y-3">
|
||||
<Card>
|
||||
<CardContent className="space-y-2 py-5">
|
||||
<select
|
||||
value={documentDraft.legalCaseId}
|
||||
onChange={(event) => setDocumentDraft((prev) => ({ ...prev, legalCaseId: event.target.value }))}
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
|
||||
>
|
||||
<option value="">Sin caso vinculado</option>
|
||||
{cases.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.caseType} - {item.counterparty}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={documentDraft.templateKey}
|
||||
onChange={(event) => setDocumentDraft((prev) => ({ ...prev, templateKey: event.target.value }))}
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
|
||||
>
|
||||
<option value="">Plantilla automatica por tipo de caso</option>
|
||||
{templates.map((template) => (
|
||||
<option key={template.key} value={template.key}>
|
||||
{template.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input
|
||||
value={documentDraft.counterparty}
|
||||
onChange={(event) => setDocumentDraft((prev) => ({ ...prev, counterparty: event.target.value }))}
|
||||
placeholder="Contraparte"
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
|
||||
/>
|
||||
<input
|
||||
value={documentDraft.companyName}
|
||||
onChange={(event) => setDocumentDraft((prev) => ({ ...prev, companyName: event.target.value }))}
|
||||
placeholder="Empresa"
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
|
||||
/>
|
||||
<input
|
||||
value={documentDraft.objective}
|
||||
onChange={(event) => setDocumentDraft((prev) => ({ ...prev, objective: event.target.value }))}
|
||||
placeholder="Objetivo legal"
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
|
||||
/>
|
||||
<textarea
|
||||
value={documentDraft.description}
|
||||
onChange={(event) => setDocumentDraft((prev) => ({ ...prev, description: event.target.value }))}
|
||||
placeholder="Descripcion"
|
||||
className="min-h-[110px] w-full rounded-lg border border-[#cfd8e7] px-3 py-2 text-sm"
|
||||
/>
|
||||
|
||||
<Button onClick={generateDocument} disabled={isBusy}>
|
||||
Generar escrito
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{generatedDocuments.map((item) => (
|
||||
<article key={item.id} className="rounded-xl border border-[#d8e1ef] bg-white p-3">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">{item.title}</p>
|
||||
<p className="mt-1 text-xs text-[#60718f]">{formatDate(item.createdAt)}</p>
|
||||
<textarea readOnly value={item.content} className="mt-2 min-h-[160px] w-full rounded-lg border border-[#cfd8e7] p-2 text-xs" />
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "directory",
|
||||
label: "Directorio",
|
||||
content: (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<select
|
||||
value={directoryFilters.jurisdictionLevel}
|
||||
onChange={(event) => setDirectoryFilters((prev) => ({ ...prev, jurisdictionLevel: event.target.value }))}
|
||||
className="h-10 rounded-lg border border-[#cfd8e7] px-3 text-sm"
|
||||
>
|
||||
<option value="">Todas las jurisdicciones</option>
|
||||
<option value="FEDERAL">FEDERAL</option>
|
||||
<option value="STATE">STATE</option>
|
||||
<option value="MUNICIPAL">MUNICIPAL</option>
|
||||
</select>
|
||||
<input
|
||||
value={directoryFilters.q}
|
||||
onChange={(event) => setDirectoryFilters((prev) => ({ ...prev, q: event.target.value }))}
|
||||
placeholder="Buscar entidad"
|
||||
className="h-10 min-w-[260px] rounded-lg border border-[#cfd8e7] px-3 text-sm"
|
||||
/>
|
||||
<Button variant="secondary" onClick={reloadDirectory}>
|
||||
Filtrar
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{directory.length === 0 ? <p className="text-sm text-[#60718f]">Sin entidades en el filtro actual.</p> : null}
|
||||
|
||||
{directory.map((item) => (
|
||||
<article key={item.id} className="rounded-xl border border-[#d8e1ef] bg-white p-3">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">{item.name}</p>
|
||||
<p className="mt-1 text-xs text-[#60718f]">{item.jurisdictionLevel} - {item.scopeTags.join(", ")}</p>
|
||||
<p className="mt-1 text-xs text-[#60718f]">{item.websiteUrl ?? "Sin sitio web"}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
src/components/app/licitation-card-actions.tsx
Normal file
93
src/components/app/licitation-card-actions.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { LicitationReviewStatusView } from "@/lib/licitations/preferences";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type LicitationCardActionsProps = {
|
||||
licitationId: string;
|
||||
initialStatus: LicitationReviewStatusView;
|
||||
normativeAnalysisHref: string;
|
||||
};
|
||||
|
||||
type ApiPayload = {
|
||||
ok?: boolean;
|
||||
error?: string;
|
||||
status?: LicitationReviewStatusView;
|
||||
};
|
||||
|
||||
export function LicitationCardActions({ licitationId, initialStatus, normativeAnalysisHref }: LicitationCardActionsProps) {
|
||||
const router = useRouter();
|
||||
const [status, setStatus] = useState<LicitationReviewStatusView>(initialStatus);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
async function updateStatus(nextStatus: LicitationReviewStatusView, shouldRefresh = true) {
|
||||
setErrorMessage(null);
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/licitations/preferences/${encodeURIComponent(licitationId)}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ status: nextStatus }),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (!response.ok || !payload.ok || !payload.status) {
|
||||
setErrorMessage(payload.error ?? "No fue posible actualizar el estado.");
|
||||
return false;
|
||||
}
|
||||
|
||||
setStatus(payload.status);
|
||||
if (shouldRefresh) {
|
||||
router.refresh();
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
setErrorMessage("No fue posible actualizar el estado.");
|
||||
return false;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleReviewed() {
|
||||
const nextStatus = status === "REVIEWED" ? "NEW" : "REVIEWED";
|
||||
await updateStatus(nextStatus);
|
||||
}
|
||||
|
||||
async function handleInterested() {
|
||||
const ok = await updateStatus("INTERESTED", false);
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
window.location.href = normativeAnalysisHref;
|
||||
}
|
||||
|
||||
async function handleDiscardOrRestore() {
|
||||
const nextStatus = status === "DISCARDED" ? "NEW" : "DISCARDED";
|
||||
await updateStatus(nextStatus);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={handleToggleReviewed} disabled={isSaving}>
|
||||
{status === "REVIEWED" ? "Quitar consultada" : "Marcar consultada"}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleInterested} disabled={isSaving}>
|
||||
Me interesa
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={handleDiscardOrRestore} disabled={isSaving}>
|
||||
{status === "DISCARDED" ? "Restaurar" : "Descartar"}
|
||||
</Button>
|
||||
</div>
|
||||
{errorMessage ? <p className="text-xs font-semibold text-[#b93a4b]">{errorMessage}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,11 @@ export function LicitationsSyncButton() {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
body: JSON.stringify({
|
||||
includePnt: true,
|
||||
includeLicitaya: true,
|
||||
force: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as {
|
||||
@@ -25,6 +29,19 @@ export function LicitationsSyncButton() {
|
||||
error?: string;
|
||||
payload?: {
|
||||
processedMunicipalities?: number;
|
||||
processedScopes?: number;
|
||||
processedLicitayaBuckets?: number;
|
||||
results?: Array<{
|
||||
sourceResults?: Array<{
|
||||
status?: string;
|
||||
warnings?: string[];
|
||||
stats?: {
|
||||
totalFetched?: number;
|
||||
inserted?: number;
|
||||
updated?: number;
|
||||
};
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -33,8 +50,41 @@ export function LicitationsSyncButton() {
|
||||
return;
|
||||
}
|
||||
|
||||
const processed = payload.payload?.processedMunicipalities ?? 0;
|
||||
setMessage(`Sincronizacion completada. Municipios procesados: ${processed}.`);
|
||||
const processedMunicipalities = payload.payload?.processedMunicipalities ?? 0;
|
||||
const processedScopes = payload.payload?.processedScopes ?? processedMunicipalities;
|
||||
const licitayaBuckets = payload.payload?.processedLicitayaBuckets ?? 0;
|
||||
const sourceResults = payload.payload?.results?.flatMap((result) => result.sourceResults ?? []) ?? [];
|
||||
|
||||
const totals = sourceResults.reduce(
|
||||
(acc, sourceResult) => {
|
||||
acc.fetched += sourceResult.stats?.totalFetched ?? 0;
|
||||
acc.inserted += sourceResult.stats?.inserted ?? 0;
|
||||
acc.updated += sourceResult.stats?.updated ?? 0;
|
||||
acc.warnings += sourceResult.warnings?.length ?? 0;
|
||||
|
||||
if (sourceResult.status === "FAILED") {
|
||||
acc.failed += 1;
|
||||
} else if (sourceResult.status === "PARTIAL") {
|
||||
acc.partial += 1;
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
fetched: 0,
|
||||
inserted: 0,
|
||||
updated: 0,
|
||||
warnings: 0,
|
||||
failed: 0,
|
||||
partial: 0,
|
||||
},
|
||||
);
|
||||
|
||||
setMessage(
|
||||
`Sync listo. Municipios: ${processedMunicipalities}. Alcances: ${processedScopes}. LicitaYa: ${licitayaBuckets}. ` +
|
||||
`Detectados: ${totals.fetched}. Nuevos: ${totals.inserted}. Actualizados: ${totals.updated}. ` +
|
||||
`Parciales: ${totals.partial}. Fallidos: ${totals.failed}. Advertencias: ${totals.warnings}.`,
|
||||
);
|
||||
} catch {
|
||||
setMessage("No se pudo ejecutar la sincronizacion.");
|
||||
} finally {
|
||||
|
||||
@@ -43,6 +43,15 @@ type EvidenceDraft = {
|
||||
|
||||
type SaveState = "idle" | "saving" | "saved" | "error";
|
||||
|
||||
type AiQuestionSuggestion = {
|
||||
questionId: string;
|
||||
suggestedAnswerOptionId: string;
|
||||
rationale: string;
|
||||
missingEvidence: string[];
|
||||
confidence: number | null;
|
||||
suggestionId: string;
|
||||
};
|
||||
|
||||
function toEvidenceDraft(
|
||||
evidence: {
|
||||
notes: string;
|
||||
@@ -102,6 +111,11 @@ export function ModuleQuestionnaire({
|
||||
|
||||
const [saveStateByQuestionId, setSaveStateByQuestionId] = useState<Record<string, SaveState>>({});
|
||||
const [saveErrorByQuestionId, setSaveErrorByQuestionId] = useState<Record<string, string>>({});
|
||||
const [aiSuggestionsByQuestionId, setAiSuggestionsByQuestionId] = useState<Record<string, AiQuestionSuggestion>>({});
|
||||
const [isGeneratingAi, setIsGeneratingAi] = useState(false);
|
||||
const [aiMessage, setAiMessage] = useState<string | null>(null);
|
||||
const [aiError, setAiError] = useState<string | null>(null);
|
||||
const [actingSuggestionId, setActingSuggestionId] = useState<string | null>(null);
|
||||
|
||||
const answeredCount = useMemo(() => {
|
||||
return questions.reduce((total, question) => {
|
||||
@@ -150,12 +164,14 @@ export function ModuleQuestionnaire({
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => ({}))) as { error?: string };
|
||||
setQuestionSaveState(questionId, "error", payload.error ?? "No se pudo guardar la respuesta.");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
setQuestionSaveState(questionId, "saved");
|
||||
return true;
|
||||
} catch {
|
||||
setQuestionSaveState(questionId, "error", "No se pudo guardar la respuesta.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +207,118 @@ export function ModuleQuestionnaire({
|
||||
void persistQuestionResponse(questionId, answerOptionId, draft);
|
||||
}
|
||||
|
||||
async function setSuggestionDecision(suggestionId: string, decision: "accept" | "dismiss") {
|
||||
const response = await fetch(`/api/ai/suggestions/${encodeURIComponent(suggestionId)}/decision`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ decision }),
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
}
|
||||
|
||||
async function generateAiSuggestions() {
|
||||
setIsGeneratingAi(true);
|
||||
setAiError(null);
|
||||
setAiMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/diagnostic/ai/suggestions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
moduleKey,
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as {
|
||||
ok?: boolean;
|
||||
error?: string;
|
||||
suggestions?: AiQuestionSuggestion[];
|
||||
};
|
||||
|
||||
if (!response.ok || !payload.ok || !payload.suggestions) {
|
||||
setAiError(payload.error ?? "No fue posible generar sugerencias IA.");
|
||||
return;
|
||||
}
|
||||
|
||||
const byQuestionId = payload.suggestions.reduce<Record<string, AiQuestionSuggestion>>((accumulator, suggestion) => {
|
||||
accumulator[suggestion.questionId] = suggestion;
|
||||
return accumulator;
|
||||
}, {});
|
||||
|
||||
setAiSuggestionsByQuestionId(byQuestionId);
|
||||
setAiMessage(payload.suggestions.length ? "Sugerencias IA actualizadas." : "No hay preguntas pendientes para sugerir.");
|
||||
} catch {
|
||||
setAiError("No fue posible generar sugerencias IA.");
|
||||
} finally {
|
||||
setIsGeneratingAi(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyAiSuggestion(questionId: string) {
|
||||
const suggestion = aiSuggestionsByQuestionId[questionId];
|
||||
if (!suggestion) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActingSuggestionId(suggestion.suggestionId);
|
||||
setAiError(null);
|
||||
setAiMessage(null);
|
||||
|
||||
try {
|
||||
setAnswers((previous) => ({
|
||||
...previous,
|
||||
[questionId]: suggestion.suggestedAnswerOptionId,
|
||||
}));
|
||||
|
||||
const draft = evidenceByQuestionId[questionId] ?? { notes: "", links: "" };
|
||||
const persisted = await persistQuestionResponse(questionId, suggestion.suggestedAnswerOptionId, draft);
|
||||
|
||||
if (!persisted) {
|
||||
setAiError("La sugerencia se aplico localmente, pero no se pudo guardar en el servidor.");
|
||||
return;
|
||||
}
|
||||
|
||||
await setSuggestionDecision(suggestion.suggestionId, "accept");
|
||||
setAiSuggestionsByQuestionId((previous) => {
|
||||
const next = { ...previous };
|
||||
delete next[questionId];
|
||||
return next;
|
||||
});
|
||||
setAiMessage("Sugerencia IA aplicada.");
|
||||
} finally {
|
||||
setActingSuggestionId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function dismissAiSuggestion(questionId: string) {
|
||||
const suggestion = aiSuggestionsByQuestionId[questionId];
|
||||
if (!suggestion) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActingSuggestionId(suggestion.suggestionId);
|
||||
setAiError(null);
|
||||
setAiMessage(null);
|
||||
|
||||
try {
|
||||
await setSuggestionDecision(suggestion.suggestionId, "dismiss");
|
||||
setAiSuggestionsByQuestionId((previous) => {
|
||||
const next = { ...previous };
|
||||
delete next[questionId];
|
||||
return next;
|
||||
});
|
||||
setAiMessage("Sugerencia IA descartada.");
|
||||
} finally {
|
||||
setActingSuggestionId(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (questions.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
@@ -231,6 +359,13 @@ export function ModuleQuestionnaire({
|
||||
</p>
|
||||
</div>
|
||||
<Progress value={completion} showValue />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" size="sm" variant="secondary" disabled={isGeneratingAi} onClick={() => void generateAiSuggestions()}>
|
||||
{isGeneratingAi ? "Generando sugerencias IA..." : "Generar sugerencias IA"}
|
||||
</Button>
|
||||
{aiMessage ? <p className="text-sm text-[#1f7c4d]">{aiMessage}</p> : null}
|
||||
{aiError ? <p className="text-sm text-[#b63d3d]">{aiError}</p> : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -241,6 +376,9 @@ export function ModuleQuestionnaire({
|
||||
const evidenceDraft = evidenceByQuestionId[question.id] ?? { notes: "", links: "" };
|
||||
const saveState = saveStateByQuestionId[question.id] ?? "idle";
|
||||
const saveError = saveErrorByQuestionId[question.id] ?? null;
|
||||
const aiSuggestion = aiSuggestionsByQuestionId[question.id] ?? null;
|
||||
const suggestedOptionLabel =
|
||||
question.options.find((option) => option.id === aiSuggestion?.suggestedAnswerOptionId)?.label ?? "Opcion sugerida";
|
||||
|
||||
return (
|
||||
<div key={question.id} className="rounded-xl border border-[#d9e2f0] p-4">
|
||||
@@ -315,6 +453,41 @@ export function ModuleQuestionnaire({
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{aiSuggestion ? (
|
||||
<div className="mt-4 rounded-lg border border-[#d7e2f4] bg-[#f6f9ff] p-3">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">Sugerencia IA</p>
|
||||
<p className="mt-1 text-sm text-[#405578]">
|
||||
Opcion sugerida: <span className="font-semibold">{suggestedOptionLabel}</span>
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[#506688]">{aiSuggestion.rationale}</p>
|
||||
{aiSuggestion.missingEvidence.length ? (
|
||||
<p className="mt-1 text-xs text-[#60718f]">Evidencia faltante: {aiSuggestion.missingEvidence.join(" | ")}</p>
|
||||
) : null}
|
||||
{typeof aiSuggestion.confidence === "number" ? (
|
||||
<p className="mt-1 text-xs font-semibold text-[#334d77]">Confianza: {Math.round(aiSuggestion.confidence * 100)}%</p>
|
||||
) : null}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={actingSuggestionId === aiSuggestion.suggestionId}
|
||||
onClick={() => void applyAiSuggestion(question.id)}
|
||||
>
|
||||
Aplicar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={actingSuggestionId === aiSuggestion.suggestionId}
|
||||
onClick={() => void dismissAiSuggestion(question.id)}
|
||||
>
|
||||
Descartar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
1132
src/components/app/normative-analysis-view.tsx
Normal file
1132
src/components/app/normative-analysis-view.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import { isAdminIdentity } from "@/lib/auth/admin";
|
||||
import { KontiaMark } from "@/components/app/kontia-mark";
|
||||
import { getCurrentUser } from "@/lib/auth/user";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getHeaderVisibility, type HeaderModeRequest } from "@/lib/layout/header";
|
||||
|
||||
type PageShellProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -10,98 +11,139 @@ type PageShellProps = {
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
className?: string;
|
||||
showPageHeading?: boolean;
|
||||
headerMode?: HeaderModeRequest;
|
||||
headerBackHref?: string;
|
||||
headerBackLabel?: string;
|
||||
headerNextHref?: string;
|
||||
headerNextLabel?: string;
|
||||
headerShowManual?: boolean;
|
||||
headerPlanBadgeLabel?: string;
|
||||
headerAction?: React.ReactNode;
|
||||
headerShowLogout?: boolean;
|
||||
contentWidth?: "default" | "wide";
|
||||
};
|
||||
|
||||
const navLinks = [
|
||||
{ href: "/dashboard", label: "Dashboard" },
|
||||
{ href: "/diagnostic", label: "Diagnostico" },
|
||||
{ href: "/dashboard#modulos", label: "Modulos" },
|
||||
{ href: "/talleres-desarrollo", label: "Talleres" },
|
||||
{ href: "/licitations", label: "Licitaciones" },
|
||||
{ href: "/results", label: "Results" },
|
||||
{ href: "/recommendations", label: "Recommendations" },
|
||||
{ href: "/manual", label: "Manual" },
|
||||
];
|
||||
function HeaderBadge({ label }: { label: string }) {
|
||||
return <span className="rounded-full bg-[#0f2a5f] px-4 py-1.5 text-sm font-semibold text-white">{label}</span>;
|
||||
}
|
||||
|
||||
export async function PageShell({ children, title, description, action, className }: PageShellProps) {
|
||||
export async function PageShell({
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
showPageHeading = true,
|
||||
headerMode = "auto",
|
||||
headerBackHref = "/dashboard",
|
||||
headerBackLabel = "Volver al Dashboard",
|
||||
headerNextHref,
|
||||
headerNextLabel,
|
||||
headerShowManual = false,
|
||||
headerPlanBadgeLabel,
|
||||
headerAction,
|
||||
headerShowLogout = true,
|
||||
contentWidth = "default",
|
||||
}: PageShellProps) {
|
||||
const currentUser = await getCurrentUser();
|
||||
const isAdmin = currentUser ? isAdminIdentity(currentUser.email, currentUser.role) : false;
|
||||
const visibility = getHeaderVisibility(Boolean(currentUser), headerMode);
|
||||
const containerClassName = contentWidth === "wide" ? "max-w-[1560px]" : "max-w-[1200px]";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#eff2f7]">
|
||||
<header className="border-b border-[#d8dde7] bg-[#eff2f7]">
|
||||
<div className="mx-auto flex w-full max-w-[1200px] items-center justify-between gap-3 px-4 py-3 sm:px-6 lg:px-8">
|
||||
<Link href="/">
|
||||
<KontiaMark />
|
||||
</Link>
|
||||
<div className={cn("mx-auto flex w-full items-center justify-between gap-3 px-4 py-4 sm:px-6 lg:px-8", containerClassName)}>
|
||||
{visibility.showBackLink ? (
|
||||
<Link href={headerBackHref} className="inline-flex items-center gap-3 text-3xl font-semibold text-[#1a2c4f] [font-family:var(--font-display)]">
|
||||
<span className="text-2xl" aria-hidden>
|
||||
←
|
||||
</span>
|
||||
<span className="text-base font-semibold [font-family:var(--font-sans)]">{headerBackLabel}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<Link href="/">
|
||||
<KontiaMark />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<nav className="hidden items-center gap-5 md:flex">
|
||||
{currentUser ? (
|
||||
navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="text-sm font-medium text-[#516180] transition-colors hover:text-[#223f7c]"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<Link href="/#metodologia" className="text-sm font-medium text-[#516180] transition-colors hover:text-[#223f7c]">
|
||||
Metodologia
|
||||
</Link>
|
||||
<Link href="/#modulos" className="text-sm font-medium text-[#516180] transition-colors hover:text-[#223f7c]">
|
||||
Modulos
|
||||
</Link>
|
||||
<Link href="/#beneficios" className="text-sm font-medium text-[#516180] transition-colors hover:text-[#223f7c]">
|
||||
Beneficios
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{isAdmin && currentUser ? (
|
||||
<Link href="/admin" className="text-sm font-semibold text-[#1f3f82] transition-colors hover:text-[#17336c]">
|
||||
{visibility.showMarketingNav ? (
|
||||
<nav className="hidden items-center gap-10 text-base font-medium text-[#4f5f7d] lg:flex">
|
||||
<Link href="/#metodologia" className="transition-colors hover:text-[#223f7c]">
|
||||
Metodologia
|
||||
</Link>
|
||||
<Link href="/#modulos" className="transition-colors hover:text-[#223f7c]">
|
||||
Modulos
|
||||
</Link>
|
||||
<Link href="/#beneficios" className="transition-colors hover:text-[#223f7c]">
|
||||
Beneficios
|
||||
</Link>
|
||||
<Link href="/#contacto" className="transition-colors hover:text-[#223f7c]">
|
||||
Contacto
|
||||
</Link>
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
{visibility.showUserControls && headerShowManual ? (
|
||||
<Link href="/manual" className="inline-flex h-11 items-center gap-2 rounded-2xl border border-[#cfd8e7] bg-[#f7f9fd] px-5 text-sm font-semibold text-[#1a2d51] transition hover:bg-white">
|
||||
<span aria-hidden>◧</span>
|
||||
Manual
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{visibility.showUserControls && headerNextHref && headerNextLabel ? (
|
||||
<Link href={headerNextHref} className="inline-flex items-center gap-2 text-sm font-semibold text-[#445d86] hover:text-[#1f3f84]">
|
||||
{headerNextLabel}
|
||||
<span aria-hidden>→</span>
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{visibility.showUserControls && headerAction ? headerAction : null}
|
||||
{visibility.showUserControls && headerPlanBadgeLabel ? <HeaderBadge label={headerPlanBadgeLabel} /> : null}
|
||||
|
||||
{isAdmin && currentUser && visibility.mode === "app" ? (
|
||||
<Link href="/admin" className="rounded-xl border border-[#cfd8e7] px-3 py-2 text-sm font-semibold text-[#2f486f] transition-colors hover:bg-white">
|
||||
Admin
|
||||
</Link>
|
||||
) : null}
|
||||
</nav>
|
||||
|
||||
{currentUser ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="hidden text-xs font-semibold text-[#5f6c84] sm:block">{currentUser.email}</p>
|
||||
{visibility.showUserControls && headerShowLogout ? (
|
||||
<form action="/api/auth/logout" method="post">
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-xl border border-[#ccd6e5] px-3 py-1.5 text-xs font-semibold text-[#334a73] transition-colors hover:bg-white"
|
||||
className="inline-flex h-11 items-center rounded-2xl border border-[#ccd6e5] px-4 text-sm font-semibold text-[#334a73] transition-colors hover:bg-white"
|
||||
>
|
||||
Logout
|
||||
Salir
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-[#5f6c84]">
|
||||
<Link href="/login" className="rounded-xl px-3 py-2 text-sm font-semibold text-[#13254a] transition-colors hover:text-[#214485]">
|
||||
Iniciar Sesion
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="rounded-2xl bg-[#1f3f84] px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-[#17356f]"
|
||||
>
|
||||
Comenzar Gratis
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{visibility.showAuthButtons ? (
|
||||
<>
|
||||
<Link href="/login" className="rounded-xl px-3 py-2 text-sm font-semibold text-[#13254a] transition-colors hover:text-[#214485]">
|
||||
Iniciar Sesion
|
||||
</Link>
|
||||
<Link href="/register" className="rounded-2xl bg-[#1f3f84] px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-[#17356f]">
|
||||
Comenzar Gratis
|
||||
</Link>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto w-full max-w-[1200px] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<section className="mb-6 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold text-[#142447] [font-family:var(--font-display)] md:text-5xl">{title}</h1>
|
||||
{description ? <p className="mt-1 text-sm text-[#60718f]">{description}</p> : null}
|
||||
</div>
|
||||
{action ? <div>{action}</div> : null}
|
||||
</section>
|
||||
<main className={cn("mx-auto w-full px-4 py-8 sm:px-6 lg:px-8", containerClassName)}>
|
||||
{showPageHeading ? (
|
||||
<section className="mb-6 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold text-[#142447] [font-family:var(--font-display)] md:text-4xl">{title}</h1>
|
||||
{description ? <p className="mt-1 text-sm text-[#60718f]">{description}</p> : null}
|
||||
</div>
|
||||
{action ? <div>{action}</div> : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className={cn("space-y-4", className)}>{children}</section>
|
||||
</main>
|
||||
|
||||
779
src/components/app/preventive-dossier-view.tsx
Normal file
779
src/components/app/preventive-dossier-view.tsx
Normal file
@@ -0,0 +1,779 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { AUDIT_QUESTIONNAIRE } from "@/lib/audits/scoring";
|
||||
import type {
|
||||
AuditKpiSnapshot,
|
||||
AuditSimulationView,
|
||||
InstitutionalDossierFreshness,
|
||||
InstitutionalDossierLoadStrategy,
|
||||
InstitutionalDossierPayload,
|
||||
} from "@/lib/audits/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
|
||||
type PreventiveDossierViewProps = {
|
||||
initialSimulations: AuditSimulationView[];
|
||||
initialKpis: AuditKpiSnapshot;
|
||||
initialDossier: InstitutionalDossierPayload | null;
|
||||
initialFreshness: InstitutionalDossierFreshness | null;
|
||||
};
|
||||
|
||||
type ApiPayload = {
|
||||
ok?: boolean;
|
||||
error?: string;
|
||||
simulations?: AuditSimulationView[];
|
||||
simulation?: AuditSimulationView;
|
||||
kpis?: AuditKpiSnapshot;
|
||||
dossier?: InstitutionalDossierPayload;
|
||||
freshness?: InstitutionalDossierFreshness;
|
||||
};
|
||||
|
||||
type AuditAiFindings = {
|
||||
auditorLikelyFindings: {
|
||||
area: string;
|
||||
finding: string;
|
||||
severity: "alto" | "medio" | "bajo";
|
||||
}[];
|
||||
missingEvidence: string[];
|
||||
topRisks: string[];
|
||||
remediationPlan: {
|
||||
step: string;
|
||||
priority: "alta" | "media" | "baja";
|
||||
ownerSuggestion: string;
|
||||
targetDate: string;
|
||||
}[];
|
||||
confidence: "low" | "medium" | "high";
|
||||
};
|
||||
|
||||
function formatDate(value: string | null) {
|
||||
if (!value) {
|
||||
return "Sin fecha";
|
||||
}
|
||||
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return "Sin fecha";
|
||||
}
|
||||
|
||||
return parsed.toLocaleString("es-MX", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number) {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||||
return "N/D";
|
||||
}
|
||||
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function PreventiveDossierView({ initialSimulations, initialKpis, initialDossier, initialFreshness }: PreventiveDossierViewProps) {
|
||||
const [simulations, setSimulations] = useState(initialSimulations);
|
||||
const [kpis, setKpis] = useState(initialKpis);
|
||||
const [dossier, setDossier] = useState(initialDossier);
|
||||
const [freshness, setFreshness] = useState<InstitutionalDossierFreshness | null>(initialFreshness);
|
||||
const [selectedSimulationId, setSelectedSimulationId] = useState(initialSimulations[0]?.id ?? "");
|
||||
|
||||
const [newSimulation, setNewSimulation] = useState({
|
||||
name: "",
|
||||
auditType: "Preventiva integral",
|
||||
});
|
||||
|
||||
const [answers, setAnswers] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(AUDIT_QUESTIONNAIRE.map((question) => [question.key, "si"])),
|
||||
);
|
||||
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isLoadingAiFindings, setIsLoadingAiFindings] = useState(false);
|
||||
const [aiFindings, setAiFindings] = useState<AuditAiFindings | null>(null);
|
||||
const [aiSuggestionId, setAiSuggestionId] = useState<string | null>(null);
|
||||
|
||||
const continuityWarnings = useMemo(() => {
|
||||
if (!dossier) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const missingComponents = dossier.components
|
||||
.filter((component) => component.totalItems > 0 && component.withEvidence < component.totalItems)
|
||||
.map((component) => `Continuidad incompleta en ${component.label}: ${component.withEvidence}/${component.totalItems} con evidencia.`);
|
||||
|
||||
return missingComponents;
|
||||
}, [dossier]);
|
||||
|
||||
const selectedSimulation = useMemo(
|
||||
() => simulations.find((item) => item.id === selectedSimulationId) ?? null,
|
||||
[simulations, selectedSimulationId],
|
||||
);
|
||||
|
||||
async function reloadSimulations() {
|
||||
const response = await fetch("/api/audits/simulations");
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (!response.ok || !payload.ok || !payload.simulations || !payload.kpis) {
|
||||
throw new Error(payload.error ?? "No fue posible recargar simulaciones.");
|
||||
}
|
||||
|
||||
setSimulations(payload.simulations);
|
||||
setKpis(payload.kpis);
|
||||
|
||||
if (!selectedSimulationId && payload.simulations[0]?.id) {
|
||||
setSelectedSimulationId(payload.simulations[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
async function createSimulation() {
|
||||
setIsBusy(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/audits/simulations", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(newSimulation),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (!response.ok || !payload.ok || !payload.simulation) {
|
||||
setErrorMessage(payload.error ?? "No fue posible crear la simulacion.");
|
||||
return;
|
||||
}
|
||||
|
||||
setNewSimulation({
|
||||
name: "",
|
||||
auditType: "Preventiva integral",
|
||||
});
|
||||
setSelectedSimulationId(payload.simulation.id);
|
||||
setMessage("Simulacion creada.");
|
||||
await reloadSimulations();
|
||||
} catch {
|
||||
setErrorMessage("No fue posible crear la simulacion.");
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function scoreSimulation() {
|
||||
if (!selectedSimulationId) {
|
||||
setErrorMessage("Selecciona una simulacion para calificar.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBusy(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/audits/simulations/${encodeURIComponent(selectedSimulationId)}/score`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
responses: AUDIT_QUESTIONNAIRE.map((question) => ({
|
||||
questionKey: question.key,
|
||||
answer: answers[question.key] ?? "si",
|
||||
evidenceRefs: [],
|
||||
})),
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (!response.ok || !payload.ok || !payload.simulation) {
|
||||
setErrorMessage(payload.error ?? "No fue posible calcular la simulacion.");
|
||||
return;
|
||||
}
|
||||
|
||||
setMessage("Simulacion calificada.");
|
||||
await reloadSimulations();
|
||||
} catch {
|
||||
setErrorMessage("No fue posible calcular la simulacion.");
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDossier(strategy: InstitutionalDossierLoadStrategy) {
|
||||
setIsBusy(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/audits/expediente?strategy=${encodeURIComponent(strategy)}`);
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (!response.ok || !payload.ok || !payload.dossier || !payload.freshness) {
|
||||
setErrorMessage(payload.error ?? "No fue posible cargar el expediente.");
|
||||
return;
|
||||
}
|
||||
|
||||
setDossier(payload.dossier);
|
||||
setFreshness(payload.freshness);
|
||||
setMessage(
|
||||
payload.freshness.source === "snapshot"
|
||||
? "Expediente cargado desde snapshot."
|
||||
: "Expediente cargado en modo vivo.",
|
||||
);
|
||||
} catch {
|
||||
setErrorMessage("No fue posible cargar el expediente.");
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDossier() {
|
||||
setIsBusy(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/audits/expediente/refresh", {
|
||||
method: "POST",
|
||||
});
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (!response.ok || !payload.ok || !payload.dossier) {
|
||||
setErrorMessage(payload.error ?? "No fue posible refrescar el expediente.");
|
||||
return;
|
||||
}
|
||||
|
||||
setDossier(payload.dossier);
|
||||
setFreshness(payload.freshness ?? null);
|
||||
setMessage("Snapshot del expediente institucional actualizado.");
|
||||
await reloadSimulations();
|
||||
} catch {
|
||||
setErrorMessage("No fue posible refrescar el expediente.");
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function setSuggestionDecision(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 generateAiFindings() {
|
||||
setIsLoadingAiFindings(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/audits/ai/findings", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
simulationId: selectedSimulationId || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as {
|
||||
ok?: boolean;
|
||||
error?: string;
|
||||
auditorLikelyFindings?: AuditAiFindings["auditorLikelyFindings"];
|
||||
missingEvidence?: AuditAiFindings["missingEvidence"];
|
||||
topRisks?: AuditAiFindings["topRisks"];
|
||||
remediationPlan?: AuditAiFindings["remediationPlan"];
|
||||
confidence?: AuditAiFindings["confidence"];
|
||||
suggestionId?: string;
|
||||
};
|
||||
|
||||
if (!response.ok || !payload.ok) {
|
||||
setErrorMessage(payload.error ?? "No fue posible generar dictamen IA.");
|
||||
return;
|
||||
}
|
||||
|
||||
setAiFindings({
|
||||
auditorLikelyFindings: payload.auditorLikelyFindings ?? [],
|
||||
missingEvidence: payload.missingEvidence ?? [],
|
||||
topRisks: payload.topRisks ?? [],
|
||||
remediationPlan: payload.remediationPlan ?? [],
|
||||
confidence: payload.confidence ?? "low",
|
||||
});
|
||||
setAiSuggestionId(payload.suggestionId ?? null);
|
||||
setMessage("Dictamen IA generado.");
|
||||
} catch {
|
||||
setErrorMessage("No fue posible generar dictamen IA.");
|
||||
} finally {
|
||||
setIsLoadingAiFindings(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<section className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Auditorias completadas</p>
|
||||
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{kpis.completedAudits}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Ultima calificacion</p>
|
||||
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{kpis.lastScore ?? 0}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Contratos registrados</p>
|
||||
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{kpis.registeredContracts}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">PDFs resguardados</p>
|
||||
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{kpis.securedPdfs}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{errorMessage ? <p className="rounded-xl border border-[#efc4c4] bg-[#fff1f1] px-3 py-2 text-sm text-[#ad3f3f]">{errorMessage}</p> : null}
|
||||
{message ? <p className="rounded-xl border border-[#bde5ce] bg-[#ecf9f1] px-3 py-2 text-sm text-[#1f7f4f]">{message}</p> : null}
|
||||
|
||||
<Tabs
|
||||
defaultTab="simulator"
|
||||
items={[
|
||||
{
|
||||
id: "simulator",
|
||||
label: "Simulador",
|
||||
content: (
|
||||
<div className="space-y-3">
|
||||
<Dialog triggerLabel="Nueva Simulacion" title="Nueva Simulacion" description="Configura simulacion de auditoria preventiva">
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
value={newSimulation.name}
|
||||
onChange={(event) => setNewSimulation((prev) => ({ ...prev, name: event.target.value }))}
|
||||
placeholder="Nombre"
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3"
|
||||
/>
|
||||
<input
|
||||
value={newSimulation.auditType}
|
||||
onChange={(event) => setNewSimulation((prev) => ({ ...prev, auditType: event.target.value }))}
|
||||
placeholder="Tipo de auditoria"
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3"
|
||||
/>
|
||||
<Button onClick={createSimulation} disabled={isBusy}>
|
||||
Guardar simulacion
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<select
|
||||
value={selectedSimulationId}
|
||||
onChange={(event) => setSelectedSimulationId(event.target.value)}
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] bg-white px-3 text-sm"
|
||||
>
|
||||
<option value="">Selecciona simulacion</option>
|
||||
{simulations.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name} ({item.status})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Card>
|
||||
<CardContent className="space-y-2 py-5">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">Cuestionario de scoring</p>
|
||||
{AUDIT_QUESTIONNAIRE.map((question) => (
|
||||
<label key={question.key} className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-[#dbe3f1] px-3 py-2 text-sm">
|
||||
<span className="text-[#2e466e]">{question.prompt}</span>
|
||||
<select
|
||||
value={answers[question.key] ?? "si"}
|
||||
onChange={(event) =>
|
||||
setAnswers((prev) => ({
|
||||
...prev,
|
||||
[question.key]: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="h-8 rounded-lg border border-[#cfd8e7] bg-white px-2 text-xs"
|
||||
>
|
||||
<option value="si">Si</option>
|
||||
<option value="parcial">Parcial</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
</label>
|
||||
))}
|
||||
|
||||
<Button onClick={scoreSimulation} disabled={isBusy || !selectedSimulationId}>
|
||||
Calificar simulacion
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{selectedSimulation ? (
|
||||
<Card>
|
||||
<CardContent className="space-y-2 py-5">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">Resultado actual</p>
|
||||
<p className="text-sm text-[#60718f]">Status: {selectedSimulation.status}</p>
|
||||
<p className="text-sm text-[#60718f]">Score: {selectedSimulation.overallScore ?? 0}</p>
|
||||
<p className="text-sm text-[#60718f]">Completada: {formatDate(selectedSimulation.completedAt)}</p>
|
||||
{selectedSimulation.sections.map((section) => (
|
||||
<article key={section.id} className="rounded-lg border border-[#dbe3f1] bg-white p-2 text-xs text-[#506180]">
|
||||
<p className="font-semibold">{section.key}</p>
|
||||
<p>Status: {section.status}</p>
|
||||
<p>Score: {section.score ?? 0}</p>
|
||||
</article>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "dossier",
|
||||
label: "Expediente",
|
||||
content: (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" onClick={() => void loadDossier("live")} disabled={isBusy}>
|
||||
Cargar modo vivo (default)
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => void loadDossier("snapshot")} disabled={isBusy}>
|
||||
Cargar ultimo snapshot
|
||||
</Button>
|
||||
<Button onClick={refreshDossier} disabled={isBusy}>
|
||||
Generar nuevo snapshot
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{freshness ? (
|
||||
<Card>
|
||||
<CardContent className="space-y-2 py-5 text-sm text-[#385073]">
|
||||
<p>
|
||||
Estrategia solicitada: <strong>{freshness.strategy}</strong> | Fuente actual: <strong>{freshness.source}</strong>
|
||||
</p>
|
||||
<p>Generado: {formatDate(freshness.generatedAt)}</p>
|
||||
<p>
|
||||
Estado de frescura:{" "}
|
||||
<span className={freshness.isStale ? "font-semibold text-[#ad3f3f]" : "font-semibold text-[#1f7f4f]"}>
|
||||
{freshness.isStale ? "Desactualizado" : "Vigente"}
|
||||
</span>{" "}
|
||||
(umbral: {freshness.staleAfterMinutes} min)
|
||||
</p>
|
||||
{freshness.snapshotId ? <p>Snapshot ID: {freshness.snapshotId}</p> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
{continuityWarnings.length > 0 ? (
|
||||
<Card>
|
||||
<CardContent className="space-y-2 py-5">
|
||||
<p className="text-sm font-semibold text-[#8d6308]">Advertencias de continuidad</p>
|
||||
{continuityWarnings.map((warning) => (
|
||||
<p key={warning} className="rounded-lg border border-[#f0deb0] bg-[#fff8e9] px-2 py-1 text-xs text-[#8d6308]">
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{!dossier ? <p className="text-sm text-[#60718f]">Sin expediente generado todavia.</p> : null}
|
||||
|
||||
{dossier ? (
|
||||
<>
|
||||
<Card>
|
||||
<CardContent className="grid gap-2 py-5 text-sm text-[#385073] md:grid-cols-3 xl:grid-cols-4">
|
||||
<p>Contratos: {dossier.summary.contracts}</p>
|
||||
<p>PDF contratos: {dossier.summary.contractDocuments}</p>
|
||||
<p>Propuestas: {dossier.summary.proposals}</p>
|
||||
<p>PDF propuestas: {dossier.summary.proposalDocuments}</p>
|
||||
<p>Casos legales: {dossier.summary.legalCases}</p>
|
||||
<p>Escritos legales: {dossier.summary.legalDocuments}</p>
|
||||
<p>Alertas alto riesgo: {dossier.summary.highRiskAlerts}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{dossier.components.map((component) => (
|
||||
<article key={component.key} className="rounded-xl border border-[#d8e1ef] bg-white p-3 text-sm">
|
||||
<p className="font-semibold text-[#1d335a]">{component.label}</p>
|
||||
<p className="mt-1 text-[#60718f]">Estado: {component.status}</p>
|
||||
<p className="mt-1 text-[#60718f]">
|
||||
Evidencia: {component.withEvidence}/{component.totalItems}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="space-y-2 py-5">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">Propuestas en expediente (M5)</p>
|
||||
{dossier.proposals.length === 0 ? <p className="text-sm text-[#60718f]">Sin propuestas.</p> : null}
|
||||
{dossier.proposals.map((item) => (
|
||||
<article key={item.id} className="rounded-lg border border-[#dbe3f1] bg-white p-2 text-xs text-[#506180]">
|
||||
<p className="font-semibold">{item.title}</p>
|
||||
<p>
|
||||
{item.status} | Documentos {item.documents}
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{item.links.map((link) => (
|
||||
<Link key={`${item.id}-${link.href}`} href={link.href} className="text-[#2b5fab] underline">
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{item.documentEntries.length > 0 ? (
|
||||
<details className="mt-2 rounded-md border border-[#e4ebf6] p-2">
|
||||
<summary className="cursor-pointer font-semibold">Ver documentos y trazabilidad ({item.documentEntries.length})</summary>
|
||||
<div className="mt-2 space-y-2">
|
||||
{item.documentEntries.map((document) => (
|
||||
<article key={document.id} className="rounded-md border border-[#edf2fa] bg-[#f9fbff] p-2">
|
||||
<p className="font-semibold">{document.fileName}</p>
|
||||
<p className="mt-1 inline-flex rounded-full border border-[#c9d9f0] bg-white px-2 py-0.5 text-[11px] font-semibold text-[#315b96]">
|
||||
Por que este archivo
|
||||
</p>
|
||||
<p>
|
||||
{document.mimeType} | {formatFileSize(document.sizeBytes)} | {formatDate(document.createdAt)}
|
||||
</p>
|
||||
<p className="mt-1">{document.traceLabel}</p>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{document.links.map((link) => (
|
||||
<Link key={`${document.id}-${link.href}`} href={link.href} className="text-[#2b5fab] underline">
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="space-y-2 py-5">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">Contratos en expediente (M8)</p>
|
||||
{dossier.contracts.length === 0 ? <p className="text-sm text-[#60718f]">Sin contratos.</p> : null}
|
||||
{dossier.contracts.map((item) => (
|
||||
<article key={item.id} className="rounded-lg border border-[#dbe3f1] bg-white p-2 text-xs text-[#506180]">
|
||||
<p className="font-semibold">{item.title}</p>
|
||||
<p>
|
||||
{item.status} | Entregables {item.deliverables} | Pagos {item.payments} | Documentos {item.documents}
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{item.links.map((link) => (
|
||||
<Link key={`${item.id}-${link.href}`} href={link.href} className="text-[#2b5fab] underline">
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{item.documentEntries.length > 0 ? (
|
||||
<details className="mt-2 rounded-md border border-[#e4ebf6] p-2">
|
||||
<summary className="cursor-pointer font-semibold">Ver documentos y trazabilidad ({item.documentEntries.length})</summary>
|
||||
<div className="mt-2 space-y-2">
|
||||
{item.documentEntries.map((document) => (
|
||||
<article key={document.id} className="rounded-md border border-[#edf2fa] bg-[#f9fbff] p-2">
|
||||
<p className="font-semibold">{document.fileName}</p>
|
||||
<p className="mt-1 inline-flex rounded-full border border-[#c9d9f0] bg-white px-2 py-0.5 text-[11px] font-semibold text-[#315b96]">
|
||||
Por que este archivo
|
||||
</p>
|
||||
<p>
|
||||
{document.kind ? `${document.kind} | ` : ""}
|
||||
{document.mimeType} | {formatFileSize(document.sizeBytes)} | {formatDate(document.createdAt)}
|
||||
</p>
|
||||
<p className="mt-1">{document.traceLabel}</p>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{document.links.map((link) => (
|
||||
<Link key={`${document.id}-${link.href}`} href={link.href} className="text-[#2b5fab] underline">
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="space-y-2 py-5">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">Casos legales en expediente (M9)</p>
|
||||
{dossier.legal.length === 0 ? <p className="text-sm text-[#60718f]">Sin casos legales.</p> : null}
|
||||
{dossier.legal.map((item) => (
|
||||
<article key={item.id} className="rounded-lg border border-[#dbe3f1] bg-white p-2 text-xs text-[#506180]">
|
||||
<p className="font-semibold">
|
||||
{item.caseType} | {item.severity}
|
||||
</p>
|
||||
<p>
|
||||
{item.status} | Escritos {item.documents}
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{item.links.map((link) => (
|
||||
<Link key={`${item.id}-${link.href}`} href={link.href} className="text-[#2b5fab] underline">
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{item.documentEntries.length > 0 ? (
|
||||
<details className="mt-2 rounded-md border border-[#e4ebf6] p-2">
|
||||
<summary className="cursor-pointer font-semibold">Ver documentos y trazabilidad ({item.documentEntries.length})</summary>
|
||||
<div className="mt-2 space-y-2">
|
||||
{item.documentEntries.map((document) => (
|
||||
<article key={document.id} className="rounded-md border border-[#edf2fa] bg-[#f9fbff] p-2">
|
||||
<p className="font-semibold">{document.fileName}</p>
|
||||
<p className="mt-1 inline-flex rounded-full border border-[#c9d9f0] bg-white px-2 py-0.5 text-[11px] font-semibold text-[#315b96]">
|
||||
Por que este archivo
|
||||
</p>
|
||||
<p>
|
||||
{document.kind ? `${document.kind} | ` : ""}
|
||||
{document.mimeType} | {formatFileSize(document.sizeBytes)} | {formatDate(document.createdAt)}
|
||||
</p>
|
||||
<p className="mt-1">{document.traceLabel}</p>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{document.links.map((link) => (
|
||||
<Link key={`${document.id}-${link.href}`} href={link.href} className="text-[#2b5fab] underline">
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "dictamen-ia",
|
||||
label: "Dictamen IA",
|
||||
content: (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="secondary" disabled={isLoadingAiFindings} onClick={() => void generateAiFindings()}>
|
||||
{isLoadingAiFindings ? "Generando dictamen IA..." : "Generar dictamen IA"}
|
||||
</Button>
|
||||
{aiFindings ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
void setSuggestionDecision("dismiss");
|
||||
setAiFindings(null);
|
||||
setMessage("Dictamen IA descartado.");
|
||||
}}
|
||||
>
|
||||
Descartar dictamen IA
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!aiFindings ? <p className="text-sm text-[#60718f]">Genera dictamen IA para simular observaciones de auditor.</p> : null}
|
||||
|
||||
{aiFindings ? (
|
||||
<>
|
||||
<article className="rounded-xl border border-[#d8e1ef] bg-white p-3">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">Hallazgos probables del auditor</p>
|
||||
<ul className="mt-2 space-y-2 text-xs text-[#60718f]">
|
||||
{aiFindings.auditorLikelyFindings.map((item) => (
|
||||
<li key={`${item.area}-${item.finding}`} className="rounded-lg border border-[#e4ebf6] px-2 py-1">
|
||||
<p className="font-semibold text-[#233b62]">
|
||||
{item.area} · {item.severity}
|
||||
</p>
|
||||
<p>{item.finding}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article className="rounded-xl border border-[#d8e1ef] bg-white p-3">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">Evidencia faltante</p>
|
||||
<ul className="mt-2 space-y-1 text-xs text-[#60718f]">
|
||||
{aiFindings.missingEvidence.map((item) => (
|
||||
<li key={item}>- {item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article className="rounded-xl border border-[#d8e1ef] bg-white p-3">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">Top riesgos</p>
|
||||
<ul className="mt-2 space-y-1 text-xs text-[#60718f]">
|
||||
{aiFindings.topRisks.map((item) => (
|
||||
<li key={item}>- {item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article className="rounded-xl border border-[#d8e1ef] bg-white p-3">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">Plan de remediacion</p>
|
||||
<ul className="mt-2 space-y-2 text-xs text-[#60718f]">
|
||||
{aiFindings.remediationPlan.map((item) => (
|
||||
<li key={`${item.step}-${item.ownerSuggestion}`} className="rounded-lg border border-[#e4ebf6] px-2 py-1">
|
||||
<p className="font-semibold text-[#233b62]">{item.step}</p>
|
||||
<p>
|
||||
Prioridad {item.priority} · Responsable sugerido: {item.ownerSuggestion} · Fecha objetivo: {item.targetDate}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void setSuggestionDecision("accept");
|
||||
setMessage("Dictamen IA marcado como aceptado.");
|
||||
}}
|
||||
>
|
||||
Aceptar dictamen IA
|
||||
</Button>
|
||||
<p className="text-xs font-semibold text-[#405578]">Confianza IA: {aiFindings.confidence}</p>
|
||||
</div>
|
||||
</article>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1850
src/components/app/proposal-workflow-view.tsx
Normal file
1850
src/components/app/proposal-workflow-view.tsx
Normal file
File diff suppressed because it is too large
Load Diff
555
src/components/app/proposals-management-view.tsx
Normal file
555
src/components/app/proposals-management-view.tsx
Normal file
@@ -0,0 +1,555 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import type { ProposalView } from "@/lib/proposals/types";
|
||||
import { getProposalStatusLabel } from "@/lib/proposals/status";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
type ProposalsManagementViewProps = {
|
||||
initialProposals: ProposalView[];
|
||||
initialSourceLicitationId?: string | null;
|
||||
};
|
||||
|
||||
type ApiPayload = {
|
||||
ok?: boolean;
|
||||
error?: string;
|
||||
proposal?: ProposalView;
|
||||
proposals?: ProposalView[];
|
||||
};
|
||||
|
||||
type DraftState = {
|
||||
title: string;
|
||||
issuingEntity: string;
|
||||
summary: string;
|
||||
status: ProposalView["status"];
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS: ProposalView["status"][] = ["DRAFT", "IN_PROGRESS", "SUBMITTED", "ARCHIVED"];
|
||||
|
||||
function statusTone(status: ProposalView["status"]) {
|
||||
if (status === "SUBMITTED") {
|
||||
return "border-[#bce5d1] bg-[#ebf9f1] text-[#1e8b63]";
|
||||
}
|
||||
|
||||
if (status === "IN_PROGRESS") {
|
||||
return "border-[#c6d9f4] bg-[#ebf3ff] text-[#1f4f95]";
|
||||
}
|
||||
|
||||
if (status === "ARCHIVED") {
|
||||
return "border-[#d8e0ed] bg-[#f3f6fb] text-[#566b8f]";
|
||||
}
|
||||
|
||||
return "border-[#f0dfb8] bg-[#fff9ea] text-[#946807]";
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return "Fecha no disponible";
|
||||
}
|
||||
|
||||
return parsed.toLocaleString("es-MX", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function ProposalsManagementView({ initialProposals, initialSourceLicitationId }: ProposalsManagementViewProps) {
|
||||
const [proposals, setProposals] = useState<ProposalView[]>(initialProposals);
|
||||
const [query, setQuery] = useState("");
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [drafts, setDrafts] = useState<Record<string, DraftState>>(() =>
|
||||
Object.fromEntries(
|
||||
initialProposals.map((proposal) => [
|
||||
proposal.id,
|
||||
{
|
||||
title: proposal.title,
|
||||
issuingEntity: proposal.issuingEntity,
|
||||
summary: proposal.summary,
|
||||
status: proposal.status,
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const [newProposal, setNewProposal] = useState({
|
||||
title: "",
|
||||
issuingEntity: "",
|
||||
summary: "",
|
||||
sourceLicitationId: initialSourceLicitationId ?? "",
|
||||
});
|
||||
|
||||
const inputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||
|
||||
const filteredProposals = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
|
||||
if (!normalized) {
|
||||
return proposals;
|
||||
}
|
||||
|
||||
return proposals.filter((proposal) => {
|
||||
const composed = `${proposal.title} ${proposal.issuingEntity} ${proposal.summary}`.toLowerCase();
|
||||
return composed.includes(normalized);
|
||||
});
|
||||
}, [proposals, query]);
|
||||
|
||||
function setDraftField(id: string, field: keyof DraftState, value: string) {
|
||||
setDrafts((previous) => ({
|
||||
...previous,
|
||||
[id]: {
|
||||
...(previous[id] ?? {
|
||||
title: "",
|
||||
issuingEntity: "",
|
||||
summary: "",
|
||||
status: "DRAFT",
|
||||
}),
|
||||
[field]: value,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async function reloadProposals(nextQuery = "") {
|
||||
const response = await fetch(`/api/proposals?q=${encodeURIComponent(nextQuery)}`);
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (!response.ok || !payload.ok || !payload.proposals) {
|
||||
throw new Error(payload.error ?? "No fue posible obtener propuestas.");
|
||||
}
|
||||
|
||||
setProposals(payload.proposals);
|
||||
setDrafts(
|
||||
Object.fromEntries(
|
||||
payload.proposals.map((proposal) => [
|
||||
proposal.id,
|
||||
{
|
||||
title: proposal.title,
|
||||
issuingEntity: proposal.issuingEntity,
|
||||
summary: proposal.summary,
|
||||
status: proposal.status,
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function createProposal() {
|
||||
setErrorMessage(null);
|
||||
setMessage(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/proposals", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(newProposal),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (!response.ok || !payload.ok || !payload.proposal) {
|
||||
setErrorMessage(payload.error ?? "No fue posible crear la propuesta.");
|
||||
return;
|
||||
}
|
||||
|
||||
const createdProposal = payload.proposal as ProposalView;
|
||||
setProposals((previous) => [createdProposal, ...previous]);
|
||||
setDrafts((previous) => ({
|
||||
...previous,
|
||||
[createdProposal.id]: {
|
||||
title: createdProposal.title,
|
||||
issuingEntity: createdProposal.issuingEntity,
|
||||
summary: createdProposal.summary,
|
||||
status: createdProposal.status,
|
||||
},
|
||||
}));
|
||||
setNewProposal({
|
||||
title: "",
|
||||
issuingEntity: "",
|
||||
summary: "",
|
||||
sourceLicitationId: initialSourceLicitationId ?? "",
|
||||
});
|
||||
setIsCreateOpen(false);
|
||||
setMessage("Propuesta creada correctamente.");
|
||||
} catch {
|
||||
setErrorMessage("No fue posible crear la propuesta.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProposal(id: string) {
|
||||
const draft = drafts[id];
|
||||
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(null);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/proposals/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(draft),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (!response.ok || !payload.ok || !payload.proposal) {
|
||||
setErrorMessage(payload.error ?? "No fue posible guardar cambios.");
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedProposal = payload.proposal as ProposalView;
|
||||
setProposals((previous) => previous.map((proposal) => (proposal.id === id ? updatedProposal : proposal)));
|
||||
setMessage("Cambios guardados.");
|
||||
} catch {
|
||||
setErrorMessage("No fue posible guardar cambios.");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProposal(id: string) {
|
||||
setErrorMessage(null);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/proposals/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (!response.ok || !payload.ok) {
|
||||
setErrorMessage(payload.error ?? "No fue posible eliminar la propuesta.");
|
||||
return;
|
||||
}
|
||||
|
||||
setProposals((previous) => previous.filter((proposal) => proposal.id !== id));
|
||||
setDrafts((previous) => {
|
||||
const next = { ...previous };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
setMessage("Propuesta eliminada.");
|
||||
} catch {
|
||||
setErrorMessage("No fue posible eliminar la propuesta.");
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadDocument(id: string, file: File) {
|
||||
setErrorMessage(null);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch(`/api/proposals/${id}/documents`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (!response.ok || !payload.ok || !payload.proposal) {
|
||||
setErrorMessage(payload.error ?? "No fue posible subir el documento.");
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedProposal = payload.proposal as ProposalView;
|
||||
setProposals((previous) => previous.map((proposal) => (proposal.id === id ? updatedProposal : proposal)));
|
||||
setDrafts((previous) => ({
|
||||
...previous,
|
||||
[id]: {
|
||||
...(previous[id] ?? {
|
||||
title: updatedProposal.title,
|
||||
issuingEntity: updatedProposal.issuingEntity,
|
||||
summary: updatedProposal.summary,
|
||||
status: updatedProposal.status,
|
||||
}),
|
||||
status: updatedProposal.status,
|
||||
},
|
||||
}));
|
||||
setMessage("Documento subido correctamente.");
|
||||
} catch {
|
||||
setErrorMessage("No fue posible subir el documento.");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDocument(proposalId: string, documentId: string) {
|
||||
setErrorMessage(null);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/proposals/${proposalId}/documents/${documentId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (!response.ok || !payload.ok) {
|
||||
setErrorMessage(payload.error ?? "No fue posible eliminar el documento.");
|
||||
return;
|
||||
}
|
||||
|
||||
await reloadProposals(query);
|
||||
setMessage("Documento eliminado.");
|
||||
} catch {
|
||||
setErrorMessage("No fue posible eliminar el documento.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<section className="flex flex-wrap items-center justify-between gap-3">
|
||||
<label className="w-full max-w-2xl space-y-1 text-sm font-semibold text-[#314764]">
|
||||
Buscar por titulo o entidad
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Buscar por titulo o entidad..."
|
||||
className="h-12 w-full rounded-xl border border-[#cfd8e6] bg-white px-4 text-base"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<Button size="lg" className="rounded-2xl px-6" onClick={() => setIsCreateOpen(true)}>
|
||||
+ Nueva Propuesta
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
{errorMessage ? <p className="rounded-xl border border-[#efc4c4] bg-[#fff1f1] px-3 py-2 text-sm text-[#ad3f3f]">{errorMessage}</p> : null}
|
||||
{message ? <p className="rounded-xl border border-[#bde5ce] bg-[#ecf9f1] px-3 py-2 text-sm text-[#1f7f4f]">{message}</p> : null}
|
||||
|
||||
{filteredProposals.length === 0 ? (
|
||||
<Card className="border-[#cfd8e7]">
|
||||
<CardContent className="py-16 text-center">
|
||||
<p className="text-3xl font-semibold text-[#162748] [font-family:var(--font-display)] md:text-4xl">No hay propuestas</p>
|
||||
<p className="mx-auto mt-2 max-w-3xl text-base text-[#5f7090] md:text-lg">Comienza creando tu primera propuesta de licitacion. El asistente te guiara paso a paso.</p>
|
||||
<Button className="mt-6 rounded-2xl px-8" size="lg" onClick={() => setIsCreateOpen(true)}>
|
||||
+ Crear Primera Propuesta
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<section className="grid gap-4 xl:grid-cols-2">
|
||||
{filteredProposals.map((proposal) => {
|
||||
const draft = drafts[proposal.id] ?? {
|
||||
title: proposal.title,
|
||||
issuingEntity: proposal.issuingEntity,
|
||||
summary: proposal.summary,
|
||||
status: proposal.status,
|
||||
};
|
||||
|
||||
return (
|
||||
<article key={proposal.id} className="rounded-2xl border border-[#d7dfed] bg-white p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#5d7091]">Actualizada {formatDate(proposal.updatedAt)}</p>
|
||||
<h3 className="mt-1 text-2xl font-semibold text-[#14264a]">{proposal.title}</h3>
|
||||
<p className="mt-1 text-xs font-semibold text-[#60718f]">
|
||||
Paso {proposal.currentStep}/6 · {proposal.completionPercent}% completado
|
||||
</p>
|
||||
{proposal.readyForSubmissionAt ? (
|
||||
<p className="mt-1 text-xs font-semibold text-[#1f8b63]">Lista para envio desde {formatDate(proposal.readyForSubmissionAt)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span className={`inline-flex rounded-full border px-3 py-1 text-sm font-semibold ${statusTone(draft.status)}`}>
|
||||
{getProposalStatusLabel(draft.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3">
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
|
||||
Titulo
|
||||
<input
|
||||
value={draft.title}
|
||||
onChange={(event) => setDraftField(proposal.id, "title", event.target.value)}
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
|
||||
Entidad emisora
|
||||
<input
|
||||
value={draft.issuingEntity}
|
||||
onChange={(event) => setDraftField(proposal.id, "issuingEntity", event.target.value)}
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
|
||||
Resumen
|
||||
<textarea
|
||||
value={draft.summary}
|
||||
onChange={(event) => setDraftField(proposal.id, "summary", event.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-[#cfd8e6] px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
|
||||
Estatus
|
||||
<select
|
||||
value={draft.status}
|
||||
onChange={(event) => setDraftField(proposal.id, "status", event.target.value)}
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm"
|
||||
>
|
||||
{STATUS_OPTIONS.map((statusOption) => (
|
||||
<option key={statusOption} value={statusOption}>
|
||||
{getProposalStatusLabel(statusOption)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-xl border border-[#d9e1ee] bg-[#f8fbff] p-3">
|
||||
<div
|
||||
className="flex min-h-[110px] cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-[#ccd7ea] bg-white text-center"
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
const dropped = event.dataTransfer.files?.[0];
|
||||
if (dropped) {
|
||||
void uploadDocument(proposal.id, dropped);
|
||||
}
|
||||
}}
|
||||
onClick={() => inputRefs.current[proposal.id]?.click()}
|
||||
>
|
||||
<p className="text-sm font-semibold text-[#1f3051]">Arrastra un documento aqui</p>
|
||||
<p className="text-xs text-[#627391]">o haz clic para seleccionar (PDF, DOC, DOCX, XLSX, JPG, PNG)</p>
|
||||
</div>
|
||||
<input
|
||||
ref={(element) => {
|
||||
inputRefs.current[proposal.id] = element;
|
||||
}}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept=".pdf,.doc,.docx,.xlsx,.jpg,.jpeg,.png"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
void uploadDocument(proposal.id, file);
|
||||
}
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
|
||||
{proposal.documents.length > 0 ? (
|
||||
<ul className="mt-3 space-y-2 text-sm text-[#4a5f84]">
|
||||
{proposal.documents.map((document) => (
|
||||
<li key={document.id} className="flex items-center justify-between gap-2 rounded-lg border border-[#d8e2ef] bg-white px-3 py-2">
|
||||
<span>{document.fileName}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs font-semibold text-[#b23f50]"
|
||||
onClick={() => {
|
||||
void deleteDocument(proposal.id, document.id);
|
||||
}}
|
||||
>
|
||||
Eliminar
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-3 text-xs text-[#627391]">Sin documentos cargados.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Link href={`/gestion-licitaciones/${encodeURIComponent(proposal.id)}?autofill=1`}>
|
||||
<Button size="sm">Continuar</Button>
|
||||
</Link>
|
||||
<Link href={`/gestion-contratos?proposalId=${encodeURIComponent(proposal.id)}&autocreate=1`}>
|
||||
<Button size="sm" variant="secondary">
|
||||
Crear contrato M8
|
||||
</Button>
|
||||
</Link>
|
||||
<Button size="sm" onClick={() => void saveProposal(proposal.id)}>
|
||||
Guardar cambios
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => void deleteProposal(proposal.id)}>
|
||||
Eliminar propuesta
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{isCreateOpen ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#0c1731]/55 p-4" role="dialog" aria-modal="true">
|
||||
<div className="w-full max-w-2xl rounded-2xl bg-white p-5">
|
||||
<h3 className="text-3xl font-semibold text-[#14264a] [font-family:var(--font-display)]">Nueva Propuesta</h3>
|
||||
|
||||
<div className="mt-4 grid gap-3">
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
|
||||
Titulo
|
||||
<input
|
||||
value={newProposal.title}
|
||||
onChange={(event) => setNewProposal((previous) => ({ ...previous, title: event.target.value }))}
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
|
||||
Entidad emisora
|
||||
<input
|
||||
value={newProposal.issuingEntity}
|
||||
onChange={(event) => setNewProposal((previous) => ({ ...previous, issuingEntity: event.target.value }))}
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
|
||||
Resumen
|
||||
<textarea
|
||||
value={newProposal.summary}
|
||||
onChange={(event) => setNewProposal((previous) => ({ ...previous, summary: event.target.value }))}
|
||||
rows={4}
|
||||
className="w-full rounded-lg border border-[#cfd8e6] px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
|
||||
ID de oportunidad origen (opcional)
|
||||
<input
|
||||
value={newProposal.sourceLicitationId}
|
||||
onChange={(event) => setNewProposal((previous) => ({ ...previous, sourceLicitationId: event.target.value }))}
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm"
|
||||
placeholder="Se autocompleta si vienes desde Modulo 3"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={() => setIsCreateOpen(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={() => void createProposal()} disabled={isSubmitting}>
|
||||
{isSubmitting ? "Creando..." : "Crear propuesta"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
430
src/lib/ai/openai.ts
Normal file
430
src/lib/ai/openai.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
import "server-only";
|
||||
|
||||
import { z } from "zod";
|
||||
import type { AiConfidence, AiEnvelope, AiUsage, AiWarning } from "@/lib/ai/types";
|
||||
|
||||
const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
|
||||
const DEFAULT_SMART_MODEL = "gpt-4.1";
|
||||
const DEFAULT_SMART_FALLBACK_MODEL = "gpt-4.1-mini";
|
||||
const DEFAULT_TIMEOUT_MS = 75_000;
|
||||
const DEFAULT_MAX_PROMPT_CHARS = 55_000;
|
||||
const DEFAULT_RETRIES = 1;
|
||||
|
||||
type OpenAiChatCompletionResponse = {
|
||||
choices?: Array<{
|
||||
message?: {
|
||||
content?: string | null;
|
||||
};
|
||||
}>;
|
||||
model?: string;
|
||||
usage?: {
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
total_tokens?: number;
|
||||
};
|
||||
error?: {
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type OpenAiCallBaseInput<T> = {
|
||||
promptVersion: string;
|
||||
systemPrompt: string;
|
||||
userPrompt: string;
|
||||
outputSchema: z.ZodType<T>;
|
||||
model?: string | null;
|
||||
fallbackModel?: string | null;
|
||||
timeoutMs?: number;
|
||||
maxPromptChars?: number;
|
||||
retries?: number;
|
||||
temperature?: number;
|
||||
};
|
||||
|
||||
type OpenAiJsonSchemaInput<T> = OpenAiCallBaseInput<T> & {
|
||||
schemaName: string;
|
||||
jsonSchema: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type OpenAiJsonObjectInput<T> = OpenAiCallBaseInput<T> & {
|
||||
initialWarnings?: AiWarning[];
|
||||
};
|
||||
|
||||
type OpenAiExecutionResult<T> = {
|
||||
data: T;
|
||||
model: string | null;
|
||||
usage: AiUsage | null;
|
||||
rawResponse: Record<string, unknown> | null;
|
||||
warnings: AiWarning[];
|
||||
};
|
||||
|
||||
function parsePositiveInteger(value: string | undefined, fallback: number) {
|
||||
const parsed = Number.parseInt((value ?? "").trim(), 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function getOpenAiApiKey() {
|
||||
return process.env.OPENAI_API_KEY?.trim() || process.env.API_KEY?.trim() || process.env.api_key?.trim() || "";
|
||||
}
|
||||
|
||||
function getSmartModel() {
|
||||
return process.env.OPENAI_SMART_MODEL?.trim() || DEFAULT_SMART_MODEL;
|
||||
}
|
||||
|
||||
function getSmartFallbackModel() {
|
||||
return process.env.OPENAI_SMART_FALLBACK_MODEL?.trim() || DEFAULT_SMART_FALLBACK_MODEL;
|
||||
}
|
||||
|
||||
function getOpenAiBaseUrl() {
|
||||
return (process.env.OPENAI_API_BASE_URL?.trim() || DEFAULT_OPENAI_BASE_URL).replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function truncatePrompt(value: string, maxChars: number) {
|
||||
if (value.length <= maxChars) {
|
||||
return {
|
||||
prompt: value,
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
prompt: `${value.slice(0, maxChars)}\n\n[TEXT_TRUNCATED_TO_${maxChars}_CHARS]`,
|
||||
truncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
function usageFromPayload(payload: OpenAiChatCompletionResponse): AiUsage {
|
||||
return {
|
||||
promptTokens: payload.usage?.prompt_tokens ?? null,
|
||||
completionTokens: payload.usage?.completion_tokens ?? null,
|
||||
totalTokens: payload.usage?.total_tokens ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function extractJsonObject(rawContent: string) {
|
||||
const trimmed = rawContent.trim();
|
||||
|
||||
try {
|
||||
return JSON.parse(trimmed) as unknown;
|
||||
} catch {
|
||||
// Continue with safe extraction.
|
||||
}
|
||||
|
||||
const withoutFence = trimmed
|
||||
.replace(/^```json\s*/i, "")
|
||||
.replace(/^```\s*/i, "")
|
||||
.replace(/\s*```$/i, "")
|
||||
.trim();
|
||||
|
||||
try {
|
||||
return JSON.parse(withoutFence) as unknown;
|
||||
} catch {
|
||||
// Continue with bracket extraction.
|
||||
}
|
||||
|
||||
const firstBrace = withoutFence.indexOf("{");
|
||||
const lastBrace = withoutFence.lastIndexOf("}");
|
||||
|
||||
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
||||
return JSON.parse(withoutFence.slice(firstBrace, lastBrace + 1)) as unknown;
|
||||
}
|
||||
|
||||
throw new Error("La respuesta de OpenAI no contiene un JSON valido.");
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function inferConfidence(value: unknown): AiConfidence | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
const confidence = record.confidence;
|
||||
|
||||
if (confidence === "low" || confidence === "medium" || confidence === "high") {
|
||||
return confidence;
|
||||
}
|
||||
|
||||
if (typeof confidence === "number") {
|
||||
if (confidence >= 0.8) {
|
||||
return "high";
|
||||
}
|
||||
|
||||
if (confidence >= 0.5) {
|
||||
return "medium";
|
||||
}
|
||||
|
||||
return "low";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function executeOpenAiCall<T>(
|
||||
args: {
|
||||
promptVersion: string;
|
||||
systemPrompt: string;
|
||||
userPrompt: string;
|
||||
outputSchema: z.ZodType<T>;
|
||||
model: string;
|
||||
responseFormat: Record<string, unknown>;
|
||||
timeoutMs: number;
|
||||
retries: number;
|
||||
temperature: number;
|
||||
},
|
||||
): Promise<OpenAiExecutionResult<T>> {
|
||||
const apiKey = getOpenAiApiKey();
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error("No se encontro API key para OpenAI (OPENAI_API_KEY o api_key).");
|
||||
}
|
||||
|
||||
const warnings: AiWarning[] = [];
|
||||
const baseUrl = getOpenAiBaseUrl();
|
||||
const requestBody = {
|
||||
model: args.model,
|
||||
temperature: args.temperature,
|
||||
response_format: args.responseFormat,
|
||||
messages: [
|
||||
{ role: "system", content: args.systemPrompt },
|
||||
{ role: "user", content: args.userPrompt },
|
||||
],
|
||||
};
|
||||
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (let attempt = 0; attempt <= args.retries; attempt += 1) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), args.timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as OpenAiChatCompletionResponse;
|
||||
|
||||
if (!response.ok) {
|
||||
const apiMessage = payload.error?.message ? ` ${payload.error.message}` : "";
|
||||
const retryable = response.status >= 500 || response.status === 429;
|
||||
|
||||
if (retryable && attempt < args.retries) {
|
||||
warnings.push({
|
||||
code: "retrying_openai_request",
|
||||
message: `OpenAI devolvio ${response.status}. Reintentando (${attempt + 1}/${args.retries + 1}).`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`OpenAI devolvio ${response.status}.${apiMessage}`);
|
||||
}
|
||||
|
||||
const rawContent = payload.choices?.[0]?.message?.content;
|
||||
|
||||
if (!rawContent || typeof rawContent !== "string") {
|
||||
throw new Error("OpenAI no devolvio contenido util.");
|
||||
}
|
||||
|
||||
const parsedJson = extractJsonObject(rawContent);
|
||||
const parsed = args.outputSchema.safeParse(parsedJson);
|
||||
|
||||
if (!parsed.success) {
|
||||
throw new Error(`La salida de OpenAI no cumple el esquema esperado: ${parsed.error.issues[0]?.message ?? "sin detalle"}`);
|
||||
}
|
||||
|
||||
return {
|
||||
data: parsed.data,
|
||||
model: payload.model ?? args.model,
|
||||
usage: usageFromPayload(payload),
|
||||
rawResponse: payload as unknown as Record<string, unknown>,
|
||||
warnings,
|
||||
};
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
if (attempt < args.retries) {
|
||||
warnings.push({
|
||||
code: "retrying_openai_request",
|
||||
message: `${getErrorMessage(error)} Reintentando (${attempt + 1}/${args.retries + 1}).`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(getErrorMessage(lastError));
|
||||
}
|
||||
|
||||
export async function callOpenAiJsonObjectFallback<T>(input: OpenAiJsonObjectInput<T>): Promise<AiEnvelope<T>> {
|
||||
const model = input.model?.trim() || input.fallbackModel?.trim() || getSmartFallbackModel();
|
||||
const timeoutMs = input.timeoutMs ?? parsePositiveInteger(process.env.OPENAI_SMART_TIMEOUT_MS, DEFAULT_TIMEOUT_MS);
|
||||
const maxPromptChars = input.maxPromptChars ?? parsePositiveInteger(process.env.OPENAI_SMART_MAX_CHARS, DEFAULT_MAX_PROMPT_CHARS);
|
||||
const retries = input.retries ?? DEFAULT_RETRIES;
|
||||
const temperature = typeof input.temperature === "number" ? input.temperature : 0;
|
||||
const { prompt, truncated } = truncatePrompt(input.userPrompt, maxPromptChars);
|
||||
const warnings: AiWarning[] = [...(input.initialWarnings ?? [])];
|
||||
|
||||
if (truncated) {
|
||||
warnings.push({
|
||||
code: "prompt_truncated",
|
||||
message: `El prompt fue truncado a ${maxPromptChars} caracteres para controlar costo y latencia.`,
|
||||
});
|
||||
}
|
||||
|
||||
const requestJson: Record<string, unknown> = {
|
||||
model,
|
||||
temperature,
|
||||
response_format: {
|
||||
type: "json_object",
|
||||
},
|
||||
messages: [
|
||||
{ role: "system", content: input.systemPrompt },
|
||||
{ role: "user", content: prompt },
|
||||
],
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await executeOpenAiCall({
|
||||
promptVersion: input.promptVersion,
|
||||
systemPrompt: input.systemPrompt,
|
||||
userPrompt: prompt,
|
||||
outputSchema: input.outputSchema,
|
||||
model,
|
||||
responseFormat: {
|
||||
type: "json_object",
|
||||
},
|
||||
timeoutMs,
|
||||
retries,
|
||||
temperature,
|
||||
});
|
||||
|
||||
return {
|
||||
data: result.data,
|
||||
engine: "openai_json_object",
|
||||
model: result.model,
|
||||
usage: result.usage,
|
||||
warnings: [...warnings, ...result.warnings],
|
||||
confidence: inferConfidence(result.data),
|
||||
promptVersion: input.promptVersion,
|
||||
truncated,
|
||||
requestJson,
|
||||
responseJson: result.rawResponse,
|
||||
};
|
||||
} catch (error) {
|
||||
warnings.push({
|
||||
code: "openai_json_object_failed",
|
||||
message: getErrorMessage(error),
|
||||
});
|
||||
|
||||
return {
|
||||
data: null,
|
||||
engine: "empty_assist",
|
||||
model: null,
|
||||
usage: null,
|
||||
warnings,
|
||||
confidence: null,
|
||||
promptVersion: input.promptVersion,
|
||||
truncated,
|
||||
requestJson,
|
||||
responseJson: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function callOpenAiJsonSchema<T>(input: OpenAiJsonSchemaInput<T>): Promise<AiEnvelope<T>> {
|
||||
const model = input.model?.trim() || getSmartModel();
|
||||
const timeoutMs = input.timeoutMs ?? parsePositiveInteger(process.env.OPENAI_SMART_TIMEOUT_MS, DEFAULT_TIMEOUT_MS);
|
||||
const maxPromptChars = input.maxPromptChars ?? parsePositiveInteger(process.env.OPENAI_SMART_MAX_CHARS, DEFAULT_MAX_PROMPT_CHARS);
|
||||
const retries = input.retries ?? DEFAULT_RETRIES;
|
||||
const temperature = typeof input.temperature === "number" ? input.temperature : 0;
|
||||
const { prompt, truncated } = truncatePrompt(input.userPrompt, maxPromptChars);
|
||||
const warnings: AiWarning[] = [];
|
||||
|
||||
if (truncated) {
|
||||
warnings.push({
|
||||
code: "prompt_truncated",
|
||||
message: `El prompt fue truncado a ${maxPromptChars} caracteres para controlar costo y latencia.`,
|
||||
});
|
||||
}
|
||||
|
||||
const requestJson: Record<string, unknown> = {
|
||||
model,
|
||||
temperature,
|
||||
response_format: {
|
||||
type: "json_schema",
|
||||
json_schema: {
|
||||
name: input.schemaName,
|
||||
strict: true,
|
||||
schema: input.jsonSchema,
|
||||
},
|
||||
},
|
||||
messages: [
|
||||
{ role: "system", content: input.systemPrompt },
|
||||
{ role: "user", content: prompt },
|
||||
],
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await executeOpenAiCall({
|
||||
promptVersion: input.promptVersion,
|
||||
systemPrompt: input.systemPrompt,
|
||||
userPrompt: prompt,
|
||||
outputSchema: input.outputSchema,
|
||||
model,
|
||||
responseFormat: {
|
||||
type: "json_schema",
|
||||
json_schema: {
|
||||
name: input.schemaName,
|
||||
strict: true,
|
||||
schema: input.jsonSchema,
|
||||
},
|
||||
},
|
||||
timeoutMs,
|
||||
retries,
|
||||
temperature,
|
||||
});
|
||||
|
||||
return {
|
||||
data: result.data,
|
||||
engine: "openai_json_schema",
|
||||
model: result.model,
|
||||
usage: result.usage,
|
||||
warnings: [...warnings, ...result.warnings],
|
||||
confidence: inferConfidence(result.data),
|
||||
promptVersion: input.promptVersion,
|
||||
truncated,
|
||||
requestJson,
|
||||
responseJson: result.rawResponse,
|
||||
};
|
||||
} catch (error) {
|
||||
warnings.push({
|
||||
code: "openai_json_schema_failed",
|
||||
message: getErrorMessage(error),
|
||||
});
|
||||
|
||||
return callOpenAiJsonObjectFallback({
|
||||
...input,
|
||||
userPrompt: prompt,
|
||||
initialWarnings: warnings,
|
||||
model: input.fallbackModel?.trim() || getSmartFallbackModel(),
|
||||
timeoutMs,
|
||||
maxPromptChars,
|
||||
retries,
|
||||
temperature,
|
||||
});
|
||||
}
|
||||
}
|
||||
204
src/lib/ai/suggestions.ts
Normal file
204
src/lib/ai/suggestions.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import "server-only";
|
||||
|
||||
import { createHash } from "crypto";
|
||||
import { AiSuggestionStatus, type Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import type { AiConfidence, AiDecision, AiEnvelope, AiSuggestionStatus as AiSuggestionStatusView } from "@/lib/ai/types";
|
||||
|
||||
type CreateAiSuggestionInput = {
|
||||
userId: string;
|
||||
moduleKey: string;
|
||||
featureKey: string;
|
||||
subjectType: string;
|
||||
subjectId: string;
|
||||
inputForHash: unknown;
|
||||
requestJson: unknown;
|
||||
responseJson: unknown;
|
||||
confidence: AiConfidence | number | null;
|
||||
engine: string;
|
||||
model: string | null;
|
||||
usageJson: unknown;
|
||||
warningsJson: unknown;
|
||||
promptVersion: string;
|
||||
};
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (value === null || value === undefined) {
|
||||
return "null";
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
const record = value as Record<string, unknown>;
|
||||
const keys = Object.keys(record).sort();
|
||||
return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`;
|
||||
}
|
||||
|
||||
return JSON.stringify(String(value));
|
||||
}
|
||||
|
||||
function normalizeConfidence(value: AiConfidence | number | null): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
if (value >= 0 && value <= 1) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value >= 0 && value <= 100) {
|
||||
return value / 100;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value === "high") {
|
||||
return 0.9;
|
||||
}
|
||||
|
||||
if (value === "medium") {
|
||||
return 0.65;
|
||||
}
|
||||
|
||||
if (value === "low") {
|
||||
return 0.35;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function toJsonValue(value: unknown): Prisma.InputJsonValue {
|
||||
return (value ?? null) as Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
export function createAiInputHash(input: unknown): string {
|
||||
return createHash("sha256").update(stableStringify(input)).digest("hex");
|
||||
}
|
||||
|
||||
export async function storeAiSuggestion(input: CreateAiSuggestionInput) {
|
||||
const inputHash = createAiInputHash(input.inputForHash);
|
||||
|
||||
const existing = await prisma.aiSuggestion.findFirst({
|
||||
where: {
|
||||
userId: input.userId,
|
||||
moduleKey: input.moduleKey,
|
||||
featureKey: input.featureKey,
|
||||
subjectType: input.subjectType,
|
||||
subjectId: input.subjectId,
|
||||
inputHash,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return {
|
||||
suggestionId: existing.id,
|
||||
inputHash,
|
||||
status: existing.status as AiSuggestionStatusView,
|
||||
reused: true,
|
||||
};
|
||||
}
|
||||
|
||||
const created = await prisma.aiSuggestion.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
moduleKey: input.moduleKey,
|
||||
featureKey: input.featureKey,
|
||||
subjectType: input.subjectType,
|
||||
subjectId: input.subjectId,
|
||||
inputHash,
|
||||
requestJson: toJsonValue(input.requestJson),
|
||||
responseJson: toJsonValue(input.responseJson),
|
||||
confidence: normalizeConfidence(input.confidence),
|
||||
engine: input.engine,
|
||||
model: input.model,
|
||||
usageJson: toJsonValue(input.usageJson),
|
||||
warningsJson: toJsonValue(input.warningsJson),
|
||||
promptVersion: input.promptVersion,
|
||||
status: AiSuggestionStatus.GENERATED,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
suggestionId: created.id,
|
||||
inputHash,
|
||||
status: created.status as AiSuggestionStatusView,
|
||||
reused: false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function storeAiSuggestionFromEnvelope<T>(
|
||||
input: Omit<CreateAiSuggestionInput, "requestJson" | "responseJson" | "confidence" | "engine" | "model" | "usageJson" | "warningsJson" | "promptVersion"> & {
|
||||
envelope: AiEnvelope<T>;
|
||||
responsePayload: unknown;
|
||||
},
|
||||
) {
|
||||
return storeAiSuggestion({
|
||||
userId: input.userId,
|
||||
moduleKey: input.moduleKey,
|
||||
featureKey: input.featureKey,
|
||||
subjectType: input.subjectType,
|
||||
subjectId: input.subjectId,
|
||||
inputForHash: input.inputForHash,
|
||||
requestJson: input.envelope.requestJson,
|
||||
responseJson: input.responsePayload,
|
||||
confidence: input.envelope.confidence,
|
||||
engine: input.envelope.engine,
|
||||
model: input.envelope.model,
|
||||
usageJson: input.envelope.usage,
|
||||
warningsJson: input.envelope.warnings,
|
||||
promptVersion: input.envelope.promptVersion,
|
||||
});
|
||||
}
|
||||
|
||||
export async function applyAiSuggestionDecision(userId: string, suggestionId: string, decision: AiDecision) {
|
||||
const suggestion = await prisma.aiSuggestion.findFirst({
|
||||
where: {
|
||||
id: suggestionId,
|
||||
userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!suggestion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const status: AiSuggestionStatus = decision === "accept" ? AiSuggestionStatus.ACCEPTED : AiSuggestionStatus.DISMISSED;
|
||||
|
||||
const updated = await prisma.aiSuggestion.update({
|
||||
where: { id: suggestionId },
|
||||
data: {
|
||||
status,
|
||||
actedAt: new Date(),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
actedAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
status: updated.status as AiSuggestionStatusView,
|
||||
actedAt: updated.actedAt?.toISOString() ?? null,
|
||||
updatedAt: updated.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
31
src/lib/ai/types.ts
Normal file
31
src/lib/ai/types.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export type AiUsage = {
|
||||
promptTokens: number | null;
|
||||
completionTokens: number | null;
|
||||
totalTokens: number | null;
|
||||
};
|
||||
|
||||
export type AiWarning = {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type AiConfidence = "low" | "medium" | "high";
|
||||
|
||||
export type AiSuggestionStatus = "GENERATED" | "ACCEPTED" | "DISMISSED" | "EXPIRED";
|
||||
|
||||
export type AiDecision = "accept" | "dismiss";
|
||||
|
||||
export type AiEngine = "openai_json_schema" | "openai_json_object" | "empty_assist";
|
||||
|
||||
export type AiEnvelope<T> = {
|
||||
data: T | null;
|
||||
engine: AiEngine;
|
||||
model: string | null;
|
||||
usage: AiUsage | null;
|
||||
warnings: AiWarning[];
|
||||
confidence: AiConfidence | null;
|
||||
promptVersion: string;
|
||||
truncated: boolean;
|
||||
requestJson: Record<string, unknown>;
|
||||
responseJson: Record<string, unknown> | null;
|
||||
};
|
||||
118
src/lib/audits/__tests__/dossier-continuity.integration.test.ts
Normal file
118
src/lib/audits/__tests__/dossier-continuity.integration.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { prismaMock, getM7DatasetForUserMock } = vi.hoisted(() => ({
|
||||
prismaMock: {
|
||||
proposal: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
contractRecord: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
legalCase: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
getM7DatasetForUserMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/compliance/server", () => ({
|
||||
getM7DatasetForUser: getM7DatasetForUserMock,
|
||||
}));
|
||||
|
||||
import { buildInstitutionalDossier } from "@/lib/audits/server";
|
||||
|
||||
describe("M4 -> M5 -> M8 -> M9 -> M10 continuity integration", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("preserves document lineage and module links in M10 dossier payload", async () => {
|
||||
const createdAt = new Date("2026-04-15T10:00:00.000Z");
|
||||
|
||||
prismaMock.proposal.findMany.mockResolvedValue([
|
||||
{
|
||||
id: "proposal-1",
|
||||
title: "Propuesta Integral",
|
||||
status: "IN_PROGRESS",
|
||||
sourceLicitationId: "licit-1",
|
||||
documents: [
|
||||
{
|
||||
id: "proposal-doc-1",
|
||||
fileName: "propuesta.pdf",
|
||||
mimeType: "application/pdf",
|
||||
sizeBytes: 101,
|
||||
createdAt,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
prismaMock.contractRecord.findMany.mockResolvedValue([
|
||||
{
|
||||
id: "contract-1",
|
||||
title: "Contrato Marco",
|
||||
status: "ACTIVE",
|
||||
sourceProposal: {
|
||||
id: "proposal-1",
|
||||
title: "Propuesta Integral",
|
||||
},
|
||||
deliverables: [{ id: "del-1" }],
|
||||
payments: [{ id: "pay-1" }],
|
||||
documents: [
|
||||
{
|
||||
id: "contract-doc-1",
|
||||
fileName: "contrato-firmado.pdf",
|
||||
mimeType: "application/pdf",
|
||||
sizeBytes: 202,
|
||||
kind: "SIGNED_CONTRACT",
|
||||
createdAt,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
prismaMock.legalCase.findMany.mockResolvedValue([
|
||||
{
|
||||
id: "legal-1",
|
||||
caseType: "CONTRACT_BREACH",
|
||||
severity: "HIGH",
|
||||
status: "OPEN",
|
||||
contractId: "contract-1",
|
||||
documents: [
|
||||
{
|
||||
id: "legal-doc-1",
|
||||
title: "Escrito inicial",
|
||||
templateKey: "general_delivery_record",
|
||||
createdAt,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
getM7DatasetForUserMock.mockResolvedValue({
|
||||
tabs: {
|
||||
alertas: [{ severity: "high" }, { severity: "medium" }],
|
||||
},
|
||||
});
|
||||
|
||||
const dossier = await buildInstitutionalDossier("user-1");
|
||||
|
||||
expect(dossier.summary.proposals).toBe(1);
|
||||
expect(dossier.summary.contracts).toBe(1);
|
||||
expect(dossier.summary.legalCases).toBe(1);
|
||||
expect(dossier.summary.highRiskAlerts).toBe(1);
|
||||
|
||||
expect(dossier.proposals[0]?.links.some((link) => link.href === "/licitations/licit-1")).toBe(true);
|
||||
|
||||
expect(dossier.contracts[0]?.links.some((link) => link.href === "/gestion-licitaciones/proposal-1")).toBe(true);
|
||||
expect(dossier.contracts[0]?.documentEntries[0]?.traceLabel).toContain("propuesta M5");
|
||||
expect(dossier.contracts[0]?.documentEntries[0]?.links.some((link) => link.href === "/api/contracts/contract-1/documents/contract-doc-1")).toBe(true);
|
||||
|
||||
expect(dossier.legal[0]?.links.some((link) => link.href === "/gestion-contratos?contractId=contract-1")).toBe(true);
|
||||
expect(dossier.legal[0]?.documentEntries[0]?.traceLabel).toContain("contract-1");
|
||||
});
|
||||
});
|
||||
166
src/lib/audits/scoring.ts
Normal file
166
src/lib/audits/scoring.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import type { AuditSimulationSectionStatus } from "@prisma/client";
|
||||
import { AUDIT_SECTION_KEYS, type AuditScoreOutput, type AuditSectionKey } from "@/lib/audits/types";
|
||||
|
||||
type AnswerRow = {
|
||||
questionKey: string;
|
||||
answer: string;
|
||||
};
|
||||
|
||||
type QuestionDefinition = {
|
||||
key: string;
|
||||
section: AuditSectionKey;
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
export const AUDIT_QUESTIONNAIRE: QuestionDefinition[] = [
|
||||
{
|
||||
key: "fiscal_obligaciones_al_dia",
|
||||
section: "cumplimiento_fiscal",
|
||||
prompt: "La empresa tiene cumplimiento fiscal actualizado?",
|
||||
},
|
||||
{
|
||||
key: "fiscal_opinion_positiva_sat",
|
||||
section: "cumplimiento_fiscal",
|
||||
prompt: "Cuenta con opinion positiva del SAT vigente?",
|
||||
},
|
||||
{
|
||||
key: "laboral_imss_infonavit",
|
||||
section: "cumplimiento_laboral",
|
||||
prompt: "Existen evidencias de cumplimiento IMSS/INFONAVIT?",
|
||||
},
|
||||
{
|
||||
key: "laboral_expedientes_personal",
|
||||
section: "cumplimiento_laboral",
|
||||
prompt: "Se mantienen expedientes laborales completos?",
|
||||
},
|
||||
{
|
||||
key: "legal_contratos_resguardados",
|
||||
section: "documentacion_legal",
|
||||
prompt: "Los contratos y anexos se encuentran resguardados?",
|
||||
},
|
||||
{
|
||||
key: "legal_actas_poderes",
|
||||
section: "documentacion_legal",
|
||||
prompt: "Las actas/poderes estan actualizados y disponibles?",
|
||||
},
|
||||
{
|
||||
key: "operativo_control_entregables",
|
||||
section: "control_operativo",
|
||||
prompt: "Existe control formal de entregables y evidencias?",
|
||||
},
|
||||
{
|
||||
key: "operativo_seguimiento_pagos",
|
||||
section: "control_operativo",
|
||||
prompt: "Se monitorean pagos y facturacion con trazabilidad?",
|
||||
},
|
||||
{
|
||||
key: "finanzas_conciliaciones",
|
||||
section: "transparencia_financiera",
|
||||
prompt: "Hay conciliaciones y soporte de movimientos financieros?",
|
||||
},
|
||||
{
|
||||
key: "finanzas_reportes_riesgo",
|
||||
section: "transparencia_financiera",
|
||||
prompt: "Se generan reportes de riesgo y controles preventivos?",
|
||||
},
|
||||
];
|
||||
|
||||
const ANSWER_SCORE_MAP: Record<string, number> = {
|
||||
si: 100,
|
||||
yes: 100,
|
||||
parcial: 60,
|
||||
partial: 60,
|
||||
en_proceso: 60,
|
||||
no: 20,
|
||||
};
|
||||
|
||||
function normalizeAnswer(answer: string) {
|
||||
return answer
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/\s+/g, "_");
|
||||
}
|
||||
|
||||
function scoreAnswer(answer: string) {
|
||||
const normalized = normalizeAnswer(answer);
|
||||
return ANSWER_SCORE_MAP[normalized] ?? 40;
|
||||
}
|
||||
|
||||
function scoreToStatus(score: number): AuditSimulationSectionStatus {
|
||||
if (score >= 80) {
|
||||
return "READY";
|
||||
}
|
||||
|
||||
if (score >= 50) {
|
||||
return "WARNING";
|
||||
}
|
||||
|
||||
return "CRITICAL";
|
||||
}
|
||||
|
||||
export function scoreAuditResponses(rows: AnswerRow[]): AuditScoreOutput {
|
||||
const answersByQuestion = new Map(rows.map((row) => [row.questionKey, row.answer]));
|
||||
const sectionAccumulator = new Map<AuditSectionKey, { total: number; count: number; missing: number }>();
|
||||
|
||||
for (const key of AUDIT_SECTION_KEYS) {
|
||||
sectionAccumulator.set(key, { total: 0, count: 0, missing: 0 });
|
||||
}
|
||||
|
||||
for (const question of AUDIT_QUESTIONNAIRE) {
|
||||
const answer = answersByQuestion.get(question.key);
|
||||
const bucket = sectionAccumulator.get(question.section);
|
||||
|
||||
if (!bucket) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!answer) {
|
||||
bucket.missing += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
bucket.total += scoreAnswer(answer);
|
||||
bucket.count += 1;
|
||||
}
|
||||
|
||||
const sections = AUDIT_SECTION_KEYS.map((sectionKey) => {
|
||||
const bucket = sectionAccumulator.get(sectionKey) ?? { total: 0, count: 0, missing: 0 };
|
||||
const score = bucket.count > 0 ? Math.round(bucket.total / bucket.count) : 0;
|
||||
const status = scoreToStatus(score);
|
||||
|
||||
const findings: string[] = [];
|
||||
const recommendations: string[] = [];
|
||||
|
||||
if (bucket.missing > 0) {
|
||||
findings.push(`${bucket.missing} reactivos sin respuesta en esta seccion.`);
|
||||
}
|
||||
|
||||
if (status === "CRITICAL") {
|
||||
findings.push("Riesgo critico de incumplimiento en esta seccion.");
|
||||
recommendations.push("Implementar plan correctivo inmediato y evidencia documental.");
|
||||
} else if (status === "WARNING") {
|
||||
findings.push("Se identifican brechas relevantes de control.");
|
||||
recommendations.push("Cerrar brechas operativas con responsables y fechas.");
|
||||
} else {
|
||||
findings.push("Seccion con nivel operativo favorable.");
|
||||
recommendations.push("Mantener controles y actualizar evidencia periodicamente.");
|
||||
}
|
||||
|
||||
return {
|
||||
key: sectionKey,
|
||||
score,
|
||||
status,
|
||||
findings,
|
||||
recommendations,
|
||||
};
|
||||
});
|
||||
|
||||
const overallScore = sections.length > 0 ? Math.round(sections.reduce((sum, item) => sum + item.score, 0) / sections.length) : 0;
|
||||
|
||||
return {
|
||||
overallScore,
|
||||
sections,
|
||||
};
|
||||
}
|
||||
640
src/lib/audits/server.ts
Normal file
640
src/lib/audits/server.ts
Normal file
@@ -0,0 +1,640 @@
|
||||
import "server-only";
|
||||
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getM7DatasetForUser } from "@/lib/compliance/server";
|
||||
import { scoreAuditResponses } from "@/lib/audits/scoring";
|
||||
import type {
|
||||
AuditKpiSnapshot,
|
||||
AuditSimulationCreateInput,
|
||||
AuditSimulationScoreInput,
|
||||
AuditSimulationView,
|
||||
InstitutionalDossierFreshness,
|
||||
InstitutionalDossierLoadStrategy,
|
||||
InstitutionalDossierPayload,
|
||||
} from "@/lib/audits/types";
|
||||
|
||||
function parseStringArray(value: unknown) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
|
||||
}
|
||||
|
||||
const DEFAULT_DOSSIER_STALE_MINUTES = 180;
|
||||
|
||||
function resolveDossierStaleMinutes() {
|
||||
const raw = process.env.DOSSIER_STALE_MINUTES?.trim();
|
||||
|
||||
if (!raw) {
|
||||
return DEFAULT_DOSSIER_STALE_MINUTES;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return DEFAULT_DOSSIER_STALE_MINUTES;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export const INSTITUTIONAL_DOSSIER_STALE_MINUTES = resolveDossierStaleMinutes();
|
||||
|
||||
function computeDossierFreshness(args: {
|
||||
generatedAt: string;
|
||||
source: "live" | "snapshot";
|
||||
strategy: InstitutionalDossierLoadStrategy;
|
||||
snapshotId: string | null;
|
||||
}) {
|
||||
const generatedMs = new Date(args.generatedAt).getTime();
|
||||
const thresholdMs = INSTITUTIONAL_DOSSIER_STALE_MINUTES * 60 * 1000;
|
||||
const isStale = !Number.isFinite(generatedMs) || Date.now() - generatedMs > thresholdMs;
|
||||
|
||||
return {
|
||||
generatedAt: args.generatedAt,
|
||||
isStale,
|
||||
source: args.source,
|
||||
strategy: args.strategy,
|
||||
snapshotId: args.snapshotId,
|
||||
staleAfterMinutes: INSTITUTIONAL_DOSSIER_STALE_MINUTES,
|
||||
} satisfies InstitutionalDossierFreshness;
|
||||
}
|
||||
|
||||
function syncPayloadGeneratedAt(payload: InstitutionalDossierPayload, generatedAt: string): InstitutionalDossierPayload {
|
||||
return {
|
||||
...payload,
|
||||
generatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function mapSimulation(row: {
|
||||
id: string;
|
||||
name: string;
|
||||
auditType: string;
|
||||
status: "DRAFT" | "COMPLETED";
|
||||
overallScore: number | null;
|
||||
completedAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
sections: Array<{
|
||||
id: string;
|
||||
simulationId: string;
|
||||
key: string;
|
||||
score: number | null;
|
||||
status: "READY" | "WARNING" | "CRITICAL";
|
||||
findingsJson: Prisma.JsonValue | null;
|
||||
recommendationsJson: Prisma.JsonValue | null;
|
||||
}>;
|
||||
responses: Array<{
|
||||
id: string;
|
||||
simulationId: string;
|
||||
questionKey: string;
|
||||
answer: string;
|
||||
evidenceRefsJson: Prisma.JsonValue | null;
|
||||
}>;
|
||||
}): AuditSimulationView {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
auditType: row.auditType,
|
||||
status: row.status,
|
||||
overallScore: row.overallScore,
|
||||
completedAt: row.completedAt ? row.completedAt.toISOString() : null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
sections: row.sections.map((section) => ({
|
||||
id: section.id,
|
||||
simulationId: section.simulationId,
|
||||
key: section.key as AuditSimulationView["sections"][number]["key"],
|
||||
score: section.score,
|
||||
status: section.status,
|
||||
findings: parseStringArray(section.findingsJson),
|
||||
recommendations: parseStringArray(section.recommendationsJson),
|
||||
})),
|
||||
responses: row.responses.map((response) => ({
|
||||
id: response.id,
|
||||
simulationId: response.simulationId,
|
||||
questionKey: response.questionKey,
|
||||
answer: response.answer,
|
||||
evidenceRefs: parseStringArray(response.evidenceRefsJson),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listAuditSimulationsForUser(userId: string) {
|
||||
const rows = await prisma.auditSimulation.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
sections: {
|
||||
orderBy: [{ key: "asc" }],
|
||||
},
|
||||
responses: {
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
},
|
||||
},
|
||||
orderBy: [{ updatedAt: "desc" }],
|
||||
take: 100,
|
||||
});
|
||||
|
||||
return rows.map((row) => mapSimulation(row));
|
||||
}
|
||||
|
||||
export async function createAuditSimulationForUser(userId: string, input: AuditSimulationCreateInput) {
|
||||
const created = await prisma.auditSimulation.create({
|
||||
data: {
|
||||
userId,
|
||||
name: input.name,
|
||||
auditType: input.auditType,
|
||||
status: "DRAFT",
|
||||
},
|
||||
include: {
|
||||
sections: true,
|
||||
responses: true,
|
||||
},
|
||||
});
|
||||
|
||||
return mapSimulation(created);
|
||||
}
|
||||
|
||||
export async function scoreAuditSimulationForUser(userId: string, simulationId: string, input: AuditSimulationScoreInput) {
|
||||
const owned = await prisma.auditSimulation.findFirst({
|
||||
where: {
|
||||
id: simulationId,
|
||||
userId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!owned) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scoring = scoreAuditResponses(input.responses);
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.auditChecklistResponse.deleteMany({
|
||||
where: {
|
||||
simulationId,
|
||||
},
|
||||
}),
|
||||
prisma.auditChecklistResponse.createMany({
|
||||
data: input.responses.map((item) => ({
|
||||
simulationId,
|
||||
questionKey: item.questionKey,
|
||||
answer: item.answer,
|
||||
evidenceRefsJson: (item.evidenceRefs ?? []) as Prisma.InputJsonValue,
|
||||
})),
|
||||
}),
|
||||
...scoring.sections.map((section) =>
|
||||
prisma.auditSimulationSection.upsert({
|
||||
where: {
|
||||
simulationId_key: {
|
||||
simulationId,
|
||||
key: section.key,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
score: section.score,
|
||||
status: section.status,
|
||||
findingsJson: section.findings as Prisma.InputJsonValue,
|
||||
recommendationsJson: section.recommendations as Prisma.InputJsonValue,
|
||||
},
|
||||
create: {
|
||||
simulationId,
|
||||
key: section.key,
|
||||
score: section.score,
|
||||
status: section.status,
|
||||
findingsJson: section.findings as Prisma.InputJsonValue,
|
||||
recommendationsJson: section.recommendations as Prisma.InputJsonValue,
|
||||
},
|
||||
}),
|
||||
),
|
||||
prisma.auditSimulation.update({
|
||||
where: { id: simulationId },
|
||||
data: {
|
||||
overallScore: scoring.overallScore,
|
||||
status: "COMPLETED",
|
||||
completedAt: new Date(),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const updated = await prisma.auditSimulation.findUnique({
|
||||
where: { id: simulationId },
|
||||
include: {
|
||||
sections: {
|
||||
orderBy: [{ key: "asc" }],
|
||||
},
|
||||
responses: {
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return updated ? mapSimulation(updated) : null;
|
||||
}
|
||||
|
||||
export async function getAuditSimulationDetail(userId: string, simulationId: string) {
|
||||
const row = await prisma.auditSimulation.findFirst({
|
||||
where: {
|
||||
id: simulationId,
|
||||
userId,
|
||||
},
|
||||
include: {
|
||||
sections: {
|
||||
orderBy: [{ key: "asc" }],
|
||||
},
|
||||
responses: {
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return row ? mapSimulation(row) : null;
|
||||
}
|
||||
|
||||
export async function getAuditKpisForUser(userId: string): Promise<AuditKpiSnapshot> {
|
||||
const [completedAudits, latestCompleted, contractsCount, contractPdfs, proposalPdfs] = await Promise.all([
|
||||
prisma.auditSimulation.count({
|
||||
where: {
|
||||
userId,
|
||||
status: "COMPLETED",
|
||||
},
|
||||
}),
|
||||
prisma.auditSimulation.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
status: "COMPLETED",
|
||||
},
|
||||
orderBy: [{ completedAt: "desc" }],
|
||||
select: {
|
||||
overallScore: true,
|
||||
},
|
||||
}),
|
||||
prisma.contractRecord.count({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
}),
|
||||
prisma.contractDocument.count({
|
||||
where: {
|
||||
contract: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.proposalDocument.count({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
completedAudits,
|
||||
lastScore: latestCompleted?.overallScore ?? null,
|
||||
registeredContracts: contractsCount,
|
||||
securedPdfs: contractPdfs + proposalPdfs,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildInstitutionalDossier(userId: string): Promise<InstitutionalDossierPayload> {
|
||||
const [proposals, contracts, legalCases, m7] = await Promise.all([
|
||||
prisma.proposal.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
documents: {
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
mimeType: true,
|
||||
sizeBytes: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ updatedAt: "desc" }],
|
||||
take: 200,
|
||||
}),
|
||||
prisma.contractRecord.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
sourceProposal: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
},
|
||||
},
|
||||
documents: {
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
mimeType: true,
|
||||
sizeBytes: true,
|
||||
kind: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
deliverables: {
|
||||
select: { id: true },
|
||||
},
|
||||
payments: {
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
orderBy: [{ updatedAt: "desc" }],
|
||||
take: 200,
|
||||
}),
|
||||
prisma.legalCase.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
documents: {
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
templateKey: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ updatedAt: "desc" }],
|
||||
take: 200,
|
||||
}),
|
||||
getM7DatasetForUser(userId),
|
||||
]);
|
||||
|
||||
const proposalDocuments = proposals.reduce((sum, item) => sum + item.documents.length, 0);
|
||||
const contractDocuments = contracts.reduce((sum, item) => sum + item.documents.length, 0);
|
||||
const legalDocuments = legalCases.reduce((sum, item) => sum + item.documents.length, 0);
|
||||
const highRiskAlerts = m7.tabs.alertas.filter((item) => item.severity === "high").length;
|
||||
|
||||
const components = [
|
||||
{
|
||||
key: "m5" as const,
|
||||
label: "M5 Propuestas",
|
||||
totalItems: proposals.length,
|
||||
withEvidence: proposals.filter((item) => item.documents.length > 0).length,
|
||||
},
|
||||
{
|
||||
key: "m8" as const,
|
||||
label: "M8 Contratos",
|
||||
totalItems: contracts.length,
|
||||
withEvidence: contracts.filter((item) => item.documents.length > 0).length,
|
||||
},
|
||||
{
|
||||
key: "m9" as const,
|
||||
label: "M9 Casos Legales",
|
||||
totalItems: legalCases.length,
|
||||
withEvidence: legalCases.filter((item) => item.documents.length > 0).length,
|
||||
},
|
||||
{
|
||||
key: "m7" as const,
|
||||
label: "M7 Alertas",
|
||||
totalItems: m7.tabs.alertas.length,
|
||||
withEvidence: Math.max(0, m7.tabs.alertas.length - highRiskAlerts),
|
||||
},
|
||||
].map((component) => ({
|
||||
...component,
|
||||
status: component.totalItems > 0 && component.withEvidence >= component.totalItems ? "completo" as const : "incompleto" as const,
|
||||
}));
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
summary: {
|
||||
contracts: contracts.length,
|
||||
contractDocuments,
|
||||
proposals: proposals.length,
|
||||
proposalDocuments,
|
||||
legalCases: legalCases.length,
|
||||
legalDocuments,
|
||||
highRiskAlerts,
|
||||
},
|
||||
components,
|
||||
contracts: contracts.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
status: item.status,
|
||||
deliverables: item.deliverables.length,
|
||||
payments: item.payments.length,
|
||||
documents: item.documents.length,
|
||||
links: [
|
||||
{
|
||||
label: "Abrir contrato en M8",
|
||||
href: `/gestion-contratos?contractId=${encodeURIComponent(item.id)}`,
|
||||
},
|
||||
{
|
||||
label: "Abrir continuidad legal en M9",
|
||||
href: `/proteccion-legal?contractId=${encodeURIComponent(item.id)}`,
|
||||
},
|
||||
...(item.sourceProposal
|
||||
? [
|
||||
{
|
||||
label: "Ver propuesta fuente en M5",
|
||||
href: `/gestion-licitaciones/${encodeURIComponent(item.sourceProposal.id)}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
documentEntries: item.documents.map((document) => ({
|
||||
id: document.id,
|
||||
fileName: document.fileName,
|
||||
kind: document.kind,
|
||||
mimeType: document.mimeType,
|
||||
sizeBytes: document.sizeBytes,
|
||||
createdAt: document.createdAt.toISOString(),
|
||||
traceLabel: item.sourceProposal
|
||||
? `Evidencia contractual de M8 ligada a propuesta M5: ${item.sourceProposal.title}`
|
||||
: "Evidencia contractual cargada en M8.",
|
||||
links: [
|
||||
{
|
||||
label: "Ver contexto en M8",
|
||||
href: `/gestion-contratos?contractId=${encodeURIComponent(item.id)}&documentId=${encodeURIComponent(document.id)}`,
|
||||
},
|
||||
{
|
||||
label: "Descargar evidencia",
|
||||
href: `/api/contracts/${encodeURIComponent(item.id)}/documents/${encodeURIComponent(document.id)}`,
|
||||
},
|
||||
...(item.sourceProposal
|
||||
? [
|
||||
{
|
||||
label: "Rastrear origen en M5",
|
||||
href: `/gestion-licitaciones/${encodeURIComponent(item.sourceProposal.id)}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
})),
|
||||
})),
|
||||
proposals: proposals.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
status: item.status,
|
||||
documents: item.documents.length,
|
||||
links: [
|
||||
{
|
||||
label: "Abrir propuesta en M5",
|
||||
href: `/gestion-licitaciones/${encodeURIComponent(item.id)}`,
|
||||
},
|
||||
...(item.sourceLicitationId
|
||||
? [
|
||||
{
|
||||
label: "Ver licitacion origen",
|
||||
href: `/licitations/${encodeURIComponent(item.sourceLicitationId)}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
documentEntries: item.documents.map((document) => ({
|
||||
id: document.id,
|
||||
fileName: document.fileName,
|
||||
kind: null,
|
||||
mimeType: document.mimeType,
|
||||
sizeBytes: document.sizeBytes,
|
||||
createdAt: document.createdAt.toISOString(),
|
||||
traceLabel: item.sourceLicitationId
|
||||
? `Documento de propuesta M5 vinculado a licitacion ${item.sourceLicitationId}.`
|
||||
: "Documento de propuesta cargado en M5.",
|
||||
links: [
|
||||
{
|
||||
label: "Ver contexto en M5",
|
||||
href: `/gestion-licitaciones/${encodeURIComponent(item.id)}?documentId=${encodeURIComponent(document.id)}`,
|
||||
},
|
||||
{
|
||||
label: "Descargar evidencia",
|
||||
href: `/api/proposals/${encodeURIComponent(item.id)}/documents/${encodeURIComponent(document.id)}`,
|
||||
},
|
||||
],
|
||||
})),
|
||||
})),
|
||||
legal: legalCases.map((item) => ({
|
||||
id: item.id,
|
||||
caseType: item.caseType,
|
||||
severity: item.severity,
|
||||
status: item.status,
|
||||
documents: item.documents.length,
|
||||
links: [
|
||||
{
|
||||
label: "Abrir caso en M9",
|
||||
href: item.contractId
|
||||
? `/proteccion-legal?contractId=${encodeURIComponent(item.contractId)}`
|
||||
: "/proteccion-legal",
|
||||
},
|
||||
...(item.contractId
|
||||
? [
|
||||
{
|
||||
label: "Ver contrato asociado en M8",
|
||||
href: `/gestion-contratos?contractId=${encodeURIComponent(item.contractId)}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
documentEntries: item.documents.map((document) => ({
|
||||
id: document.id,
|
||||
fileName: document.title,
|
||||
kind: document.templateKey,
|
||||
mimeType: "text/plain",
|
||||
sizeBytes: 0,
|
||||
createdAt: document.createdAt.toISOString(),
|
||||
traceLabel: item.contractId
|
||||
? `Escrito legal de M9 vinculado al contrato ${item.contractId}.`
|
||||
: "Escrito legal generado en M9.",
|
||||
links: [
|
||||
{
|
||||
label: "Ver contexto en M9",
|
||||
href: item.contractId
|
||||
? `/proteccion-legal?contractId=${encodeURIComponent(item.contractId)}`
|
||||
: "/proteccion-legal",
|
||||
},
|
||||
],
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshInstitutionalDossierSnapshot(userId: string) {
|
||||
const payload = await buildInstitutionalDossier(userId);
|
||||
|
||||
const snapshot = await prisma.institutionalDossierSnapshot.create({
|
||||
data: {
|
||||
userId,
|
||||
payloadJson: payload as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
const generatedAt = snapshot.generatedAt.toISOString();
|
||||
const syncedPayload = syncPayloadGeneratedAt(payload, generatedAt);
|
||||
|
||||
return {
|
||||
id: snapshot.id,
|
||||
generatedAt,
|
||||
payload: syncedPayload,
|
||||
freshness: computeDossierFreshness({
|
||||
generatedAt,
|
||||
source: "snapshot",
|
||||
strategy: "snapshot",
|
||||
snapshotId: snapshot.id,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLatestInstitutionalDossierSnapshot(userId: string) {
|
||||
const latest = await prisma.institutionalDossierSnapshot.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
orderBy: [{ generatedAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
generatedAt: true,
|
||||
payloadJson: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!latest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const generatedAt = latest.generatedAt.toISOString();
|
||||
const payload = syncPayloadGeneratedAt(latest.payloadJson as InstitutionalDossierPayload, generatedAt);
|
||||
|
||||
return {
|
||||
id: latest.id,
|
||||
generatedAt,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getInstitutionalDossierForUser(userId: string, strategy: InstitutionalDossierLoadStrategy = "live") {
|
||||
if (strategy === "snapshot") {
|
||||
const latest = await getLatestInstitutionalDossierSnapshot(userId);
|
||||
|
||||
if (latest) {
|
||||
return {
|
||||
dossier: latest.payload,
|
||||
freshness: computeDossierFreshness({
|
||||
generatedAt: latest.generatedAt,
|
||||
source: "snapshot",
|
||||
strategy,
|
||||
snapshotId: latest.id,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const live = await buildInstitutionalDossier(userId);
|
||||
const generatedAt = live.generatedAt;
|
||||
|
||||
return {
|
||||
dossier: live,
|
||||
freshness: computeDossierFreshness({
|
||||
generatedAt,
|
||||
source: "live",
|
||||
strategy,
|
||||
snapshotId: null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
176
src/lib/audits/types.ts
Normal file
176
src/lib/audits/types.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import type { AuditSimulationSectionStatus, AuditSimulationStatus } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
export const AUDIT_SECTION_KEYS = [
|
||||
"cumplimiento_fiscal",
|
||||
"cumplimiento_laboral",
|
||||
"documentacion_legal",
|
||||
"control_operativo",
|
||||
"transparencia_financiera",
|
||||
] as const;
|
||||
|
||||
export type AuditSectionKey = (typeof AUDIT_SECTION_KEYS)[number];
|
||||
|
||||
export type AuditKpiSnapshot = {
|
||||
completedAudits: number;
|
||||
lastScore: number | null;
|
||||
registeredContracts: number;
|
||||
securedPdfs: number;
|
||||
};
|
||||
|
||||
export type AuditSimulationView = {
|
||||
id: string;
|
||||
name: string;
|
||||
auditType: string;
|
||||
status: AuditSimulationStatus;
|
||||
overallScore: number | null;
|
||||
completedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
sections: AuditSimulationSectionView[];
|
||||
responses: AuditChecklistResponseView[];
|
||||
};
|
||||
|
||||
export type AuditSimulationSectionView = {
|
||||
id: string;
|
||||
simulationId: string;
|
||||
key: AuditSectionKey;
|
||||
score: number | null;
|
||||
status: AuditSimulationSectionStatus;
|
||||
findings: string[];
|
||||
recommendations: string[];
|
||||
};
|
||||
|
||||
export type AuditChecklistResponseView = {
|
||||
id: string;
|
||||
simulationId: string;
|
||||
questionKey: string;
|
||||
answer: string;
|
||||
evidenceRefs: string[];
|
||||
};
|
||||
|
||||
export type AuditSectionScore = {
|
||||
key: AuditSectionKey;
|
||||
score: number;
|
||||
status: AuditSimulationSectionStatus;
|
||||
findings: string[];
|
||||
recommendations: string[];
|
||||
};
|
||||
|
||||
export type AuditScoreOutput = {
|
||||
overallScore: number;
|
||||
sections: AuditSectionScore[];
|
||||
};
|
||||
|
||||
export type InstitutionalDossierComponentStatus = {
|
||||
key: "m5" | "m8" | "m9" | "m7";
|
||||
label: string;
|
||||
status: "completo" | "incompleto";
|
||||
totalItems: number;
|
||||
withEvidence: number;
|
||||
};
|
||||
|
||||
export type InstitutionalDossierLoadStrategy = "live" | "snapshot";
|
||||
|
||||
export type InstitutionalDossierEvidenceLink = {
|
||||
label: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
export type InstitutionalDossierEvidenceDocument = {
|
||||
id: string;
|
||||
fileName: string;
|
||||
kind: string | null;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
createdAt: string;
|
||||
traceLabel: string;
|
||||
links: InstitutionalDossierEvidenceLink[];
|
||||
};
|
||||
|
||||
export type InstitutionalDossierPayload = {
|
||||
generatedAt: string;
|
||||
summary: {
|
||||
contracts: number;
|
||||
contractDocuments: number;
|
||||
proposals: number;
|
||||
proposalDocuments: number;
|
||||
legalCases: number;
|
||||
legalDocuments: number;
|
||||
highRiskAlerts: number;
|
||||
};
|
||||
components: InstitutionalDossierComponentStatus[];
|
||||
contracts: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
deliverables: number;
|
||||
payments: number;
|
||||
documents: number;
|
||||
links: InstitutionalDossierEvidenceLink[];
|
||||
documentEntries: InstitutionalDossierEvidenceDocument[];
|
||||
}>;
|
||||
proposals: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
documents: number;
|
||||
links: InstitutionalDossierEvidenceLink[];
|
||||
documentEntries: InstitutionalDossierEvidenceDocument[];
|
||||
}>;
|
||||
legal: Array<{
|
||||
id: string;
|
||||
caseType: string;
|
||||
severity: string;
|
||||
status: string;
|
||||
documents: number;
|
||||
links: InstitutionalDossierEvidenceLink[];
|
||||
documentEntries: InstitutionalDossierEvidenceDocument[];
|
||||
}>;
|
||||
aiFindings?: AuditAiFindings | null;
|
||||
};
|
||||
|
||||
export type InstitutionalDossierFreshness = {
|
||||
generatedAt: string;
|
||||
isStale: boolean;
|
||||
source: "live" | "snapshot";
|
||||
strategy: InstitutionalDossierLoadStrategy;
|
||||
snapshotId: string | null;
|
||||
staleAfterMinutes: number;
|
||||
};
|
||||
|
||||
export type AuditAiFindings = {
|
||||
auditorLikelyFindings: {
|
||||
area: string;
|
||||
finding: string;
|
||||
severity: "alto" | "medio" | "bajo";
|
||||
}[];
|
||||
missingEvidence: string[];
|
||||
topRisks: string[];
|
||||
remediationPlan: {
|
||||
step: string;
|
||||
priority: "alta" | "media" | "baja";
|
||||
ownerSuggestion: string;
|
||||
targetDate: string;
|
||||
}[];
|
||||
confidence: "low" | "medium" | "high";
|
||||
suggestionId: string;
|
||||
};
|
||||
|
||||
export const AuditSimulationCreateInputSchema = z.object({
|
||||
name: z.string().trim().min(2).max(220),
|
||||
auditType: z.string().trim().min(2).max(120),
|
||||
});
|
||||
|
||||
export const AuditSimulationScoreInputSchema = z.object({
|
||||
responses: z.array(
|
||||
z.object({
|
||||
questionKey: z.string().trim().min(2).max(120),
|
||||
answer: z.string().trim().min(1).max(120),
|
||||
evidenceRefs: z.array(z.string().trim().min(1).max(260)).optional(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export type AuditSimulationCreateInput = z.infer<typeof AuditSimulationCreateInputSchema>;
|
||||
export type AuditSimulationScoreInput = z.infer<typeof AuditSimulationScoreInputSchema>;
|
||||
67
src/lib/auth/module-access.ts
Normal file
67
src/lib/auth/module-access.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import "server-only";
|
||||
|
||||
import { type ModulePlanKey, type UserRole } from "@prisma/client";
|
||||
import { isAdminIdentity } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getModulePlanByModuleNumber } from "@/lib/payments/plans";
|
||||
|
||||
function isSubscriptionValid(subscription: { isActive: boolean; startsAt: Date; expiresAt: Date | null }, now: Date) {
|
||||
if (!subscription.isActive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (subscription.startsAt.getTime() > now.getTime()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (subscription.expiresAt && subscription.expiresAt.getTime() < now.getTime()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function getActivePlanKeysForUser(userId: string) {
|
||||
const now = new Date();
|
||||
|
||||
const subscriptions = await prisma.modulePlanSubscription.findMany({
|
||||
where: {
|
||||
userId,
|
||||
isActive: true,
|
||||
},
|
||||
select: {
|
||||
planKey: true,
|
||||
isActive: true,
|
||||
startsAt: true,
|
||||
expiresAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return new Set(
|
||||
subscriptions.filter((item) => isSubscriptionValid(item, now)).map((item) => item.planKey as ModulePlanKey),
|
||||
);
|
||||
}
|
||||
|
||||
export async function hasAnyPaidModuleAccess(userId: string, email: string, role: UserRole) {
|
||||
if (isAdminIdentity(email, role)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const plans = await getActivePlanKeysForUser(userId);
|
||||
return plans.size > 0;
|
||||
}
|
||||
|
||||
export async function hasModuleAccess(user: { id: string; email: string; role: UserRole }, moduleNumber: number) {
|
||||
if (isAdminIdentity(user.email, user.role)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const targetPlan = getModulePlanByModuleNumber(moduleNumber);
|
||||
|
||||
if (!targetPlan) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const activePlans = await getActivePlanKeysForUser(user.id);
|
||||
return activePlans.has(targetPlan.key);
|
||||
}
|
||||
117
src/lib/compliance/__tests__/m3-m5-m7.integration.test.ts
Normal file
117
src/lib/compliance/__tests__/m3-m5-m7.integration.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildM7Dataset, computeM3Counters } from "@/lib/compliance/m7";
|
||||
import { ProposalWorkflowStateSchema } from "@/lib/proposals/workflow-state";
|
||||
|
||||
function buildWorkflowWithDeadline(dateIso: string) {
|
||||
return ProposalWorkflowStateSchema.parse({
|
||||
step: 5,
|
||||
info: {
|
||||
title: "Licitacion infraestructura",
|
||||
issuingEntity: "Gobierno municipal",
|
||||
procedureType: "Publica",
|
||||
jurisdiction: "Estatal",
|
||||
state: "Nuevo Leon",
|
||||
municipality: "Monterrey",
|
||||
sector: "Obra publica",
|
||||
description: "Proceso de prueba",
|
||||
convocatoriaUrl: "https://example.com/convocatoria",
|
||||
},
|
||||
requirements: [
|
||||
{
|
||||
id: "req-critical",
|
||||
title: "Garantia critica",
|
||||
description: "Incumplimiento puede descalificar la propuesta",
|
||||
category: "legal",
|
||||
mandatory: true,
|
||||
source: "critical_requirement",
|
||||
status: "pending",
|
||||
note: "",
|
||||
evidences: [],
|
||||
},
|
||||
],
|
||||
technicalSections: [{ id: "tech-1", title: "Alcance tecnico", description: "Detalle tecnico", completed: true }],
|
||||
economicItems: [{ id: "eco-1", concept: "Servicio", unit: "lote", quantity: 1, unitPrice: 1000 }],
|
||||
milestones: [{ id: "mil-1", title: "Entrega", dateIso, location: "", note: "", source: "manual" }],
|
||||
signatureCompliance: {
|
||||
policyStatus: "condicionado",
|
||||
policyName: "Politica piloto",
|
||||
jurisdictionLabel: "Nuevo Leon",
|
||||
sourceUrl: "https://www.nl.gob.mx/",
|
||||
minimumEvidence: ["Acuse"],
|
||||
notes: "",
|
||||
validatedByLegal: false,
|
||||
},
|
||||
readyMarked: false,
|
||||
});
|
||||
}
|
||||
|
||||
describe("M3 -> M5 -> M7 integration", () => {
|
||||
it("combines M3 preferences with active M5 workflow into M7 KPI snapshot", () => {
|
||||
const now = new Date("2026-04-02T10:00:00.000Z");
|
||||
const m3 = computeM3Counters({
|
||||
totalOpenLicitations: 8,
|
||||
preferences: [{ status: "REVIEWED" }, { status: "INTERESTED" }, { status: "INTERESTED" }],
|
||||
activeLinked: 1,
|
||||
});
|
||||
|
||||
const dataset = buildM7Dataset({
|
||||
proposals: [
|
||||
{
|
||||
id: "proposal-1",
|
||||
title: "Propuesta M5 activa",
|
||||
status: "IN_PROGRESS",
|
||||
sourceLicitationId: "licit-1",
|
||||
workflowDraft: buildWorkflowWithDeadline("2026-04-03T09:00:00.000Z"),
|
||||
updatedAt: now,
|
||||
},
|
||||
],
|
||||
m3,
|
||||
dueVerifications: [],
|
||||
now,
|
||||
});
|
||||
|
||||
expect(dataset.kpis.activeLicitations).toBe(1);
|
||||
expect(dataset.m3States.consulted).toBe(1);
|
||||
expect(dataset.m3States.interested).toBe(2);
|
||||
expect(dataset.m3States.active).toBe(1);
|
||||
expect(dataset.tabs.checklist.length).toBe(1);
|
||||
expect(dataset.tabs.panelKpi.some((item) => item.label === "M3 Consultadas")).toBe(true);
|
||||
});
|
||||
|
||||
it("raises critical pending alert when high-risk pending items meet near deadlines", () => {
|
||||
const now = new Date("2026-04-02T10:00:00.000Z");
|
||||
const m3 = computeM3Counters({
|
||||
totalOpenLicitations: 3,
|
||||
preferences: [],
|
||||
activeLinked: 1,
|
||||
});
|
||||
|
||||
const dataset = buildM7Dataset({
|
||||
proposals: [
|
||||
{
|
||||
id: "proposal-critical",
|
||||
title: "Propuesta critica",
|
||||
status: "DRAFT",
|
||||
sourceLicitationId: "licit-2",
|
||||
workflowDraft: buildWorkflowWithDeadline("2026-04-02T12:00:00.000Z"),
|
||||
updatedAt: now,
|
||||
},
|
||||
],
|
||||
m3,
|
||||
dueVerifications: [
|
||||
{
|
||||
sourceId: "nl-reg",
|
||||
sourceTitle: "Reglamento NL",
|
||||
authorityName: "Gobierno NL",
|
||||
dueAt: "2026-04-02T11:00:00.000Z",
|
||||
overdue: false,
|
||||
},
|
||||
],
|
||||
now,
|
||||
});
|
||||
|
||||
expect(dataset.kpis.criticalPending).toBeGreaterThan(0);
|
||||
expect(dataset.tabs.alertas.some((item) => item.kind === "critical_requirement_pending")).toBe(true);
|
||||
expect(dataset.tabs.alertas.some((item) => item.kind === "deadline_soon")).toBe(true);
|
||||
});
|
||||
});
|
||||
124
src/lib/compliance/__tests__/m7.test.ts
Normal file
124
src/lib/compliance/__tests__/m7.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildM7Dataset, computeM3Counters } from "@/lib/compliance/m7";
|
||||
import { ProposalWorkflowStateSchema } from "@/lib/proposals/workflow-state";
|
||||
|
||||
function buildWorkflow(overrides?: Partial<ReturnType<typeof ProposalWorkflowStateSchema.parse>>) {
|
||||
const base = ProposalWorkflowStateSchema.parse({
|
||||
step: 5,
|
||||
info: {
|
||||
title: "Proceso de prueba",
|
||||
issuingEntity: "Entidad",
|
||||
procedureType: "Publica",
|
||||
jurisdiction: "Estatal",
|
||||
state: "Nuevo Leon",
|
||||
municipality: "Monterrey",
|
||||
sector: "Tecnologia",
|
||||
description: "Servicio",
|
||||
convocatoriaUrl: "https://example.com",
|
||||
},
|
||||
requirements: [
|
||||
{
|
||||
id: "req-1",
|
||||
title: "Requisito critico",
|
||||
description: "Debe cumplirse",
|
||||
category: "legal",
|
||||
mandatory: true,
|
||||
source: "critical_requirement",
|
||||
status: "pending",
|
||||
note: "",
|
||||
evidences: [],
|
||||
},
|
||||
],
|
||||
technicalSections: [
|
||||
{
|
||||
id: "tech-1",
|
||||
title: "Tecnica",
|
||||
description: "Seccion",
|
||||
completed: true,
|
||||
},
|
||||
],
|
||||
economicItems: [
|
||||
{
|
||||
id: "eco-1",
|
||||
concept: "Concepto",
|
||||
unit: "pieza",
|
||||
quantity: 1,
|
||||
unitPrice: 100,
|
||||
},
|
||||
],
|
||||
milestones: [
|
||||
{
|
||||
id: "mil-1",
|
||||
title: "Entrega",
|
||||
dateIso: "2026-04-03T10:00:00.000Z",
|
||||
location: "",
|
||||
note: "",
|
||||
source: "manual",
|
||||
},
|
||||
],
|
||||
signatureCompliance: {
|
||||
policyStatus: "requiere_validacion_legal",
|
||||
policyName: "Politica",
|
||||
jurisdictionLabel: "Nuevo Leon",
|
||||
sourceUrl: "",
|
||||
minimumEvidence: ["Opinion legal"],
|
||||
notes: "",
|
||||
validatedByLegal: false,
|
||||
},
|
||||
readyMarked: false,
|
||||
});
|
||||
|
||||
return {
|
||||
...base,
|
||||
...(overrides ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildM7Dataset", () => {
|
||||
it("computes KPIs and tabs using M3+M5 inputs", () => {
|
||||
const now = new Date("2026-04-02T10:00:00.000Z");
|
||||
const m3 = computeM3Counters({
|
||||
totalOpenLicitations: 10,
|
||||
preferences: [{ status: "REVIEWED" }, { status: "INTERESTED" }],
|
||||
activeLinked: 2,
|
||||
});
|
||||
|
||||
const dataset = buildM7Dataset({
|
||||
proposals: [
|
||||
{
|
||||
id: "p-1",
|
||||
title: "Propuesta activa",
|
||||
status: "IN_PROGRESS",
|
||||
sourceLicitationId: "l-1",
|
||||
workflowDraft: buildWorkflow(),
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "p-2",
|
||||
title: "Archivada",
|
||||
status: "ARCHIVED",
|
||||
sourceLicitationId: "l-2",
|
||||
workflowDraft: buildWorkflow(),
|
||||
updatedAt: now,
|
||||
},
|
||||
],
|
||||
m3,
|
||||
dueVerifications: [
|
||||
{
|
||||
sourceId: "nl-1",
|
||||
sourceTitle: "Reglamento NL",
|
||||
authorityName: "Gobierno NL",
|
||||
dueAt: "2026-04-04T09:00:00.000Z",
|
||||
overdue: false,
|
||||
},
|
||||
],
|
||||
now,
|
||||
});
|
||||
|
||||
expect(dataset.kpis.activeLicitations).toBe(1);
|
||||
expect(dataset.kpis.upcoming7Days).toBeGreaterThanOrEqual(2);
|
||||
expect(dataset.kpis.criticalPending).toBeGreaterThan(0);
|
||||
expect(dataset.m3States.consulted).toBe(1);
|
||||
expect(dataset.m3States.interested).toBe(1);
|
||||
});
|
||||
});
|
||||
78
src/lib/compliance/__tests__/regulations.test.ts
Normal file
78
src/lib/compliance/__tests__/regulations.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
listRegulationChangeAlerts,
|
||||
resetRegulationsStateForTests,
|
||||
runPeriodicNormativeVerification,
|
||||
verifyOfficialNormativeSources,
|
||||
} from "@/lib/compliance/regulations";
|
||||
|
||||
function buildResponse(etag: string, status = 200) {
|
||||
return new Response("", {
|
||||
status,
|
||||
headers: {
|
||||
etag,
|
||||
"last-modified": "Thu, 02 Apr 2026 00:00:00 GMT",
|
||||
"content-length": "100",
|
||||
"content-type": "text/html",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("regulations verification", () => {
|
||||
beforeEach(async () => {
|
||||
await resetRegulationsStateForTests();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-04-02T10:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
await resetRegulationsStateForTests();
|
||||
});
|
||||
|
||||
it("detects source changes and emits regulation update alerts", async () => {
|
||||
const fetchMock = vi
|
||||
.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>()
|
||||
.mockResolvedValueOnce(buildResponse("v1"))
|
||||
.mockResolvedValueOnce(buildResponse("v2"));
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await verifyOfficialNormativeSources(["nl-reglamento-adquisiciones-estatal"]);
|
||||
const changed = await verifyOfficialNormativeSources(["nl-reglamento-adquisiciones-estatal"]);
|
||||
|
||||
expect(changed[0]?.status).toBe("warning");
|
||||
expect(changed[0]?.changed).toBe(true);
|
||||
|
||||
const alerts = await listRegulationChangeAlerts(new Date("2026-04-02T10:30:00.000Z"), 30);
|
||||
expect(alerts.some((item) => item.kind === "normative_regulation_update" && item.severity === "high")).toBe(true);
|
||||
});
|
||||
|
||||
it("records failed verifications and emits pending alerts", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>().mockRejectedValue(new Error("timeout")),
|
||||
);
|
||||
|
||||
const result = await verifyOfficialNormativeSources(["spgg-reglamento-adquisiciones"]);
|
||||
expect(result[0]?.status).toBe("failed");
|
||||
|
||||
const alerts = await listRegulationChangeAlerts(new Date("2026-04-02T11:00:00.000Z"), 30);
|
||||
expect(alerts.some((item) => item.kind === "normative_verification_pending")).toBe(true);
|
||||
});
|
||||
|
||||
it("runs periodic verification only when due", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>().mockResolvedValue(buildResponse("v1")));
|
||||
|
||||
const first = await runPeriodicNormativeVerification(new Date("2026-04-02T10:00:00.000Z"));
|
||||
expect(first.checked).toBe(true);
|
||||
expect(first.dueCount).toBeGreaterThan(0);
|
||||
expect(first.results.length).toBe(first.dueCount);
|
||||
|
||||
const second = await runPeriodicNormativeVerification(new Date("2026-04-02T10:05:00.000Z"));
|
||||
expect(second.checked).toBe(false);
|
||||
expect(second.dueCount).toBe(0);
|
||||
});
|
||||
});
|
||||
35
src/lib/compliance/__tests__/signature-policy.test.ts
Normal file
35
src/lib/compliance/__tests__/signature-policy.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { evaluateSignaturePolicy } from "@/lib/compliance/signature-policy";
|
||||
|
||||
describe("evaluateSignaturePolicy", () => {
|
||||
it("returns conservative municipal rule for San Pedro", () => {
|
||||
const evaluation = evaluateSignaturePolicy({
|
||||
stateCode: "NL",
|
||||
municipalityName: "San Pedro Garza Garcia",
|
||||
documentType: "BASES_LICITACION",
|
||||
});
|
||||
|
||||
expect(evaluation.policyStatus).toBe("requiere_validacion_legal");
|
||||
expect(evaluation.evidenceRequired.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns conditioned rule for Nuevo Leon state scope", () => {
|
||||
const evaluation = evaluateSignaturePolicy({
|
||||
stateCode: "NL",
|
||||
municipalityName: "Monterrey",
|
||||
documentType: "CONVOCATORIA",
|
||||
});
|
||||
|
||||
expect(evaluation.policyStatus).toBe("condicionado");
|
||||
expect(evaluation.policyName.toLowerCase()).toContain("firma electronica");
|
||||
});
|
||||
|
||||
it("defaults to legal validation when jurisdiction is unknown", () => {
|
||||
const evaluation = evaluateSignaturePolicy({
|
||||
stateName: "Estado no configurado",
|
||||
municipalityName: "Municipio X",
|
||||
});
|
||||
|
||||
expect(evaluation.policyStatus).toBe("requiere_validacion_legal");
|
||||
});
|
||||
});
|
||||
389
src/lib/compliance/m7.ts
Normal file
389
src/lib/compliance/m7.ts
Normal file
@@ -0,0 +1,389 @@
|
||||
import type { LicitationReviewStatus, ProposalStatus } from "@prisma/client";
|
||||
import { computeWorkflowGate, ProposalWorkflowStateSchema, type ProposalWorkflowState } from "@/lib/proposals/workflow-state";
|
||||
import type {
|
||||
ComplianceAlertView,
|
||||
ComplianceChecklistItem,
|
||||
ComplianceDeadlineItem,
|
||||
CompliancePanelItem,
|
||||
M7Dataset,
|
||||
SignaturePolicyStatus,
|
||||
} from "@/lib/compliance/types";
|
||||
|
||||
type ProposalM7Input = {
|
||||
id: string;
|
||||
title: string;
|
||||
status: ProposalStatus;
|
||||
sourceLicitationId: string | null;
|
||||
workflowDraft: unknown;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
type M3Counters = {
|
||||
totalOpenLicitations: number;
|
||||
reviewed: number;
|
||||
interested: number;
|
||||
activeLinked: number;
|
||||
};
|
||||
|
||||
type VerificationDueItem = {
|
||||
sourceId: string;
|
||||
sourceTitle: string;
|
||||
authorityName: string;
|
||||
dueAt: string;
|
||||
overdue: boolean;
|
||||
};
|
||||
|
||||
const CRITICAL_DEADLINE_HOURS = 72;
|
||||
|
||||
function parseWorkflowDraft(value: unknown) {
|
||||
const parsed = ProposalWorkflowStateSchema.safeParse(value);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
function isCriticalRequirement(requirement: ProposalWorkflowState["requirements"][number]) {
|
||||
if (requirement.source === "critical_requirement") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const text = `${requirement.title} ${requirement.description}`.toLowerCase();
|
||||
return /\bcritic|alto\b|descalific|inhabilit|sancion/.test(text);
|
||||
}
|
||||
|
||||
function toSignatureStatus(workflow: ProposalWorkflowState): SignaturePolicyStatus {
|
||||
const candidate = (workflow as ProposalWorkflowState & { signatureCompliance?: { policyStatus?: SignaturePolicyStatus } }).signatureCompliance;
|
||||
return candidate?.policyStatus ?? "requiere_validacion_legal";
|
||||
}
|
||||
|
||||
export function isOperationalProposal(status: ProposalStatus) {
|
||||
return status === "DRAFT" || status === "IN_PROGRESS";
|
||||
}
|
||||
|
||||
function toDeadlineStatus(dateIso: string, now: Date) {
|
||||
const due = new Date(dateIso);
|
||||
if (Number.isNaN(due.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
due,
|
||||
overdue: due.getTime() < now.getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildChecklistRows(proposals: Array<ProposalM7Input & { workflow: ProposalWorkflowState }>): ComplianceChecklistItem[] {
|
||||
return proposals.map((proposal) => {
|
||||
const gate = computeWorkflowGate(proposal.workflow);
|
||||
const completionRate = gate.mandatoryCount > 0 ? Math.round((gate.resolvedCount / gate.mandatoryCount) * 100) : 0;
|
||||
|
||||
return {
|
||||
proposalId: proposal.id,
|
||||
proposalTitle: proposal.title,
|
||||
mandatoryResolved: gate.resolvedCount,
|
||||
mandatoryTotal: gate.mandatoryCount,
|
||||
completionRate,
|
||||
signaturePolicyStatus: toSignatureStatus(proposal.workflow),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildProposalDeadlines(proposals: Array<ProposalM7Input & { workflow: ProposalWorkflowState }>, now: Date): ComplianceDeadlineItem[] {
|
||||
const rows: ComplianceDeadlineItem[] = [];
|
||||
|
||||
for (const proposal of proposals) {
|
||||
for (const milestone of proposal.workflow.milestones) {
|
||||
if (!milestone.dateIso) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const timeline = toDeadlineStatus(milestone.dateIso, now);
|
||||
if (!timeline) {
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.push({
|
||||
id: `${proposal.id}-${milestone.id}`,
|
||||
proposalId: proposal.id,
|
||||
licitationId: proposal.sourceLicitationId,
|
||||
title: milestone.title,
|
||||
description: milestone.note || `Hito de ${proposal.title}`,
|
||||
dueAt: timeline.due.toISOString(),
|
||||
source: "milestone",
|
||||
status: timeline.overdue ? "overdue" : "upcoming",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function buildVerificationDeadlines(items: VerificationDueItem[]): ComplianceDeadlineItem[] {
|
||||
return items.map((item) => ({
|
||||
id: `verification-${item.sourceId}`,
|
||||
proposalId: null,
|
||||
licitationId: item.sourceId,
|
||||
title: `Verificacion normativa: ${item.sourceTitle}`,
|
||||
description: `${item.authorityName} (${item.overdue ? "atrasada" : "programada"})`,
|
||||
dueAt: item.dueAt,
|
||||
source: "normative_verification",
|
||||
status: item.overdue ? "overdue" : "upcoming",
|
||||
}));
|
||||
}
|
||||
|
||||
function calculateCriticalPending(proposals: Array<ProposalM7Input & { workflow: ProposalWorkflowState }>, deadlines: ComplianceDeadlineItem[], now: Date) {
|
||||
const deadlineByProposal = new Map<string, ComplianceDeadlineItem[]>();
|
||||
|
||||
for (const item of deadlines) {
|
||||
if (!item.proposalId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = deadlineByProposal.get(item.proposalId) ?? [];
|
||||
existing.push(item);
|
||||
deadlineByProposal.set(item.proposalId, existing);
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
|
||||
for (const proposal of proposals) {
|
||||
const proposalDeadlines = deadlineByProposal.get(proposal.id) ?? [];
|
||||
const hasUrgentDeadline = proposalDeadlines.some((deadline) => {
|
||||
const due = new Date(deadline.dueAt);
|
||||
if (Number.isNaN(due.getTime())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const diffHours = (due.getTime() - now.getTime()) / 3_600_000;
|
||||
return diffHours <= CRITICAL_DEADLINE_HOURS;
|
||||
});
|
||||
|
||||
if (!hasUrgentDeadline) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pendingCriticalRequirements = proposal.workflow.requirements.filter((item) => item.status === "pending" && isCriticalRequirement(item));
|
||||
total += pendingCriticalRequirements.length;
|
||||
|
||||
if (toSignatureStatus(proposal.workflow) === "requiere_validacion_legal") {
|
||||
total += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
function buildAlerts(args: {
|
||||
criticalPending: number;
|
||||
deadlines: ComplianceDeadlineItem[];
|
||||
checklist: ComplianceChecklistItem[];
|
||||
now: Date;
|
||||
}): ComplianceAlertView[] {
|
||||
const alerts: ComplianceAlertView[] = [];
|
||||
|
||||
for (const deadline of args.deadlines) {
|
||||
const due = new Date(deadline.dueAt);
|
||||
const diffHours = (due.getTime() - args.now.getTime()) / 3_600_000;
|
||||
|
||||
if (deadline.status === "overdue") {
|
||||
alerts.push({
|
||||
id: `alert-overdue-${deadline.id}`,
|
||||
proposalId: deadline.proposalId,
|
||||
licitationId: deadline.licitationId,
|
||||
title: `Vencido: ${deadline.title}`,
|
||||
description: deadline.description,
|
||||
severity: "high",
|
||||
kind: "deadline_overdue",
|
||||
dueAt: deadline.dueAt,
|
||||
createdAt: args.now.toISOString(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (diffHours <= 72) {
|
||||
alerts.push({
|
||||
id: `alert-soon-${deadline.id}`,
|
||||
proposalId: deadline.proposalId,
|
||||
licitationId: deadline.licitationId,
|
||||
title: `Vence pronto: ${deadline.title}`,
|
||||
description: deadline.description,
|
||||
severity: diffHours <= 24 ? "high" : "medium",
|
||||
kind: "deadline_soon",
|
||||
dueAt: deadline.dueAt,
|
||||
createdAt: args.now.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (args.criticalPending > 0) {
|
||||
alerts.unshift({
|
||||
id: "alert-critical-pending",
|
||||
proposalId: null,
|
||||
licitationId: null,
|
||||
title: "Pendientes criticos detectados",
|
||||
description: `${args.criticalPending} pendientes combinan severidad alta con plazo inminente o vencido.`,
|
||||
severity: "high",
|
||||
kind: "critical_requirement_pending",
|
||||
dueAt: null,
|
||||
createdAt: args.now.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
for (const item of args.checklist) {
|
||||
if (item.signaturePolicyStatus === "requiere_validacion_legal") {
|
||||
alerts.push({
|
||||
id: `alert-signature-${item.proposalId}`,
|
||||
proposalId: item.proposalId,
|
||||
licitationId: null,
|
||||
title: `Firma electronica: validacion legal requerida`,
|
||||
description: `La propuesta ${item.proposalTitle} conserva semaforo conservador de firma.`,
|
||||
severity: "medium",
|
||||
kind: "signature_policy",
|
||||
dueAt: null,
|
||||
createdAt: args.now.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return alerts;
|
||||
}
|
||||
|
||||
function buildPanelRows(dataset: {
|
||||
activeLicitations: number;
|
||||
completionRate: number;
|
||||
criticalPending: number;
|
||||
upcoming7Days: number;
|
||||
reviewed: number;
|
||||
interested: number;
|
||||
}) {
|
||||
const panel: CompliancePanelItem[] = [
|
||||
{
|
||||
id: "panel-active",
|
||||
label: "Licitaciones activas",
|
||||
value: String(dataset.activeLicitations),
|
||||
tone: dataset.activeLicitations > 0 ? "success" : "neutral",
|
||||
},
|
||||
{
|
||||
id: "panel-completion",
|
||||
label: "Completitud obligatoria",
|
||||
value: `${dataset.completionRate}%`,
|
||||
tone: dataset.completionRate >= 70 ? "success" : dataset.completionRate >= 40 ? "warning" : "danger",
|
||||
},
|
||||
{
|
||||
id: "panel-critical",
|
||||
label: "Pendientes criticos",
|
||||
value: String(dataset.criticalPending),
|
||||
tone: dataset.criticalPending > 0 ? "danger" : "success",
|
||||
},
|
||||
{
|
||||
id: "panel-upcoming",
|
||||
label: "Proximos 7 dias",
|
||||
value: String(dataset.upcoming7Days),
|
||||
tone: dataset.upcoming7Days > 0 ? "warning" : "neutral",
|
||||
},
|
||||
{
|
||||
id: "panel-consulted",
|
||||
label: "M3 Consultadas",
|
||||
value: String(dataset.reviewed),
|
||||
tone: "neutral",
|
||||
},
|
||||
{
|
||||
id: "panel-interested",
|
||||
label: "M3 Me interesa",
|
||||
value: String(dataset.interested),
|
||||
tone: "neutral",
|
||||
},
|
||||
];
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
export function computeM3Counters(args: {
|
||||
totalOpenLicitations: number;
|
||||
preferences: Array<{ status: LicitationReviewStatus }>;
|
||||
activeLinked: number;
|
||||
}): M3Counters {
|
||||
const reviewed = args.preferences.filter((item) => item.status === "REVIEWED").length;
|
||||
const interested = args.preferences.filter((item) => item.status === "INTERESTED").length;
|
||||
|
||||
return {
|
||||
totalOpenLicitations: Math.max(0, args.totalOpenLicitations),
|
||||
reviewed,
|
||||
interested,
|
||||
activeLinked: Math.max(0, args.activeLinked),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildM7Dataset(args: {
|
||||
proposals: ProposalM7Input[];
|
||||
m3: M3Counters;
|
||||
dueVerifications: VerificationDueItem[];
|
||||
externalAlerts?: ComplianceAlertView[];
|
||||
now?: Date;
|
||||
}): M7Dataset {
|
||||
const now = args.now ?? new Date();
|
||||
const horizon = new Date(now);
|
||||
horizon.setUTCDate(horizon.getUTCDate() + 7);
|
||||
|
||||
const activeProposals = args.proposals
|
||||
.filter((proposal) => isOperationalProposal(proposal.status))
|
||||
.map((proposal) => ({
|
||||
...proposal,
|
||||
workflow: parseWorkflowDraft(proposal.workflowDraft),
|
||||
}))
|
||||
.filter((proposal): proposal is ProposalM7Input & { workflow: ProposalWorkflowState } => Boolean(proposal.workflow));
|
||||
|
||||
const checklist = buildChecklistRows(activeProposals);
|
||||
const proposalDeadlines = buildProposalDeadlines(activeProposals, now);
|
||||
const verificationDeadlines = buildVerificationDeadlines(args.dueVerifications);
|
||||
const allDeadlines = [...proposalDeadlines, ...verificationDeadlines].sort((left, right) => new Date(left.dueAt).getTime() - new Date(right.dueAt).getTime());
|
||||
|
||||
const mandatoryResolved = checklist.reduce((sum, item) => sum + item.mandatoryResolved, 0);
|
||||
const mandatoryTotal = checklist.reduce((sum, item) => sum + item.mandatoryTotal, 0);
|
||||
const completionRate = mandatoryTotal > 0 ? Math.round((mandatoryResolved / mandatoryTotal) * 100) : 0;
|
||||
const upcoming7Days = allDeadlines.filter((item) => {
|
||||
const dueAt = new Date(item.dueAt);
|
||||
return !Number.isNaN(dueAt.getTime()) && dueAt <= horizon;
|
||||
}).length;
|
||||
|
||||
const criticalPending = calculateCriticalPending(activeProposals, allDeadlines, now);
|
||||
const generatedAlerts = buildAlerts({
|
||||
criticalPending,
|
||||
deadlines: allDeadlines,
|
||||
checklist,
|
||||
now,
|
||||
});
|
||||
const alerts = [...generatedAlerts, ...(args.externalAlerts ?? [])];
|
||||
|
||||
const reviewed = args.m3.reviewed;
|
||||
const interested = args.m3.interested;
|
||||
const knownStatus = reviewed + interested;
|
||||
const estimatedNew = Math.max(0, args.m3.totalOpenLicitations - knownStatus);
|
||||
|
||||
return {
|
||||
generatedAt: now.toISOString(),
|
||||
kpis: {
|
||||
activeLicitations: activeProposals.length,
|
||||
completionRate,
|
||||
criticalPending,
|
||||
upcoming7Days,
|
||||
},
|
||||
m3States: {
|
||||
consulted: reviewed,
|
||||
interested,
|
||||
active: args.m3.activeLinked,
|
||||
new: estimatedNew,
|
||||
},
|
||||
tabs: {
|
||||
plazos: allDeadlines,
|
||||
alertas: alerts,
|
||||
checklist,
|
||||
panelKpi: buildPanelRows({
|
||||
activeLicitations: activeProposals.length,
|
||||
completionRate,
|
||||
criticalPending,
|
||||
upcoming7Days,
|
||||
reviewed,
|
||||
interested,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
735
src/lib/compliance/regulations.ts
Normal file
735
src/lib/compliance/regulations.ts
Normal file
@@ -0,0 +1,735 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { NormativeVerificationStatus, OfficialNormativeSourceType } from "@prisma/client";
|
||||
import type {
|
||||
ComplianceAlertView,
|
||||
NormativeVerificationResultView,
|
||||
OfficialNormativeSourceView,
|
||||
OfficialNormativeSuggestionInput,
|
||||
} from "@/lib/compliance/types";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
type InternalVerificationState = {
|
||||
lastKnownHash: string | null;
|
||||
lastVerifiedAt: string | null;
|
||||
nextCheckAt: string | null;
|
||||
lastStatus: "success" | "warning" | "failed" | null;
|
||||
lastMessage: string | null;
|
||||
lastChangedAt: string | null;
|
||||
};
|
||||
|
||||
const DEFAULT_CHECK_INTERVAL_DAYS = 7;
|
||||
const VERIFY_TIMEOUT_MS = 15_000;
|
||||
const TEST_ENV = process.env.NODE_ENV === "test";
|
||||
|
||||
const DEFAULT_OFFICIAL_SOURCES: OfficialNormativeSourceView[] = [
|
||||
{
|
||||
id: "nl-ley-adquisiciones-estatal",
|
||||
stateCode: "NL",
|
||||
stateName: "Nuevo Leon",
|
||||
municipalityCode: null,
|
||||
municipalityName: null,
|
||||
authorityName: "Gobierno del Estado de Nuevo Leon",
|
||||
title: "Ley de Adquisiciones, Arrendamientos y Contratacion de Servicios del Estado de Nuevo Leon",
|
||||
officialUrl: "https://www.hcnl.gob.mx/trabajo_legislativo/leyes/",
|
||||
sourceType: "ley",
|
||||
versionLabel: null,
|
||||
lastKnownHash: null,
|
||||
lastVerifiedAt: null,
|
||||
nextCheckAt: null,
|
||||
isPilot: true,
|
||||
},
|
||||
{
|
||||
id: "nl-reglamento-adquisiciones-estatal",
|
||||
stateCode: "NL",
|
||||
stateName: "Nuevo Leon",
|
||||
municipalityCode: null,
|
||||
municipalityName: null,
|
||||
authorityName: "Gobierno del Estado de Nuevo Leon",
|
||||
title: "Reglamento de la Ley de Adquisiciones del Estado de Nuevo Leon",
|
||||
officialUrl: "https://www.nl.gob.mx/",
|
||||
sourceType: "reglamento",
|
||||
versionLabel: null,
|
||||
lastKnownHash: null,
|
||||
lastVerifiedAt: null,
|
||||
nextCheckAt: null,
|
||||
isPilot: true,
|
||||
},
|
||||
{
|
||||
id: "spgg-reglamento-adquisiciones",
|
||||
stateCode: "NL",
|
||||
stateName: "Nuevo Leon",
|
||||
municipalityCode: "SPGG",
|
||||
municipalityName: "San Pedro Garza Garcia",
|
||||
authorityName: "Municipio de San Pedro Garza Garcia",
|
||||
title: "Reglamento de Adquisiciones y Contratacion de Servicios de San Pedro Garza Garcia",
|
||||
officialUrl: "https://www.sanpedro.gob.mx/",
|
||||
sourceType: "reglamento",
|
||||
versionLabel: null,
|
||||
lastKnownHash: null,
|
||||
lastVerifiedAt: null,
|
||||
nextCheckAt: null,
|
||||
isPilot: true,
|
||||
},
|
||||
];
|
||||
|
||||
const inMemorySuggestions: Array<OfficialNormativeSuggestionInput & { id: string; createdAt: string }> = [];
|
||||
const inMemoryVerificationState = new Map<string, InternalVerificationState>();
|
||||
|
||||
function addDaysIso(base: Date, days: number) {
|
||||
const copy = new Date(base);
|
||||
copy.setUTCDate(copy.getUTCDate() + days);
|
||||
return copy.toISOString();
|
||||
}
|
||||
|
||||
function normalize(value: string | null | undefined) {
|
||||
return (value ?? "")
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
}
|
||||
|
||||
function composeFingerprint(url: string, headers: Headers) {
|
||||
const etag = headers.get("etag") ?? "";
|
||||
const lastModified = headers.get("last-modified") ?? "";
|
||||
const contentLength = headers.get("content-length") ?? "";
|
||||
const contentType = headers.get("content-type") ?? "";
|
||||
return `${url}|${etag}|${lastModified}|${contentLength}|${contentType}`;
|
||||
}
|
||||
|
||||
function sha256(value: string) {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function toPrismaSourceType(value: OfficialNormativeSourceView["sourceType"]) {
|
||||
if (value === "ley") {
|
||||
return OfficialNormativeSourceType.LEY;
|
||||
}
|
||||
|
||||
if (value === "reglamento") {
|
||||
return OfficialNormativeSourceType.REGLAMENTO;
|
||||
}
|
||||
|
||||
if (value === "lineamiento") {
|
||||
return OfficialNormativeSourceType.LINEAMIENTO;
|
||||
}
|
||||
|
||||
return OfficialNormativeSourceType.PORTAL;
|
||||
}
|
||||
|
||||
function fromPrismaSourceType(value: OfficialNormativeSourceType): OfficialNormativeSourceView["sourceType"] {
|
||||
if (value === OfficialNormativeSourceType.LEY) {
|
||||
return "ley";
|
||||
}
|
||||
|
||||
if (value === OfficialNormativeSourceType.REGLAMENTO) {
|
||||
return "reglamento";
|
||||
}
|
||||
|
||||
if (value === OfficialNormativeSourceType.LINEAMIENTO) {
|
||||
return "lineamiento";
|
||||
}
|
||||
|
||||
return "portal";
|
||||
}
|
||||
|
||||
function toPrismaStatus(value: "success" | "warning" | "failed") {
|
||||
if (value === "success") {
|
||||
return NormativeVerificationStatus.SUCCESS;
|
||||
}
|
||||
|
||||
if (value === "warning") {
|
||||
return NormativeVerificationStatus.WARNING;
|
||||
}
|
||||
|
||||
return NormativeVerificationStatus.FAILED;
|
||||
}
|
||||
|
||||
function fromPrismaStatus(value: NormativeVerificationStatus | null): "success" | "warning" | "failed" | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value === NormativeVerificationStatus.SUCCESS) {
|
||||
return "success";
|
||||
}
|
||||
|
||||
if (value === NormativeVerificationStatus.WARNING) {
|
||||
return "warning";
|
||||
}
|
||||
|
||||
return "failed";
|
||||
}
|
||||
|
||||
function mapSourceRowToView(row: {
|
||||
id: string;
|
||||
stateCode: string;
|
||||
stateName: string;
|
||||
municipalityCode: string | null;
|
||||
municipalityName: string | null;
|
||||
authorityName: string;
|
||||
title: string;
|
||||
officialUrl: string;
|
||||
sourceType: OfficialNormativeSourceType;
|
||||
versionLabel: string | null;
|
||||
lastKnownHash: string | null;
|
||||
lastVerifiedAt: Date | null;
|
||||
nextCheckAt: Date | null;
|
||||
isPilot: boolean;
|
||||
}) {
|
||||
return {
|
||||
id: row.id,
|
||||
stateCode: row.stateCode,
|
||||
stateName: row.stateName,
|
||||
municipalityCode: row.municipalityCode,
|
||||
municipalityName: row.municipalityName,
|
||||
authorityName: row.authorityName,
|
||||
title: row.title,
|
||||
officialUrl: row.officialUrl,
|
||||
sourceType: fromPrismaSourceType(row.sourceType),
|
||||
versionLabel: row.versionLabel,
|
||||
lastKnownHash: row.lastKnownHash,
|
||||
lastVerifiedAt: row.lastVerifiedAt?.toISOString() ?? null,
|
||||
nextCheckAt: row.nextCheckAt?.toISOString() ?? null,
|
||||
isPilot: row.isPilot,
|
||||
} satisfies OfficialNormativeSourceView;
|
||||
}
|
||||
|
||||
async function fetchSourceMetadata(url: string) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), VERIFY_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
redirect: "follow",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const fingerprint = composeFingerprint(response.url || url, response.headers);
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
hash: sha256(fingerprint),
|
||||
finalUrl: response.url || url,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function filterSources(
|
||||
sources: OfficialNormativeSourceView[],
|
||||
filters?: {
|
||||
stateCode?: string | null;
|
||||
municipalityCode?: string | null;
|
||||
pilotOnly?: boolean;
|
||||
},
|
||||
) {
|
||||
const stateCode = normalize(filters?.stateCode);
|
||||
const municipalityCode = normalize(filters?.municipalityCode);
|
||||
|
||||
return sources.filter((source) => {
|
||||
if (filters?.pilotOnly && !source.isPilot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (stateCode && normalize(source.stateCode) !== stateCode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (municipalityCode && normalize(source.municipalityCode) !== municipalityCode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureDefaultSourcesPersisted() {
|
||||
await Promise.all(
|
||||
DEFAULT_OFFICIAL_SOURCES.map((source) =>
|
||||
prisma.officialNormativeSource.upsert({
|
||||
where: { id: source.id },
|
||||
update: {
|
||||
stateCode: source.stateCode,
|
||||
stateName: source.stateName,
|
||||
municipalityCode: source.municipalityCode,
|
||||
municipalityName: source.municipalityName,
|
||||
authorityName: source.authorityName,
|
||||
title: source.title,
|
||||
officialUrl: source.officialUrl,
|
||||
sourceType: toPrismaSourceType(source.sourceType),
|
||||
versionLabel: source.versionLabel,
|
||||
isPilot: source.isPilot,
|
||||
},
|
||||
create: {
|
||||
id: source.id,
|
||||
stateCode: source.stateCode,
|
||||
stateName: source.stateName,
|
||||
municipalityCode: source.municipalityCode,
|
||||
municipalityName: source.municipalityName,
|
||||
authorityName: source.authorityName,
|
||||
title: source.title,
|
||||
officialUrl: source.officialUrl,
|
||||
sourceType: toPrismaSourceType(source.sourceType),
|
||||
versionLabel: source.versionLabel,
|
||||
isPilot: source.isPilot,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function listInMemorySources(filters?: {
|
||||
stateCode?: string | null;
|
||||
municipalityCode?: string | null;
|
||||
pilotOnly?: boolean;
|
||||
}) {
|
||||
const sources = DEFAULT_OFFICIAL_SOURCES.map((source) => {
|
||||
const state = inMemoryVerificationState.get(source.id);
|
||||
return {
|
||||
...source,
|
||||
lastKnownHash: state?.lastKnownHash ?? source.lastKnownHash,
|
||||
lastVerifiedAt: state?.lastVerifiedAt ?? source.lastVerifiedAt,
|
||||
nextCheckAt: state?.nextCheckAt ?? source.nextCheckAt,
|
||||
};
|
||||
});
|
||||
|
||||
return filterSources(sources, filters);
|
||||
}
|
||||
|
||||
export async function listOfficialNormativeSources(filters?: {
|
||||
stateCode?: string | null;
|
||||
municipalityCode?: string | null;
|
||||
pilotOnly?: boolean;
|
||||
}) {
|
||||
if (TEST_ENV) {
|
||||
return listInMemorySources(filters);
|
||||
}
|
||||
|
||||
await ensureDefaultSourcesPersisted();
|
||||
const rows = await prisma.officialNormativeSource.findMany({
|
||||
orderBy: [{ stateCode: "asc" }, { municipalityCode: "asc" }, { title: "asc" }],
|
||||
});
|
||||
|
||||
return filterSources(rows.map(mapSourceRowToView), filters);
|
||||
}
|
||||
|
||||
export async function suggestOfficialNormativeSource(input: OfficialNormativeSuggestionInput) {
|
||||
if (TEST_ENV) {
|
||||
const suggestion = {
|
||||
...input,
|
||||
id: `suggestion-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
inMemorySuggestions.unshift(suggestion);
|
||||
return suggestion;
|
||||
}
|
||||
|
||||
const created = await prisma.officialNormativeSuggestion.create({
|
||||
data: {
|
||||
stateCode: input.stateCode,
|
||||
municipalityCode: input.municipalityCode ?? null,
|
||||
authorityName: input.authorityName,
|
||||
title: input.title,
|
||||
officialUrl: input.officialUrl,
|
||||
sourceType: toPrismaSourceType(input.sourceType),
|
||||
notes: input.notes ?? null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
stateCode: true,
|
||||
municipalityCode: true,
|
||||
authorityName: true,
|
||||
title: true,
|
||||
officialUrl: true,
|
||||
sourceType: true,
|
||||
notes: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
stateCode: created.stateCode,
|
||||
municipalityCode: created.municipalityCode,
|
||||
authorityName: created.authorityName,
|
||||
title: created.title,
|
||||
officialUrl: created.officialUrl,
|
||||
sourceType: fromPrismaSourceType(created.sourceType),
|
||||
notes: created.notes ?? undefined,
|
||||
createdAt: created.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listOfficialNormativeSuggestions() {
|
||||
if (TEST_ENV) {
|
||||
return [...inMemorySuggestions];
|
||||
}
|
||||
|
||||
const rows = await prisma.officialNormativeSuggestion.findMany({
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
take: 200,
|
||||
select: {
|
||||
id: true,
|
||||
stateCode: true,
|
||||
municipalityCode: true,
|
||||
authorityName: true,
|
||||
title: true,
|
||||
officialUrl: true,
|
||||
sourceType: true,
|
||||
notes: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
stateCode: row.stateCode,
|
||||
municipalityCode: row.municipalityCode,
|
||||
authorityName: row.authorityName,
|
||||
title: row.title,
|
||||
officialUrl: row.officialUrl,
|
||||
sourceType: fromPrismaSourceType(row.sourceType),
|
||||
notes: row.notes ?? undefined,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function verifyOfficialNormativeSources(sourceIds?: string[]) {
|
||||
const now = new Date();
|
||||
|
||||
if (TEST_ENV) {
|
||||
const sources = sourceIds && sourceIds.length > 0 ? DEFAULT_OFFICIAL_SOURCES.filter((source) => sourceIds.includes(source.id)) : DEFAULT_OFFICIAL_SOURCES;
|
||||
const results: NormativeVerificationResultView[] = [];
|
||||
|
||||
for (const source of sources) {
|
||||
const previous = inMemoryVerificationState.get(source.id) ?? {
|
||||
lastKnownHash: source.lastKnownHash,
|
||||
lastVerifiedAt: source.lastVerifiedAt,
|
||||
nextCheckAt: source.nextCheckAt,
|
||||
lastStatus: null,
|
||||
lastMessage: null,
|
||||
lastChangedAt: null,
|
||||
};
|
||||
|
||||
try {
|
||||
const metadata = await fetchSourceMetadata(source.officialUrl);
|
||||
const changed = Boolean(previous.lastKnownHash && metadata.hash !== previous.lastKnownHash);
|
||||
const checkedAt = now.toISOString();
|
||||
const status: "success" | "warning" | "failed" = metadata.ok ? (changed ? "warning" : "success") : "failed";
|
||||
const message = metadata.ok
|
||||
? changed
|
||||
? "Se detecto cambio potencial en fuente normativa oficial."
|
||||
: "Fuente normativa verificada sin cambios."
|
||||
: "La fuente normativa no respondio correctamente.";
|
||||
|
||||
inMemoryVerificationState.set(source.id, {
|
||||
lastKnownHash: metadata.hash,
|
||||
lastVerifiedAt: checkedAt,
|
||||
nextCheckAt: addDaysIso(now, DEFAULT_CHECK_INTERVAL_DAYS),
|
||||
lastStatus: status,
|
||||
lastMessage: message,
|
||||
lastChangedAt: changed ? checkedAt : previous.lastChangedAt,
|
||||
});
|
||||
|
||||
results.push({
|
||||
sourceId: source.id,
|
||||
checkedAt,
|
||||
changed,
|
||||
status,
|
||||
httpStatus: metadata.status,
|
||||
observedHash: metadata.hash,
|
||||
message,
|
||||
});
|
||||
} catch (error) {
|
||||
const checkedAt = now.toISOString();
|
||||
const message = error instanceof Error ? error.message : "No fue posible verificar la fuente normativa.";
|
||||
|
||||
inMemoryVerificationState.set(source.id, {
|
||||
lastKnownHash: previous.lastKnownHash ?? null,
|
||||
lastVerifiedAt: checkedAt,
|
||||
nextCheckAt: addDaysIso(now, 1),
|
||||
lastStatus: "failed",
|
||||
lastMessage: message,
|
||||
lastChangedAt: previous.lastChangedAt ?? null,
|
||||
});
|
||||
|
||||
results.push({
|
||||
sourceId: source.id,
|
||||
checkedAt,
|
||||
changed: false,
|
||||
status: "failed",
|
||||
httpStatus: null,
|
||||
observedHash: previous.lastKnownHash ?? null,
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
await ensureDefaultSourcesPersisted();
|
||||
const sources = await prisma.officialNormativeSource.findMany({
|
||||
where: sourceIds && sourceIds.length > 0 ? { id: { in: sourceIds } } : undefined,
|
||||
orderBy: [{ title: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
officialUrl: true,
|
||||
lastKnownHash: true,
|
||||
lastChangedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const results: NormativeVerificationResultView[] = [];
|
||||
|
||||
for (const source of sources) {
|
||||
try {
|
||||
const metadata = await fetchSourceMetadata(source.officialUrl);
|
||||
const changed = Boolean(source.lastKnownHash && metadata.hash !== source.lastKnownHash);
|
||||
const checkedAt = now.toISOString();
|
||||
const status: "success" | "warning" | "failed" = metadata.ok ? (changed ? "warning" : "success") : "failed";
|
||||
const message = metadata.ok
|
||||
? changed
|
||||
? "Se detecto cambio potencial en fuente normativa oficial."
|
||||
: "Fuente normativa verificada sin cambios."
|
||||
: "La fuente normativa no respondio correctamente.";
|
||||
|
||||
await prisma.officialNormativeSource.update({
|
||||
where: { id: source.id },
|
||||
data: {
|
||||
lastKnownHash: metadata.hash,
|
||||
lastVerifiedAt: now,
|
||||
nextCheckAt: new Date(addDaysIso(now, DEFAULT_CHECK_INTERVAL_DAYS)),
|
||||
lastStatus: toPrismaStatus(status),
|
||||
lastMessage: message,
|
||||
lastChangedAt: changed ? now : source.lastChangedAt,
|
||||
},
|
||||
});
|
||||
|
||||
results.push({
|
||||
sourceId: source.id,
|
||||
checkedAt,
|
||||
changed,
|
||||
status,
|
||||
httpStatus: metadata.status,
|
||||
observedHash: metadata.hash,
|
||||
message,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "No fue posible verificar la fuente normativa.";
|
||||
|
||||
await prisma.officialNormativeSource.update({
|
||||
where: { id: source.id },
|
||||
data: {
|
||||
lastVerifiedAt: now,
|
||||
nextCheckAt: new Date(addDaysIso(now, 1)),
|
||||
lastStatus: NormativeVerificationStatus.FAILED,
|
||||
lastMessage: message,
|
||||
},
|
||||
});
|
||||
|
||||
results.push({
|
||||
sourceId: source.id,
|
||||
checkedAt: now.toISOString(),
|
||||
changed: false,
|
||||
status: "failed",
|
||||
httpStatus: null,
|
||||
observedHash: source.lastKnownHash,
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function getNormativeSourcesDueForVerification(now = new Date()) {
|
||||
const nowTime = now.getTime();
|
||||
const sources = await listOfficialNormativeSources({ pilotOnly: true });
|
||||
|
||||
return sources
|
||||
.filter((source) => {
|
||||
if (!source.lastVerifiedAt || !source.nextCheckAt) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const nextCheck = new Date(source.nextCheckAt);
|
||||
if (Number.isNaN(nextCheck.getTime())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return nextCheck.getTime() <= nowTime;
|
||||
})
|
||||
.map((source) => source.id);
|
||||
}
|
||||
|
||||
export async function runPeriodicNormativeVerification(now = new Date()) {
|
||||
const dueSourceIds = await getNormativeSourcesDueForVerification(now);
|
||||
|
||||
if (dueSourceIds.length === 0) {
|
||||
return {
|
||||
checkedAt: now.toISOString(),
|
||||
checked: false,
|
||||
dueCount: 0,
|
||||
results: [] as NormativeVerificationResultView[],
|
||||
};
|
||||
}
|
||||
|
||||
const results = await verifyOfficialNormativeSources(dueSourceIds);
|
||||
return {
|
||||
checkedAt: now.toISOString(),
|
||||
checked: true,
|
||||
dueCount: dueSourceIds.length,
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listRegulationChangeAlerts(now = new Date(), lookbackDays = 30): Promise<ComplianceAlertView[]> {
|
||||
const threshold = new Date(now);
|
||||
threshold.setUTCDate(threshold.getUTCDate() - Math.max(1, lookbackDays));
|
||||
|
||||
if (TEST_ENV) {
|
||||
const sourcesById = new Map(DEFAULT_OFFICIAL_SOURCES.map((source) => [source.id, source]));
|
||||
const alerts: ComplianceAlertView[] = [];
|
||||
|
||||
for (const [sourceId, state] of inMemoryVerificationState.entries()) {
|
||||
const source = sourcesById.get(sourceId);
|
||||
if (!source || !state.lastVerifiedAt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const verifiedAt = new Date(state.lastVerifiedAt);
|
||||
if (Number.isNaN(verifiedAt.getTime()) || verifiedAt < threshold) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (state.lastStatus === "warning") {
|
||||
alerts.push({
|
||||
id: `reg-update-${sourceId}`,
|
||||
proposalId: null,
|
||||
licitationId: sourceId,
|
||||
title: "Cambio detectado en fuente normativa",
|
||||
description: `${source.title}: ${state.lastMessage ?? "Se detectaron cambios en metadatos oficiales."}`,
|
||||
severity: "high",
|
||||
kind: "normative_regulation_update",
|
||||
dueAt: state.lastVerifiedAt,
|
||||
createdAt: state.lastVerifiedAt,
|
||||
});
|
||||
}
|
||||
|
||||
if (state.lastStatus === "failed") {
|
||||
alerts.push({
|
||||
id: `reg-failed-${sourceId}`,
|
||||
proposalId: null,
|
||||
licitationId: sourceId,
|
||||
title: "Verificacion normativa fallida",
|
||||
description: `${source.title}: ${state.lastMessage ?? "No fue posible verificar la fuente."}`,
|
||||
severity: "medium",
|
||||
kind: "normative_verification_pending",
|
||||
dueAt: state.nextCheckAt,
|
||||
createdAt: state.lastVerifiedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return alerts;
|
||||
}
|
||||
|
||||
await ensureDefaultSourcesPersisted();
|
||||
const rows = await prisma.officialNormativeSource.findMany({
|
||||
where: {
|
||||
lastVerifiedAt: { gte: threshold },
|
||||
lastStatus: { in: [NormativeVerificationStatus.WARNING, NormativeVerificationStatus.FAILED] },
|
||||
},
|
||||
orderBy: [{ lastVerifiedAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
lastStatus: true,
|
||||
lastMessage: true,
|
||||
lastVerifiedAt: true,
|
||||
nextCheckAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const alerts: ComplianceAlertView[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.lastVerifiedAt || !row.lastStatus) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const status = fromPrismaStatus(row.lastStatus);
|
||||
if (status === "warning") {
|
||||
alerts.push({
|
||||
id: `reg-update-${row.id}`,
|
||||
proposalId: null,
|
||||
licitationId: row.id,
|
||||
title: "Cambio detectado en fuente normativa",
|
||||
description: `${row.title}: ${row.lastMessage ?? "Se detectaron cambios en metadatos oficiales."}`,
|
||||
severity: "high",
|
||||
kind: "normative_regulation_update",
|
||||
dueAt: row.lastVerifiedAt.toISOString(),
|
||||
createdAt: row.lastVerifiedAt.toISOString(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (status === "failed") {
|
||||
alerts.push({
|
||||
id: `reg-failed-${row.id}`,
|
||||
proposalId: null,
|
||||
licitationId: row.id,
|
||||
title: "Verificacion normativa fallida",
|
||||
description: `${row.title}: ${row.lastMessage ?? "No fue posible verificar la fuente."}`,
|
||||
severity: "medium",
|
||||
kind: "normative_verification_pending",
|
||||
dueAt: row.nextCheckAt?.toISOString() ?? null,
|
||||
createdAt: row.lastVerifiedAt.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return alerts;
|
||||
}
|
||||
|
||||
export async function getDueNormativeVerifications(now = new Date(), horizonDays = 7) {
|
||||
const horizon = new Date(now);
|
||||
horizon.setUTCDate(horizon.getUTCDate() + Math.max(0, horizonDays));
|
||||
|
||||
const sources = await listOfficialNormativeSources({ pilotOnly: true });
|
||||
return sources
|
||||
.map((source) => {
|
||||
const dueAtIso = source.nextCheckAt ?? addDaysIso(now, 1);
|
||||
const dueAt = new Date(dueAtIso);
|
||||
if (Number.isNaN(dueAt.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (dueAt > horizon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sourceId: source.id,
|
||||
sourceTitle: source.title,
|
||||
authorityName: source.authorityName,
|
||||
dueAt: dueAt.toISOString(),
|
||||
overdue: dueAt.getTime() < now.getTime(),
|
||||
};
|
||||
})
|
||||
.filter((item): item is { sourceId: string; sourceTitle: string; authorityName: string; dueAt: string; overdue: boolean } => Boolean(item))
|
||||
.sort((left, right) => new Date(left.dueAt).getTime() - new Date(right.dueAt).getTime());
|
||||
}
|
||||
|
||||
export async function resetRegulationsStateForTests() {
|
||||
if (!TEST_ENV) {
|
||||
return;
|
||||
}
|
||||
|
||||
inMemoryVerificationState.clear();
|
||||
inMemorySuggestions.splice(0, inMemorySuggestions.length);
|
||||
}
|
||||
168
src/lib/compliance/server.ts
Normal file
168
src/lib/compliance/server.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import "server-only";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import type { ComplianceAlertView } from "@/lib/compliance/types";
|
||||
import { buildM7Dataset, computeM3Counters } from "@/lib/compliance/m7";
|
||||
import { getDueNormativeVerifications, listRegulationChangeAlerts } from "@/lib/compliance/regulations";
|
||||
|
||||
export async function getM7DatasetForUser(userId: string, now = new Date()) {
|
||||
const [proposals, preferences, totalOpenLicitations] = await Promise.all([
|
||||
prisma.proposal.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
status: true,
|
||||
sourceLicitationId: true,
|
||||
workflowDraft: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
orderBy: [{ updatedAt: "desc" }],
|
||||
take: 200,
|
||||
}),
|
||||
prisma.licitationUserPreference.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
select: {
|
||||
status: true,
|
||||
},
|
||||
}),
|
||||
prisma.licitation.count({
|
||||
where: {
|
||||
OR: [{ isOpen: true }, { closingDate: { gte: now } }],
|
||||
municipality: {
|
||||
isActive: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const activeLinked = new Set(
|
||||
proposals
|
||||
.filter((item) => item.status !== "ARCHIVED" && item.sourceLicitationId)
|
||||
.map((item) => item.sourceLicitationId as string),
|
||||
).size;
|
||||
|
||||
const m3 = computeM3Counters({
|
||||
totalOpenLicitations,
|
||||
preferences,
|
||||
activeLinked,
|
||||
});
|
||||
|
||||
const [dueVerifications, regulationAlerts, overdueDeliverables, disputedPayments, highSeverityCases] = await Promise.all([
|
||||
getDueNormativeVerifications(now, 7),
|
||||
listRegulationChangeAlerts(now, 30),
|
||||
prisma.contractDeliverable.findMany({
|
||||
where: {
|
||||
contract: {
|
||||
userId,
|
||||
},
|
||||
status: {
|
||||
in: ["PENDING", "OVERDUE"],
|
||||
},
|
||||
dueDate: {
|
||||
lt: now,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
dueDate: true,
|
||||
contract: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ dueDate: "asc" }],
|
||||
take: 50,
|
||||
}),
|
||||
prisma.contractPayment.findMany({
|
||||
where: {
|
||||
contract: {
|
||||
userId,
|
||||
},
|
||||
status: "DISPUTED",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
concept: true,
|
||||
paymentDate: true,
|
||||
amount: true,
|
||||
contract: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ paymentDate: "desc" }],
|
||||
take: 50,
|
||||
}),
|
||||
prisma.legalCase.findMany({
|
||||
where: {
|
||||
userId,
|
||||
severity: "HIGH",
|
||||
status: {
|
||||
in: ["OPEN", "IN_PROGRESS", "ESCALATED"],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
caseType: true,
|
||||
counterparty: true,
|
||||
openedAt: true,
|
||||
},
|
||||
orderBy: [{ openedAt: "desc" }],
|
||||
take: 50,
|
||||
}),
|
||||
]);
|
||||
|
||||
const crossModuleAlerts: ComplianceAlertView[] = [
|
||||
...overdueDeliverables.map((item) => ({
|
||||
id: `contract-deliverable-overdue-${item.id}`,
|
||||
proposalId: null,
|
||||
licitationId: item.contract.id,
|
||||
title: `Entregable vencido: ${item.contract.title}`,
|
||||
description: item.title,
|
||||
severity: "high" as const,
|
||||
kind: "contract_deliverable_overdue" as const,
|
||||
dueAt: item.dueDate ? item.dueDate.toISOString() : null,
|
||||
createdAt: now.toISOString(),
|
||||
})),
|
||||
...disputedPayments.map((item) => ({
|
||||
id: `contract-payment-disputed-${item.id}`,
|
||||
proposalId: null,
|
||||
licitationId: item.contract.id,
|
||||
title: `Pago en disputa: ${item.contract.title}`,
|
||||
description: `${item.concept} (${Number(item.amount).toLocaleString("es-MX")} MXN)`,
|
||||
severity: "high" as const,
|
||||
kind: "contract_payment_disputed" as const,
|
||||
dueAt: item.paymentDate.toISOString(),
|
||||
createdAt: now.toISOString(),
|
||||
})),
|
||||
...highSeverityCases.map((item) => ({
|
||||
id: `legal-high-severity-${item.id}`,
|
||||
proposalId: null,
|
||||
licitationId: null,
|
||||
title: `Caso legal alta severidad: ${item.caseType}`,
|
||||
description: `Contraparte: ${item.counterparty}`,
|
||||
severity: "high" as const,
|
||||
kind: "legal_case_high_severity" as const,
|
||||
dueAt: item.openedAt.toISOString(),
|
||||
createdAt: now.toISOString(),
|
||||
})),
|
||||
];
|
||||
|
||||
return buildM7Dataset({
|
||||
proposals,
|
||||
m3,
|
||||
dueVerifications,
|
||||
externalAlerts: [...regulationAlerts, ...crossModuleAlerts],
|
||||
now,
|
||||
});
|
||||
}
|
||||
133
src/lib/compliance/signature-policy.ts
Normal file
133
src/lib/compliance/signature-policy.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type { SignaturePolicyEvaluation, SignaturePolicyMatrixEntry, SignaturePolicyStatus } from "@/lib/compliance/types";
|
||||
|
||||
const MATRIX: SignaturePolicyMatrixEntry[] = [
|
||||
{
|
||||
id: "nl-general-licitaciones",
|
||||
stateCode: "NL",
|
||||
stateName: "Nuevo Leon",
|
||||
municipalityCode: null,
|
||||
municipalityName: null,
|
||||
appliesTo: "licitaciones",
|
||||
policyStatus: "condicionado",
|
||||
policyName: "Firma electronica avanzada condicionada por convocatoria",
|
||||
evidenceRequired: [
|
||||
"Verificacion expresa en Bases/Convocatoria de admision de firma electronica",
|
||||
"Acuse de autenticidad o certificado vigente de la e.firma",
|
||||
"Respaldo de representacion legal de firmantes",
|
||||
],
|
||||
notes: "Si no existe clausula expresa de admision, se escala a validacion legal interna.",
|
||||
sourceUrl: "https://www.nl.gob.mx/",
|
||||
},
|
||||
{
|
||||
id: "nl-san-pedro",
|
||||
stateCode: "NL",
|
||||
stateName: "Nuevo Leon",
|
||||
municipalityCode: "SPGG",
|
||||
municipalityName: "San Pedro Garza Garcia",
|
||||
appliesTo: "licitaciones",
|
||||
policyStatus: "requiere_validacion_legal",
|
||||
policyName: "Municipio con validacion legal previa obligatoria",
|
||||
evidenceRequired: [
|
||||
"Reglamento municipal vigente de adquisiciones",
|
||||
"Reglas de firma indicadas en convocatoria especifica",
|
||||
"Opinion legal documentada antes de envio",
|
||||
],
|
||||
notes: "Configuracion conservadora para piloto: no asumir autorizacion automatica de firma electronica.",
|
||||
sourceUrl: "https://www.sanpedro.gob.mx/",
|
||||
},
|
||||
];
|
||||
|
||||
function normalize(value: string | null | undefined) {
|
||||
return (value ?? "")
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
}
|
||||
|
||||
function isMatch(entry: SignaturePolicyMatrixEntry, input: { stateCode?: string | null; stateName?: string | null; municipalityName?: string | null }) {
|
||||
const stateCode = normalize(input.stateCode);
|
||||
const stateName = normalize(input.stateName);
|
||||
const municipalityName = normalize(input.municipalityName);
|
||||
|
||||
const entryStateCode = normalize(entry.stateCode);
|
||||
const entryStateName = normalize(entry.stateName);
|
||||
const entryMunicipalityName = normalize(entry.municipalityName);
|
||||
|
||||
const stateMatches = (stateCode && stateCode === entryStateCode) || (stateName && stateName === entryStateName);
|
||||
if (!stateMatches) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!entryMunicipalityName) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Boolean(municipalityName && municipalityName === entryMunicipalityName);
|
||||
}
|
||||
|
||||
export function evaluateSignaturePolicy(input: {
|
||||
stateCode?: string | null;
|
||||
stateName?: string | null;
|
||||
municipalityName?: string | null;
|
||||
documentType?: "BASES_LICITACION" | "CONVOCATORIA" | "ANEXOS" | "OTRO" | "REGLAMENTO" | "LEY";
|
||||
}): SignaturePolicyEvaluation {
|
||||
const exactMunicipality = MATRIX.find((entry) => Boolean(entry.municipalityName) && isMatch(entry, input));
|
||||
const stateDefault = MATRIX.find((entry) => !entry.municipalityName && isMatch(entry, input));
|
||||
const chosen = exactMunicipality ?? stateDefault;
|
||||
|
||||
if (!chosen) {
|
||||
return {
|
||||
policyStatus: "requiere_validacion_legal",
|
||||
policyName: "Sin certeza regulatoria local",
|
||||
evidenceRequired: [
|
||||
"Reglamento oficial vigente de la jurisdiccion",
|
||||
"Clausula explicita en la convocatoria sobre firma electronica",
|
||||
"Opinion legal interna previa al envio",
|
||||
],
|
||||
notes: "Sin confirmacion oficial suficiente. Regla conservadora: requiere validacion legal.",
|
||||
jurisdictionLabel: input.stateName?.trim() || "Jurisdiccion no definida",
|
||||
sourceUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
const documentHint = input.documentType ? `Documento base: ${input.documentType}.` : "";
|
||||
const jurisdictionLabel = chosen.municipalityName ? `${chosen.municipalityName}, ${chosen.stateName}` : chosen.stateName;
|
||||
|
||||
return {
|
||||
policyStatus: chosen.policyStatus,
|
||||
policyName: chosen.policyName,
|
||||
evidenceRequired: chosen.evidenceRequired,
|
||||
notes: `${chosen.notes}${documentHint ? ` ${documentHint}` : ""}`.trim(),
|
||||
jurisdictionLabel,
|
||||
sourceUrl: chosen.sourceUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export function getSignaturePolicyStatusLabel(status: SignaturePolicyStatus) {
|
||||
if (status === "permitido") {
|
||||
return "Permitido";
|
||||
}
|
||||
|
||||
if (status === "condicionado") {
|
||||
return "Condicionado";
|
||||
}
|
||||
|
||||
return "Requiere validacion legal";
|
||||
}
|
||||
|
||||
export function getSignaturePolicyTone(status: SignaturePolicyStatus) {
|
||||
if (status === "permitido") {
|
||||
return "border-[#b9e6cd] bg-[#eaf9f1] text-[#1f8b63]";
|
||||
}
|
||||
|
||||
if (status === "condicionado") {
|
||||
return "border-[#f0deb0] bg-[#fff8e9] text-[#8d6308]";
|
||||
}
|
||||
|
||||
return "border-[#f1c7ce] bg-[#fff1f4] text-[#b03f4f]";
|
||||
}
|
||||
|
||||
export function listSignaturePolicyMatrix() {
|
||||
return [...MATRIX];
|
||||
}
|
||||
156
src/lib/compliance/types.ts
Normal file
156
src/lib/compliance/types.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
export type SignaturePolicyStatus = "permitido" | "condicionado" | "requiere_validacion_legal";
|
||||
|
||||
export type SignaturePolicyMatrixEntry = {
|
||||
id: string;
|
||||
stateCode: string;
|
||||
stateName: string;
|
||||
municipalityCode: string | null;
|
||||
municipalityName: string | null;
|
||||
appliesTo: "licitaciones" | "contratos" | "general";
|
||||
policyStatus: SignaturePolicyStatus;
|
||||
policyName: string;
|
||||
evidenceRequired: string[];
|
||||
notes: string;
|
||||
sourceUrl: string | null;
|
||||
};
|
||||
|
||||
export type SignaturePolicyEvaluation = {
|
||||
policyStatus: SignaturePolicyStatus;
|
||||
policyName: string;
|
||||
evidenceRequired: string[];
|
||||
notes: string;
|
||||
jurisdictionLabel: string;
|
||||
sourceUrl: string | null;
|
||||
};
|
||||
|
||||
export type ComplianceAlertSeverity = "high" | "medium" | "low";
|
||||
|
||||
export type ComplianceAlertKind =
|
||||
| "deadline_overdue"
|
||||
| "deadline_soon"
|
||||
| "critical_requirement_pending"
|
||||
| "signature_policy"
|
||||
| "normative_regulation_update"
|
||||
| "normative_verification_pending"
|
||||
| "contract_deliverable_overdue"
|
||||
| "contract_payment_disputed"
|
||||
| "legal_case_high_severity";
|
||||
|
||||
export type ComplianceAlertView = {
|
||||
id: string;
|
||||
proposalId: string | null;
|
||||
licitationId: string | null;
|
||||
title: string;
|
||||
description: string;
|
||||
severity: ComplianceAlertSeverity;
|
||||
kind: ComplianceAlertKind;
|
||||
dueAt: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type ComplianceDeadlineItem = {
|
||||
id: string;
|
||||
proposalId: string | null;
|
||||
licitationId: string | null;
|
||||
title: string;
|
||||
description: string;
|
||||
dueAt: string;
|
||||
source: "milestone" | "normative_verification";
|
||||
status: "upcoming" | "overdue";
|
||||
};
|
||||
|
||||
export type ComplianceChecklistItem = {
|
||||
proposalId: string;
|
||||
proposalTitle: string;
|
||||
mandatoryResolved: number;
|
||||
mandatoryTotal: number;
|
||||
completionRate: number;
|
||||
signaturePolicyStatus: SignaturePolicyStatus;
|
||||
};
|
||||
|
||||
export type CompliancePanelItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
tone: "neutral" | "success" | "warning" | "danger";
|
||||
};
|
||||
|
||||
export type M7KpiSnapshot = {
|
||||
activeLicitations: number;
|
||||
completionRate: number;
|
||||
criticalPending: number;
|
||||
upcoming7Days: number;
|
||||
};
|
||||
|
||||
export type M7Dataset = {
|
||||
generatedAt: string;
|
||||
kpis: M7KpiSnapshot;
|
||||
m3States: {
|
||||
consulted: number;
|
||||
interested: number;
|
||||
active: number;
|
||||
new: number;
|
||||
};
|
||||
tabs: {
|
||||
plazos: ComplianceDeadlineItem[];
|
||||
alertas: ComplianceAlertView[];
|
||||
checklist: ComplianceChecklistItem[];
|
||||
panelKpi: CompliancePanelItem[];
|
||||
};
|
||||
aiPlaybook?: M7AiPlaybook | null;
|
||||
};
|
||||
|
||||
export type M7AiPlaybook = {
|
||||
predictedIncidents: {
|
||||
title: string;
|
||||
likelihood: "alta" | "media" | "baja";
|
||||
impact: "alto" | "medio" | "bajo";
|
||||
timeHorizon: string;
|
||||
}[];
|
||||
priorityOrder: string[];
|
||||
preventiveActions: {
|
||||
action: string;
|
||||
ownerSuggestion: string;
|
||||
targetDate: string;
|
||||
}[];
|
||||
escalationAdvice: string[];
|
||||
confidence: "low" | "medium" | "high";
|
||||
suggestionId: string;
|
||||
};
|
||||
|
||||
export type OfficialNormativeSourceView = {
|
||||
id: string;
|
||||
stateCode: string;
|
||||
stateName: string;
|
||||
municipalityCode: string | null;
|
||||
municipalityName: string | null;
|
||||
authorityName: string;
|
||||
title: string;
|
||||
officialUrl: string;
|
||||
sourceType: "ley" | "reglamento" | "lineamiento" | "portal";
|
||||
versionLabel: string | null;
|
||||
lastKnownHash: string | null;
|
||||
lastVerifiedAt: string | null;
|
||||
nextCheckAt: string | null;
|
||||
isPilot: boolean;
|
||||
};
|
||||
|
||||
export type OfficialNormativeSuggestionInput = {
|
||||
stateCode: string;
|
||||
municipalityCode?: string | null;
|
||||
authorityName: string;
|
||||
title: string;
|
||||
officialUrl: string;
|
||||
sourceType: OfficialNormativeSourceView["sourceType"];
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
export type NormativeVerificationResultView = {
|
||||
sourceId: string;
|
||||
checkedAt: string;
|
||||
changed: boolean;
|
||||
status: "success" | "warning" | "failed";
|
||||
httpStatus: number | null;
|
||||
observedHash: string | null;
|
||||
message: string;
|
||||
};
|
||||
203
src/lib/contracts/__tests__/proposal-continuity.test.ts
Normal file
203
src/lib/contracts/__tests__/proposal-continuity.test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { prismaMock, readStoredProposalDocumentMock, storeContractDocumentFileMock } = vi.hoisted(() => ({
|
||||
prismaMock: {
|
||||
proposal: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
contractDocument: {
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
},
|
||||
readStoredProposalDocumentMock: vi.fn(),
|
||||
storeContractDocumentFileMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/proposals/storage", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/proposals/storage")>("@/lib/proposals/storage");
|
||||
return {
|
||||
...actual,
|
||||
readStoredProposalDocument: readStoredProposalDocumentMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/contracts/storage", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/contracts/storage")>("@/lib/contracts/storage");
|
||||
return {
|
||||
...actual,
|
||||
storeContractDocumentFile: storeContractDocumentFileMock,
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
ensureProposalPdfLinkedToContract,
|
||||
getLatestProposalPdfSourceForUser,
|
||||
isPdfProposalDocument,
|
||||
} from "@/lib/contracts/proposal-continuity";
|
||||
|
||||
describe("proposal continuity", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("detects PDF proposal documents by mime type or file extension", () => {
|
||||
expect(isPdfProposalDocument({ fileName: "source.pdf", mimeType: "application/octet-stream" })).toBe(true);
|
||||
expect(isPdfProposalDocument({ fileName: "source.bin", mimeType: "application/pdf" })).toBe(true);
|
||||
expect(isPdfProposalDocument({ fileName: "source.docx", mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns proposal_not_found when proposal does not belong to user", async () => {
|
||||
prismaMock.proposal.findFirst.mockResolvedValue(null);
|
||||
|
||||
const result = await getLatestProposalPdfSourceForUser("user-1", "proposal-1");
|
||||
|
||||
expect(result).toEqual({ status: "proposal_not_found" });
|
||||
expect(readStoredProposalDocumentMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns pdf_not_found when proposal has no PDF document", async () => {
|
||||
prismaMock.proposal.findFirst.mockResolvedValue({
|
||||
id: "proposal-1",
|
||||
title: "Propuesta sin PDF",
|
||||
documents: [{ id: "doc-1", fileName: "evidencia.docx", filePath: "storage/proposals/u/p/doc-1.docx", mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", sizeBytes: 20, checksumSha256: null, createdAt: new Date() }],
|
||||
});
|
||||
|
||||
const result = await getLatestProposalPdfSourceForUser("user-1", "proposal-1");
|
||||
|
||||
expect(result).toEqual({
|
||||
status: "pdf_not_found",
|
||||
proposal: { id: "proposal-1", title: "Propuesta sin PDF" },
|
||||
});
|
||||
expect(readStoredProposalDocumentMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns PDF source when proposal has a PDF document", async () => {
|
||||
const fileBuffer = Buffer.from("pdf");
|
||||
prismaMock.proposal.findFirst.mockResolvedValue({
|
||||
id: "proposal-1",
|
||||
title: "Propuesta con PDF",
|
||||
documents: [
|
||||
{
|
||||
id: "doc-2",
|
||||
fileName: "contrato.pdf",
|
||||
filePath: "storage/proposals/u/p/doc-2.pdf",
|
||||
mimeType: "application/pdf",
|
||||
sizeBytes: 120,
|
||||
checksumSha256: "sha-source",
|
||||
createdAt: new Date(),
|
||||
},
|
||||
],
|
||||
});
|
||||
readStoredProposalDocumentMock.mockResolvedValue(fileBuffer);
|
||||
|
||||
const result = await getLatestProposalPdfSourceForUser("user-1", "proposal-1");
|
||||
|
||||
expect(result).toEqual({
|
||||
status: "ok",
|
||||
source: {
|
||||
proposal: { id: "proposal-1", title: "Propuesta con PDF" },
|
||||
document: {
|
||||
id: "doc-2",
|
||||
fileName: "contrato.pdf",
|
||||
filePath: "storage/proposals/u/p/doc-2.pdf",
|
||||
mimeType: "application/pdf",
|
||||
sizeBytes: 120,
|
||||
checksumSha256: "sha-source",
|
||||
createdAt: expect.any(Date),
|
||||
},
|
||||
fileBuffer,
|
||||
},
|
||||
});
|
||||
expect(readStoredProposalDocumentMock).toHaveBeenCalledTimes(1);
|
||||
expect(readStoredProposalDocumentMock.mock.calls[0]?.[0]).toBe("storage/proposals/u/p/doc-2.pdf");
|
||||
});
|
||||
|
||||
it("avoids creating duplicate contract document when checksum matches", async () => {
|
||||
prismaMock.contractDocument.findMany.mockResolvedValue([
|
||||
{
|
||||
id: "existing-doc",
|
||||
fileName: "contrato.pdf",
|
||||
sizeBytes: 120,
|
||||
checksumSha256: "sha-source",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await ensureProposalPdfLinkedToContract({
|
||||
userId: "user-1",
|
||||
contractId: "contract-1",
|
||||
source: {
|
||||
proposal: { id: "proposal-1", title: "Propuesta" },
|
||||
document: {
|
||||
id: "proposal-doc",
|
||||
fileName: "contrato.pdf",
|
||||
filePath: "storage/proposals/u/p/doc.pdf",
|
||||
mimeType: "application/pdf",
|
||||
sizeBytes: 120,
|
||||
checksumSha256: "sha-source",
|
||||
createdAt: new Date(),
|
||||
},
|
||||
fileBuffer: Buffer.from("pdf"),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ created: false, documentId: "existing-doc" });
|
||||
expect(storeContractDocumentFileMock).not.toHaveBeenCalled();
|
||||
expect(prismaMock.contractDocument.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stores and links proposal PDF when no duplicate exists", async () => {
|
||||
prismaMock.contractDocument.findMany.mockResolvedValue([]);
|
||||
storeContractDocumentFileMock.mockResolvedValue({
|
||||
fileName: "contract-copy.pdf",
|
||||
filePath: "storage/contracts/user-1/contract-1/copy.pdf",
|
||||
mimeType: "application/pdf",
|
||||
sizeBytes: 120,
|
||||
checksumSha256: "sha-copy",
|
||||
});
|
||||
prismaMock.contractDocument.create.mockResolvedValue({
|
||||
id: "new-doc",
|
||||
});
|
||||
|
||||
const result = await ensureProposalPdfLinkedToContract({
|
||||
userId: "user-1",
|
||||
contractId: "contract-1",
|
||||
source: {
|
||||
proposal: { id: "proposal-1", title: "Propuesta" },
|
||||
document: {
|
||||
id: "proposal-doc",
|
||||
fileName: "contrato.pdf",
|
||||
filePath: "storage/proposals/u/p/doc.pdf",
|
||||
mimeType: "application/pdf",
|
||||
sizeBytes: 120,
|
||||
checksumSha256: "sha-source",
|
||||
createdAt: new Date(),
|
||||
},
|
||||
fileBuffer: Buffer.from("pdf"),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ created: true, documentId: "new-doc" });
|
||||
expect(storeContractDocumentFileMock).toHaveBeenCalledWith("user-1", "contract-1", "contrato.pdf", "application/pdf", expect.any(Buffer));
|
||||
expect(prismaMock.contractDocument.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
contractId: "contract-1",
|
||||
fileName: "contract-copy.pdf",
|
||||
filePath: "storage/contracts/user-1/contract-1/copy.pdf",
|
||||
mimeType: "application/pdf",
|
||||
sizeBytes: 120,
|
||||
checksumSha256: "sha-copy",
|
||||
kind: "SIGNED_CONTRACT",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
458
src/lib/contracts/extraction.ts
Normal file
458
src/lib/contracts/extraction.ts
Normal file
@@ -0,0 +1,458 @@
|
||||
import { z } from "zod";
|
||||
import type { ContractAiExtractionOutput, ContractRiskClause } from "@/lib/contracts/types";
|
||||
|
||||
const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
|
||||
const DEFAULT_OPENAI_MODEL = "gpt-4.1-mini";
|
||||
const DEFAULT_TIMEOUT_MS = 60_000;
|
||||
const DEFAULT_MAX_TEXT_CHARS = 45_000;
|
||||
|
||||
const ContractExtractionResponseSchema = z.object({
|
||||
fields: z
|
||||
.object({
|
||||
title: z.string().nullable().optional(),
|
||||
counterpartyEntity: z.string().nullable().optional(),
|
||||
contractNumber: z.string().nullable().optional(),
|
||||
contractType: z.string().nullable().optional(),
|
||||
startDate: z.string().nullable().optional(),
|
||||
endDate: z.string().nullable().optional(),
|
||||
totalAmount: z.union([z.number(), z.string()]).nullable().optional(),
|
||||
currency: z.string().nullable().optional(),
|
||||
description: z.string().nullable().optional(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
deliverables: z
|
||||
.array(
|
||||
z.object({
|
||||
title: z.string().nullable().optional(),
|
||||
dueDate: z.string().nullable().optional(),
|
||||
amountLinked: z.union([z.number(), z.string()]).nullable().optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
}),
|
||||
)
|
||||
.nullable()
|
||||
.optional(),
|
||||
paymentMilestones: z
|
||||
.array(
|
||||
z.object({
|
||||
concept: z.string().nullable().optional(),
|
||||
paymentDate: z.string().nullable().optional(),
|
||||
amount: z.union([z.number(), z.string()]).nullable().optional(),
|
||||
}),
|
||||
)
|
||||
.nullable()
|
||||
.optional(),
|
||||
riskClauses: z
|
||||
.array(
|
||||
z.object({
|
||||
title: z.string().nullable().optional(),
|
||||
snippet: z.string().nullable().optional(),
|
||||
riskLevel: z.enum(["low", "medium", "high"]).nullable().optional(),
|
||||
}),
|
||||
)
|
||||
.nullable()
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type OpenAiChatCompletionResponse = {
|
||||
choices?: Array<{
|
||||
message?: {
|
||||
content?: string | null;
|
||||
};
|
||||
}>;
|
||||
model?: string;
|
||||
usage?: {
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
total_tokens?: number;
|
||||
};
|
||||
error?: {
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
|
||||
function parsePositiveInteger(value: string | undefined, fallback: number) {
|
||||
const parsed = Number.parseInt((value ?? "").trim(), 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function getOpenAiApiKey() {
|
||||
return process.env.OPENAI_API_KEY?.trim() || process.env.API_KEY?.trim() || process.env.api_key?.trim() || "";
|
||||
}
|
||||
|
||||
function compactText(value: string, maxLength = 600) {
|
||||
const compacted = value.replace(/\s+/g, " ").trim();
|
||||
if (!compacted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return compacted.length > maxLength ? compacted.slice(0, maxLength) : compacted;
|
||||
}
|
||||
|
||||
function normalizeIsoDate(value: unknown): string | null {
|
||||
if (!value || typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const compact = value.trim();
|
||||
if (!compact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = new Date(compact);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed.toISOString();
|
||||
}
|
||||
|
||||
function normalizeAmount(value: unknown): number | null {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? Number(value.toFixed(2)) : null;
|
||||
}
|
||||
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = value
|
||||
.replace(/[^\d.,-]/g, "")
|
||||
.replace(/\.(?=.*\.)/g, "")
|
||||
.replace(/,/g, ".");
|
||||
|
||||
const parsed = Number.parseFloat(normalized);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Number(parsed.toFixed(2));
|
||||
}
|
||||
|
||||
function clampText(fullText: string) {
|
||||
const maxChars = parsePositiveInteger(process.env.OPENAI_CONTRACT_MAX_CHARS, DEFAULT_MAX_TEXT_CHARS);
|
||||
|
||||
if (fullText.length <= maxChars) {
|
||||
return fullText;
|
||||
}
|
||||
|
||||
return `${fullText.slice(0, maxChars)}\n\n[TEXT_TRUNCATED_TO_${maxChars}_CHARS]`;
|
||||
}
|
||||
|
||||
function extractJsonObject(raw: string) {
|
||||
const trimmed = raw.trim();
|
||||
|
||||
try {
|
||||
return JSON.parse(trimmed) as unknown;
|
||||
} catch {
|
||||
// Continue with alternative parsing.
|
||||
}
|
||||
|
||||
const noFence = trimmed
|
||||
.replace(/^```json\s*/i, "")
|
||||
.replace(/^```\s*/i, "")
|
||||
.replace(/\s*```$/i, "")
|
||||
.trim();
|
||||
|
||||
try {
|
||||
return JSON.parse(noFence) as unknown;
|
||||
} catch {
|
||||
// Continue with slicing.
|
||||
}
|
||||
|
||||
const firstBrace = noFence.indexOf("{");
|
||||
const lastBrace = noFence.lastIndexOf("}");
|
||||
|
||||
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
||||
return JSON.parse(noFence.slice(firstBrace, lastBrace + 1)) as unknown;
|
||||
}
|
||||
|
||||
throw new Error("La respuesta de IA no contiene JSON valido.");
|
||||
}
|
||||
|
||||
function inferContractNumber(text: string) {
|
||||
const match = text.match(/(?:contrato|numero\s+de\s+contrato|expediente)\s*(?:no\.?|num(?:ero)?\.?|:)\s*([a-z0-9\-_/]{4,40})/i);
|
||||
return compactText(match?.[1] ?? "", 80);
|
||||
}
|
||||
|
||||
function inferAmount(text: string) {
|
||||
const match = text.match(/(?:monto\s+total|importe\s+total|total\s+a\s+pagar)\s*[:\-]?\s*\$?\s*([\d.,]{4,20})/i);
|
||||
return normalizeAmount(match?.[1] ?? null);
|
||||
}
|
||||
|
||||
function inferCounterparty(text: string) {
|
||||
const match = text.match(/(?:entidad\s+contratante|dependencia|contratante)\s*[:\-]\s*([^\n]{4,160})/i);
|
||||
return compactText(match?.[1] ?? "", 220);
|
||||
}
|
||||
|
||||
function inferTitle(text: string) {
|
||||
const lines = text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const contractLine = lines.find((line) => /\bcontrato\b/i.test(line));
|
||||
return compactText(contractLine ?? lines[0] ?? "Contrato sin titulo", 220);
|
||||
}
|
||||
|
||||
function inferContractType(text: string) {
|
||||
if (/obra\s+publica/i.test(text)) {
|
||||
return "Obra publica";
|
||||
}
|
||||
|
||||
if (/servicio/i.test(text)) {
|
||||
return "Servicios";
|
||||
}
|
||||
|
||||
if (/adquisicion/i.test(text)) {
|
||||
return "Adquisiciones";
|
||||
}
|
||||
|
||||
return "General";
|
||||
}
|
||||
|
||||
function inferDeliverables(text: string) {
|
||||
const lines = text.split(/\r?\n/);
|
||||
const rows = lines
|
||||
.filter((line) => /entregable|hito|entrega/i.test(line))
|
||||
.slice(0, 12)
|
||||
.map((line) => ({
|
||||
title: compactText(line, 220) ?? "Entregable detectado",
|
||||
dueDate: null,
|
||||
amountLinked: null,
|
||||
notes: null,
|
||||
}));
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function inferPaymentMilestones(text: string) {
|
||||
const lines = text.split(/\r?\n/);
|
||||
const rows = lines
|
||||
.filter((line) => /pago|anticipo|estimacion|factura/i.test(line))
|
||||
.slice(0, 12)
|
||||
.map((line) => ({
|
||||
concept: compactText(line, 220) ?? "Hito de pago detectado",
|
||||
paymentDate: null,
|
||||
amount: null,
|
||||
}));
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function inferRiskClauses(text: string): ContractRiskClause[] {
|
||||
const lines = text.split(/\r?\n/);
|
||||
const catalog: Array<{ key: string; title: string; riskLevel: "medium" | "high" }> = [
|
||||
{ key: "penalizacion", title: "Penalizaciones economicas", riskLevel: "high" },
|
||||
{ key: "rescision", title: "Causal de rescision", riskLevel: "high" },
|
||||
{ key: "garantia", title: "Garantias y fianzas", riskLevel: "medium" },
|
||||
{ key: "retencion", title: "Retencion de pagos", riskLevel: "high" },
|
||||
{ key: "sancion", title: "Sanciones administrativas", riskLevel: "high" },
|
||||
];
|
||||
|
||||
const matches: ContractRiskClause[] = [];
|
||||
|
||||
for (const item of catalog) {
|
||||
const foundLine = lines.find((line) => line.toLowerCase().includes(item.key));
|
||||
|
||||
if (!foundLine) {
|
||||
continue;
|
||||
}
|
||||
|
||||
matches.push({
|
||||
title: item.title,
|
||||
snippet: compactText(foundLine, 320) ?? item.title,
|
||||
riskLevel: item.riskLevel,
|
||||
requiresLegalValidation: true,
|
||||
});
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
function buildRegexFallback(fullText: string): ContractAiExtractionOutput {
|
||||
const riskClauses = inferRiskClauses(fullText);
|
||||
|
||||
return {
|
||||
fields: {
|
||||
title: inferTitle(fullText),
|
||||
counterpartyEntity: inferCounterparty(fullText),
|
||||
contractNumber: inferContractNumber(fullText),
|
||||
contractType: inferContractType(fullText),
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
totalAmount: inferAmount(fullText),
|
||||
currency: "MXN",
|
||||
description: compactText(fullText, 800),
|
||||
},
|
||||
deliverables: inferDeliverables(fullText),
|
||||
paymentMilestones: inferPaymentMilestones(fullText),
|
||||
riskClauses,
|
||||
warnings: [
|
||||
{
|
||||
code: "CONTRACT_EXTRACTION_FALLBACK",
|
||||
message: "Extraccion parcial con heuristicas. Requiere validacion legal.",
|
||||
},
|
||||
],
|
||||
engine: "regex_fallback",
|
||||
model: null,
|
||||
usage: null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAiOutput(payload: z.infer<typeof ContractExtractionResponseSchema>): ContractAiExtractionOutput {
|
||||
const fallback = buildRegexFallback("");
|
||||
|
||||
const fields = payload.fields ?? {};
|
||||
const deliverables = Array.isArray(payload.deliverables) ? payload.deliverables : [];
|
||||
const paymentMilestones = Array.isArray(payload.paymentMilestones) ? payload.paymentMilestones : [];
|
||||
const riskClauses = Array.isArray(payload.riskClauses) ? payload.riskClauses : [];
|
||||
|
||||
return {
|
||||
fields: {
|
||||
title: compactText(fields.title ?? "", 220),
|
||||
counterpartyEntity: compactText(fields.counterpartyEntity ?? "", 220),
|
||||
contractNumber: compactText(fields.contractNumber ?? "", 120),
|
||||
contractType: compactText(fields.contractType ?? "", 120),
|
||||
startDate: normalizeIsoDate(fields.startDate),
|
||||
endDate: normalizeIsoDate(fields.endDate),
|
||||
totalAmount: normalizeAmount(fields.totalAmount),
|
||||
currency: compactText(fields.currency ?? "", 10) ?? "MXN",
|
||||
description: compactText(fields.description ?? "", 1200),
|
||||
},
|
||||
deliverables: deliverables
|
||||
.map((item) => ({
|
||||
title: compactText(item.title ?? "", 220),
|
||||
dueDate: normalizeIsoDate(item.dueDate),
|
||||
amountLinked: normalizeAmount(item.amountLinked),
|
||||
notes: compactText(item.notes ?? "", 500),
|
||||
}))
|
||||
.filter((item): item is { title: string; dueDate: string | null; amountLinked: number | null; notes: string | null } => Boolean(item.title)),
|
||||
paymentMilestones: paymentMilestones
|
||||
.map((item) => ({
|
||||
concept: compactText(item.concept ?? "", 220),
|
||||
paymentDate: normalizeIsoDate(item.paymentDate),
|
||||
amount: normalizeAmount(item.amount),
|
||||
}))
|
||||
.filter((item): item is { concept: string; paymentDate: string | null; amount: number | null } => Boolean(item.concept)),
|
||||
riskClauses: riskClauses
|
||||
.map((item) => ({
|
||||
title: compactText(item.title ?? "", 160),
|
||||
snippet: compactText(item.snippet ?? "", 320),
|
||||
riskLevel: item.riskLevel ?? "medium",
|
||||
requiresLegalValidation: true,
|
||||
}))
|
||||
.filter((item): item is ContractRiskClause => Boolean(item.title) && Boolean(item.snippet)),
|
||||
warnings: fallback.warnings,
|
||||
engine: "openai_json_object",
|
||||
model: null,
|
||||
usage: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function callOpenAi(fullText: string) {
|
||||
const apiKey = getOpenAiApiKey();
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error("No se encontro API key para OpenAI.");
|
||||
}
|
||||
|
||||
const model = process.env.OPENAI_CONTRACT_MODEL?.trim() || DEFAULT_OPENAI_MODEL;
|
||||
const timeoutMs = parsePositiveInteger(process.env.OPENAI_CONTRACT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS);
|
||||
const baseUrl = (process.env.OPENAI_API_BASE_URL?.trim() || DEFAULT_OPENAI_BASE_URL).replace(/\/+$/, "");
|
||||
|
||||
const systemPrompt = [
|
||||
"Eres analista juridico especializado en contratos publicos mexicanos.",
|
||||
"Responde unicamente JSON valido.",
|
||||
"Si no hay certeza en un campo usa null.",
|
||||
"Identifica riesgos contractuales y marca enfoque conservador.",
|
||||
].join(" ");
|
||||
|
||||
const userPrompt = [
|
||||
"Extrae campos del contrato y devuelve JSON con claves: fields, deliverables, paymentMilestones, riskClauses.",
|
||||
"fields: title,counterpartyEntity,contractNumber,contractType,startDate,endDate,totalAmount,currency,description.",
|
||||
"deliverables: [{title,dueDate,amountLinked,notes}]",
|
||||
"paymentMilestones: [{concept,paymentDate,amount}]",
|
||||
"riskClauses: [{title,snippet,riskLevel}]",
|
||||
"Texto:",
|
||||
clampText(fullText),
|
||||
].join("\n\n");
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
temperature: 0,
|
||||
response_format: {
|
||||
type: "json_object",
|
||||
},
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userPrompt },
|
||||
],
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as OpenAiChatCompletionResponse;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error?.message ?? `OpenAI error ${response.status}`);
|
||||
}
|
||||
|
||||
const content = payload.choices?.[0]?.message?.content;
|
||||
|
||||
if (!content) {
|
||||
throw new Error("OpenAI no devolvio contenido util.");
|
||||
}
|
||||
|
||||
const parsed = ContractExtractionResponseSchema.parse(extractJsonObject(content));
|
||||
const normalized = normalizeAiOutput(parsed);
|
||||
|
||||
return {
|
||||
...normalized,
|
||||
engine: "openai_json_object" as const,
|
||||
model: payload.model ?? model,
|
||||
usage: {
|
||||
promptTokens: payload.usage?.prompt_tokens ?? null,
|
||||
completionTokens: payload.usage?.completion_tokens ?? null,
|
||||
totalTokens: payload.usage?.total_tokens ?? null,
|
||||
},
|
||||
warnings: [
|
||||
{
|
||||
code: "LEGAL_VALIDATION_REQUIRED",
|
||||
message: "Resultado automatizado. Requiere validacion legal antes de uso formal.",
|
||||
},
|
||||
],
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function extractContractWithAi(fullText: string): Promise<ContractAiExtractionOutput> {
|
||||
const cleaned = fullText.trim();
|
||||
|
||||
if (!cleaned) {
|
||||
return buildRegexFallback(fullText);
|
||||
}
|
||||
|
||||
try {
|
||||
return await callOpenAi(cleaned);
|
||||
} catch (error) {
|
||||
const fallback = buildRegexFallback(cleaned);
|
||||
fallback.warnings.push({
|
||||
code: "OPENAI_CONTRACT_EXTRACTION_FAILED",
|
||||
message: error instanceof Error ? error.message : "No fue posible usar IA para extraer el contrato.",
|
||||
});
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user