initial push

This commit is contained in:
Marcelo Dares
2026-03-15 15:03:56 +01:00
parent d48b9d5352
commit 65aaf9275e
146 changed files with 70245 additions and 100 deletions

288
src/app/admin/page.tsx Normal file
View File

@@ -0,0 +1,288 @@
import { ContentPageType, OrganizationDocumentType, OverallScoreMethod, UserRole } from "@prisma/client";
import { PageShell } from "@/components/app/page-shell";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { requireAdminUser } from "@/lib/auth/admin";
import { getScoringConfig } from "@/lib/scoring-config";
import { prisma } from "@/lib/prisma";
type AdminPageProps = {
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;
}
const statusMessages: Record<string, string> = {
role_updated: "Rol de usuario actualizado.",
content_created: "Contenido creado correctamente.",
content_updated: "Contenido actualizado correctamente.",
scoring_updated: "Configuracion de scoring actualizada.",
admin_error: "No se pudo completar la operacion de admin.",
};
export default async function AdminPage({ searchParams }: AdminPageProps) {
await requireAdminUser();
const params = await searchParams;
const status = getParam(params, "status");
const statusMessage = status ? statusMessages[status] : null;
const [users, contentPages, modules, scoringConfig] = await Promise.all([
prisma.user.findMany({
orderBy: { createdAt: "desc" },
select: {
id: true,
email: true,
name: true,
role: true,
emailVerifiedAt: true,
createdAt: true,
organization: {
select: {
id: true,
name: true,
onboardingCompletedAt: true,
documents: {
where: { type: OrganizationDocumentType.ACTA_CONSTITUTIVA },
select: { id: true },
take: 1,
},
},
},
},
}),
prisma.contentPage.findMany({
orderBy: [{ type: "asc" }, { sortOrder: "asc" }, { createdAt: "asc" }],
select: {
id: true,
type: true,
slug: true,
title: true,
content: true,
sortOrder: true,
isPublished: true,
},
}),
prisma.diagnosticModule.findMany({
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
select: {
key: true,
name: true,
},
}),
getScoringConfig(),
]);
return (
<PageShell title="Admin Portal" description="Gestiona usuarios, contenidos y configuracion de scoring.">
{statusMessage ? (
<Card>
<CardContent className="py-4">
<p className="text-sm font-semibold text-[#35527c]">{statusMessage}</p>
</CardContent>
</Card>
) : null}
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-[#1f2a40]">Configuracion de scoring</h2>
</CardHeader>
<CardContent>
<form action="/api/admin/scoring" method="post" className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
Umbral de modulo bajo (%)
<input
type="number"
name="lowScoreThreshold"
min={0}
max={100}
defaultValue={scoringConfig.lowScoreThreshold}
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]">
Metodo de score global
<select
name="overallScoreMethod"
defaultValue={scoringConfig.overallScoreMethod}
className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm"
>
<option value={OverallScoreMethod.EQUAL_ALL_MODULES}>Promedio igual entre todos los modulos</option>
<option value={OverallScoreMethod.EQUAL_ANSWERED_MODULES}>Promedio igual entre modulos respondidos</option>
<option value={OverallScoreMethod.WEIGHTED_ANSWERED_MODULES}>Promedio ponderado por modulo (respondidos)</option>
</select>
</label>
</div>
<div className="space-y-2">
<p className="text-sm font-semibold text-[#33415c]">Pesos por modulo (solo aplica en metodo ponderado)</p>
<div className="grid gap-3 md:grid-cols-2">
{modules.map((module) => (
<label key={module.key} className="space-y-1 text-sm font-medium text-[#52617b]">
{module.name}
<input
type="number"
name={`weight:${module.key}`}
min={0.1}
step={0.1}
defaultValue={scoringConfig.moduleWeights[module.key] ?? 1}
className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm"
/>
</label>
))}
</div>
</div>
<Button type="submit">Guardar scoring</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-[#1f2a40]">Usuarios</h2>
</CardHeader>
<CardContent className="space-y-3">
{users.map((user) => {
const completedOnboarding = Boolean(user.organization?.onboardingCompletedAt && user.organization.documents.length > 0);
return (
<div key={user.id} className="rounded-xl border border-[#e1e8f4] p-3">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-sm font-semibold text-[#273750]">{user.name ?? "Sin nombre"}</p>
<p className="text-xs text-[#5f6d86]">{user.email}</p>
</div>
<div className="flex items-center gap-2">
<Badge variant={user.emailVerifiedAt ? "success" : "warning"}>{user.emailVerifiedAt ? "Verificado" : "No verificado"}</Badge>
<Badge variant={completedOnboarding ? "success" : "neutral"}>
{completedOnboarding ? "Onboarded" : "Sin onboarding"}
</Badge>
</div>
</div>
<form action="/api/admin/users/role" method="post" className="flex flex-wrap items-end gap-2">
<input type="hidden" name="userId" value={user.id} />
<label className="text-sm font-semibold text-[#33415c]">
Rol
<select name="role" defaultValue={user.role} className="ml-2 h-9 rounded-lg border border-[#cfd8e6] px-2 text-sm">
<option value={UserRole.USER}>USER</option>
<option value={UserRole.ADMIN}>ADMIN</option>
</select>
</label>
<Button type="submit" size="sm" variant="secondary">
Actualizar rol
</Button>
</form>
</div>
);
})}
</CardContent>
</Card>
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-[#1f2a40]">Crear contenido (Manual / FAQ)</h2>
</CardHeader>
<CardContent>
<form action="/api/admin/content/create" method="post" className="grid gap-3 md:grid-cols-2">
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
Tipo
<select name="type" className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm" defaultValue={ContentPageType.FAQ}>
<option value={ContentPageType.FAQ}>FAQ</option>
<option value={ContentPageType.MANUAL}>MANUAL</option>
</select>
</label>
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
Slug
<input name="slug" required className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm" placeholder="faq-nueva-politica" />
</label>
<label className="space-y-1 text-sm font-semibold text-[#33415c] md:col-span-2">
Titulo
<input name="title" required className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm" placeholder="Nueva entrada" />
</label>
<label className="space-y-1 text-sm font-semibold text-[#33415c] md:col-span-2">
Contenido
<textarea name="content" required 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]">
Orden
<input type="number" name="sortOrder" defaultValue={1} className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm" />
</label>
<label className="flex items-center gap-2 text-sm font-semibold text-[#33415c]">
<input type="checkbox" name="isPublished" defaultChecked className="h-4 w-4" />
Publicado
</label>
<div className="md:col-span-2">
<Button type="submit">Crear contenido</Button>
</div>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-[#1f2a40]">Contenido existente</h2>
</CardHeader>
<CardContent className="space-y-3">
{contentPages.map((page) => (
<form key={page.id} action="/api/admin/content/update" method="post" className="space-y-2 rounded-xl border border-[#e1e8f4] p-3">
<input type="hidden" name="id" value={page.id} />
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
Tipo
<select name="type" defaultValue={page.type} className="h-9 w-full rounded-lg border border-[#cfd8e6] px-2 text-sm">
<option value={ContentPageType.FAQ}>FAQ</option>
<option value={ContentPageType.MANUAL}>MANUAL</option>
</select>
</label>
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
Slug
<input name="slug" defaultValue={page.slug} className="h-9 w-full rounded-lg border border-[#cfd8e6] px-2 text-sm" />
</label>
<label className="space-y-1 text-sm font-semibold text-[#33415c] md:col-span-2">
Titulo
<input name="title" defaultValue={page.title} className="h-9 w-full rounded-lg border border-[#cfd8e6] px-2 text-sm" />
</label>
<label className="space-y-1 text-sm font-semibold text-[#33415c] md:col-span-2">
Contenido
<textarea name="content" defaultValue={page.content} rows={3} className="w-full rounded-lg border border-[#cfd8e6] px-2 py-2 text-sm" />
</label>
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
Orden
<input type="number" name="sortOrder" defaultValue={page.sortOrder} className="h-9 w-full rounded-lg border border-[#cfd8e6] px-2 text-sm" />
</label>
<label className="flex items-center gap-2 text-sm font-semibold text-[#33415c]">
<input type="checkbox" name="isPublished" defaultChecked={page.isPublished} className="h-4 w-4" />
Publicado
</label>
</div>
<Button type="submit" size="sm" variant="secondary">
Guardar cambios
</Button>
</form>
))}
</CardContent>
</Card>
</PageShell>
);
}

View File

@@ -0,0 +1,3 @@
export const runtime = "nodejs";
export { POST } from "@/app/api/onboarding/acta/route";

View File

@@ -0,0 +1,52 @@
import { NextResponse } from "next/server";
import { ContentPageType } from "@prisma/client";
import { requireAdminApiUser } from "@/lib/auth/admin";
import { prisma } from "@/lib/prisma";
import { buildAppUrl } from "@/lib/http/url";
function redirectTo(request: Request, status: string) {
return NextResponse.redirect(buildAppUrl(request, "/admin", { status }));
}
function asString(formData: FormData, key: string) {
const value = formData.get(key);
return typeof value === "string" ? value.trim() : "";
}
export async function POST(request: Request) {
const adminUser = await requireAdminApiUser();
if (!adminUser) {
return redirectTo(request, "admin_error");
}
const formData = await request.formData();
const typeValue = asString(formData, "type");
const slug = asString(formData, "slug");
const title = asString(formData, "title");
const content = asString(formData, "content");
const sortOrder = Number.parseInt(asString(formData, "sortOrder") || "0", 10);
const isPublished = formData.get("isPublished") === "on";
if (!slug || !title || !content || (typeValue !== ContentPageType.FAQ && typeValue !== ContentPageType.MANUAL)) {
return redirectTo(request, "admin_error");
}
try {
await prisma.contentPage.create({
data: {
type: typeValue as ContentPageType,
slug,
title,
content,
sortOrder: Number.isNaN(sortOrder) ? 0 : sortOrder,
isPublished,
},
});
return redirectTo(request, "content_created");
} catch {
return redirectTo(request, "admin_error");
}
}

View File

@@ -0,0 +1,54 @@
import { NextResponse } from "next/server";
import { ContentPageType } from "@prisma/client";
import { requireAdminApiUser } from "@/lib/auth/admin";
import { prisma } from "@/lib/prisma";
import { buildAppUrl } from "@/lib/http/url";
function redirectTo(request: Request, status: string) {
return NextResponse.redirect(buildAppUrl(request, "/admin", { status }));
}
function asString(formData: FormData, key: string) {
const value = formData.get(key);
return typeof value === "string" ? value.trim() : "";
}
export async function POST(request: Request) {
const adminUser = await requireAdminApiUser();
if (!adminUser) {
return redirectTo(request, "admin_error");
}
const formData = await request.formData();
const id = asString(formData, "id");
const typeValue = asString(formData, "type");
const slug = asString(formData, "slug");
const title = asString(formData, "title");
const content = asString(formData, "content");
const sortOrder = Number.parseInt(asString(formData, "sortOrder") || "0", 10);
const isPublished = formData.get("isPublished") === "on";
if (!id || !slug || !title || !content || (typeValue !== ContentPageType.FAQ && typeValue !== ContentPageType.MANUAL)) {
return redirectTo(request, "admin_error");
}
try {
await prisma.contentPage.update({
where: { id },
data: {
type: typeValue as ContentPageType,
slug,
title,
content,
sortOrder: Number.isNaN(sortOrder) ? 0 : sortOrder,
isPublished,
},
});
return redirectTo(request, "content_updated");
} catch {
return redirectTo(request, "admin_error");
}
}

View File

@@ -0,0 +1,72 @@
import { NextResponse } from "next/server";
import { OverallScoreMethod } from "@prisma/client";
import { requireAdminApiUser } from "@/lib/auth/admin";
import { DEFAULT_SCORING_CONFIG } from "@/lib/scoring-config";
import { prisma } from "@/lib/prisma";
import { buildAppUrl } from "@/lib/http/url";
function redirectTo(request: Request, status: string) {
return NextResponse.redirect(buildAppUrl(request, "/admin", { status }));
}
function asString(formData: FormData, key: string) {
const value = formData.get(key);
return typeof value === "string" ? value.trim() : "";
}
export async function POST(request: Request) {
const adminUser = await requireAdminApiUser();
if (!adminUser) {
return redirectTo(request, "admin_error");
}
const formData = await request.formData();
const thresholdCandidate = Number.parseInt(asString(formData, "lowScoreThreshold") || "70", 10);
const lowScoreThreshold = Number.isNaN(thresholdCandidate) ? 70 : Math.min(100, Math.max(0, thresholdCandidate));
const methodCandidate = asString(formData, "overallScoreMethod");
const allowedMethods = Object.values(OverallScoreMethod);
const overallScoreMethod = allowedMethods.includes(methodCandidate as OverallScoreMethod)
? (methodCandidate as OverallScoreMethod)
: OverallScoreMethod.EQUAL_ALL_MODULES;
const moduleWeights: Record<string, number> = {};
for (const [key, rawValue] of formData.entries()) {
if (!key.startsWith("weight:")) {
continue;
}
const moduleKey = key.replace("weight:", "").trim();
const numericValue = Number.parseFloat(typeof rawValue === "string" ? rawValue : String(rawValue));
if (moduleKey && !Number.isNaN(numericValue) && Number.isFinite(numericValue) && numericValue > 0) {
moduleWeights[moduleKey] = numericValue;
}
}
try {
await prisma.scoringConfig.upsert({
where: {
key: DEFAULT_SCORING_CONFIG.key,
},
update: {
lowScoreThreshold,
overallScoreMethod,
moduleWeights,
},
create: {
key: DEFAULT_SCORING_CONFIG.key,
lowScoreThreshold,
overallScoreMethod,
moduleWeights,
},
});
return redirectTo(request, "scoring_updated");
} catch {
return redirectTo(request, "admin_error");
}
}

View File

@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { requireAdminApiUser } from "@/lib/auth/admin";
import { runDailyLicitationsSync } from "@/lib/licitations/sync";
export const runtime = "nodejs";
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().catch(() => ({}))) as {
municipalityId?: string;
limit?: number;
skip?: number;
targetYear?: number;
includePnt?: boolean;
force?: boolean;
};
const payload = await runDailyLicitationsSync({
municipalityId: typeof body.municipalityId === "string" ? body.municipalityId : undefined,
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,
});
return NextResponse.json({ ok: true, payload });
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "No se pudo ejecutar el sync de licitaciones.",
},
{ status: 400 },
);
}
}

View File

@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { UserRole } from "@prisma/client";
import { requireAdminApiUser } from "@/lib/auth/admin";
import { prisma } from "@/lib/prisma";
import { buildAppUrl } from "@/lib/http/url";
function redirectTo(request: Request, status: string) {
return NextResponse.redirect(buildAppUrl(request, "/admin", { status }));
}
export async function POST(request: Request) {
const adminUser = await requireAdminApiUser();
if (!adminUser) {
return redirectTo(request, "admin_error");
}
const formData = await request.formData();
const userId = typeof formData.get("userId") === "string" ? formData.get("userId")!.toString() : "";
const roleValue = typeof formData.get("role") === "string" ? formData.get("role")!.toString() : "";
if (!userId || (roleValue !== UserRole.USER && roleValue !== UserRole.ADMIN)) {
return redirectTo(request, "admin_error");
}
try {
await prisma.user.update({
where: { id: userId },
data: {
role: roleValue as UserRole,
},
});
return redirectTo(request, "role_updated");
} catch {
return redirectTo(request, "admin_error");
}
}

View File

@@ -0,0 +1,87 @@
import { NextResponse } from "next/server";
import { isAdminIdentity } from "@/lib/auth/admin";
import { createSessionToken, setSessionCookie } from "@/lib/auth/session";
import { verifyPassword } from "@/lib/auth/password";
import { issueEmailVerificationToken, sendEmailVerificationLink } from "@/lib/auth/verification";
import { prisma } from "@/lib/prisma";
import { buildAppUrl } from "@/lib/http/url";
function redirectTo(request: Request, path: string, params: Record<string, string>) {
return NextResponse.redirect(buildAppUrl(request, path, params));
}
export async function POST(request: Request) {
const formData = await request.formData();
const email = typeof formData.get("email") === "string" ? formData.get("email")!.toString().trim().toLowerCase() : "";
const password = typeof formData.get("password") === "string" ? formData.get("password")!.toString() : "";
if (!email || !password) {
return redirectTo(request, "/login", { error: "invalid_credentials" });
}
try {
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
return redirectTo(request, "/login", { error: "invalid_credentials" });
}
const passwordMatches = await verifyPassword(password, user.passwordHash);
if (!passwordMatches) {
return redirectTo(request, "/login", { error: "invalid_credentials" });
}
if (!user.emailVerifiedAt) {
const { token } = await issueEmailVerificationToken(user.id);
const sendResult = await sendEmailVerificationLink(request, email, token);
const verifyParams: Record<string, string> = {
email,
sent: sendResult.sent ? "1" : "0",
unverified: "1",
};
if (!sendResult.sent) {
verifyParams.error = "email_delivery_failed";
}
return redirectTo(request, "/verify", verifyParams);
}
let onboardingCompleted = false;
try {
const organization = await prisma.organization.findUnique({
where: { userId: user.id },
select: { onboardingCompletedAt: true },
});
onboardingCompleted = Boolean(organization?.onboardingCompletedAt);
} catch {
// Backward compatibility for databases that have not applied onboarding v2 migration yet.
const legacyOrganization = await prisma.organization.findUnique({
where: { userId: user.id },
select: { id: true },
});
onboardingCompleted = Boolean(legacyOrganization);
}
const targetPath = isAdminIdentity(user.email, user.role)
? "/admin"
: onboardingCompleted
? "/dashboard"
: "/onboarding";
const response = NextResponse.redirect(buildAppUrl(request, targetPath));
const token = createSessionToken(user.id, user.email);
setSessionCookie(response, token);
return response;
} catch {
return redirectTo(request, "/login", { error: "server_error" });
}
}

View File

@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { clearSessionCookie } from "@/lib/auth/session";
import { buildAppUrl } from "@/lib/http/url";
export async function POST(request: Request) {
const response = NextResponse.redirect(buildAppUrl(request, "/login", { logged_out: "1" }));
clearSessionCookie(response);
return response;
}

View File

@@ -0,0 +1,75 @@
import { UserRole } from "@prisma/client";
import { NextResponse } from "next/server";
import { isConfiguredAdminEmail } from "@/lib/auth/admin";
import { hashPassword } from "@/lib/auth/password";
import { prisma } from "@/lib/prisma";
import { issueEmailVerificationToken, sendEmailVerificationLink } from "@/lib/auth/verification";
import { buildAppUrl } from "@/lib/http/url";
function redirectTo(request: Request, path: string, params: Record<string, string>) {
return NextResponse.redirect(buildAppUrl(request, path, params));
}
export async function POST(request: Request) {
const formData = await request.formData();
const name = typeof formData.get("name") === "string" ? formData.get("name")!.toString().trim() : "";
const email = typeof formData.get("email") === "string" ? formData.get("email")!.toString().trim().toLowerCase() : "";
const password = typeof formData.get("password") === "string" ? formData.get("password")!.toString() : "";
if (!email || !password || password.length < 8) {
return redirectTo(request, "/register", { error: "invalid_input" });
}
try {
const existingUser = await prisma.user.findUnique({ where: { email } });
if (existingUser) {
if (!existingUser.emailVerifiedAt) {
const { token } = await issueEmailVerificationToken(existingUser.id);
const sendResult = await sendEmailVerificationLink(request, email, token);
const verifyParams: Record<string, string> = {
email,
sent: sendResult.sent ? "1" : "0",
unverified: "1",
};
if (!sendResult.sent) {
verifyParams.error = "email_delivery_failed";
}
return redirectTo(request, "/verify", verifyParams);
}
return redirectTo(request, "/register", { error: "email_in_use" });
}
const passwordHash = await hashPassword(password);
const user = await prisma.user.create({
data: {
name: name || null,
email,
passwordHash,
role: isConfiguredAdminEmail(email) ? UserRole.ADMIN : UserRole.USER,
},
});
const { token } = await issueEmailVerificationToken(user.id);
const sendResult = await sendEmailVerificationLink(request, email, token);
const verifyParams: Record<string, string> = {
email,
sent: sendResult.sent ? "1" : "0",
};
if (!sendResult.sent) {
verifyParams.error = "email_delivery_failed";
}
return redirectTo(request, "/verify", verifyParams);
} catch {
return redirectTo(request, "/register", { error: "server_error" });
}
}

View File

@@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { issueEmailVerificationToken, sendEmailVerificationLink } from "@/lib/auth/verification";
import { prisma } from "@/lib/prisma";
import { buildAppUrl } from "@/lib/http/url";
function redirectTo(request: Request, path: string, params: Record<string, string>) {
return NextResponse.redirect(buildAppUrl(request, path, params));
}
export async function POST(request: Request) {
const formData = await request.formData();
const email = typeof formData.get("email") === "string" ? formData.get("email")!.toString().trim().toLowerCase() : "";
if (!email) {
return redirectTo(request, "/verify", { error: "missing_email" });
}
try {
const user = await prisma.user.findUnique({ where: { email } });
if (user && !user.emailVerifiedAt) {
const { token } = await issueEmailVerificationToken(user.id);
const sendResult = await sendEmailVerificationLink(request, email, token);
const verifyParams: Record<string, string> = {
email,
sent: sendResult.sent ? "1" : "0",
};
if (!sendResult.sent) {
verifyParams.error = "email_delivery_failed";
}
return redirectTo(request, "/verify", verifyParams);
}
return redirectTo(request, "/verify", { email, sent: "1" });
} catch {
return redirectTo(request, "/verify", { email, error: "server_error" });
}
}

View File

@@ -0,0 +1,54 @@
import { NextResponse } from "next/server";
import { licitationsConfig } from "@/lib/licitations/config";
import { runDailyLicitationsSync } from "@/lib/licitations/sync";
export const runtime = "nodejs";
function parsePositiveInt(value: string | null) {
if (!value) {
return undefined;
}
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
}
function parseBoolean(value: string | null) {
if (!value) {
return undefined;
}
const normalized = value.trim().toLowerCase();
if (["1", "true", "yes", "si"].includes(normalized)) {
return true;
}
if (["0", "false", "no"].includes(normalized)) {
return false;
}
return undefined;
}
export async function POST(request: Request) {
const token = request.headers.get("x-sync-token") ?? "";
if (!licitationsConfig.syncCronToken || token !== licitationsConfig.syncCronToken) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const url = new URL(request.url);
const municipalityId = url.searchParams.get("municipality_id") ?? undefined;
const limit = parsePositiveInt(url.searchParams.get("limit"));
const skip = parsePositiveInt(url.searchParams.get("skip"));
const targetYear = parsePositiveInt(url.searchParams.get("target_year"));
const includePnt = parseBoolean(url.searchParams.get("include_pnt"));
const force = parseBoolean(url.searchParams.get("force"));
const payload = await runDailyLicitationsSync({ municipalityId, limit, skip, targetYear, includePnt, force });
return NextResponse.json({
ok: true,
payload,
});
}

View File

@@ -0,0 +1,160 @@
import { Prisma } from "@prisma/client";
import { NextResponse } from "next/server";
import { getSessionPayload } from "@/lib/auth/session";
import { prisma } from "@/lib/prisma";
function parseString(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}
function parseEvidence(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
const notes = parseString(record.notes).slice(0, 2000);
const links = Array.isArray(record.links)
? record.links
.map((entry) => parseString(entry))
.filter((entry) => entry.length > 0)
.slice(0, 10)
: [];
if (!notes && links.length === 0) {
return null;
}
return {
...(notes ? { notes } : {}),
...(links.length > 0 ? { links } : {}),
};
}
function isLegacyResponseEvidenceSchemaError(error: unknown) {
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
}
export async function POST(request: Request) {
const session = await getSessionPayload();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = (await request.json()) as Record<string, unknown>;
const moduleKey = parseString(body.moduleKey);
const questionId = parseString(body.questionId);
const answerOptionId = parseString(body.answerOptionId);
const evidence = parseEvidence(body.evidence);
const normalizedEvidence = evidence ?? Prisma.DbNull;
if (!moduleKey || !questionId || !answerOptionId) {
return NextResponse.json({ error: "Missing response payload fields." }, { status: 400 });
}
const question = await prisma.question.findUnique({
where: { id: questionId },
select: {
id: true,
module: {
select: {
key: true,
id: true,
questions: {
select: {
id: true,
},
},
},
},
},
});
if (!question || question.module.key !== moduleKey) {
return NextResponse.json({ error: "Question does not belong to the target module." }, { status: 400 });
}
const option = await prisma.answerOption.findFirst({
where: {
id: answerOptionId,
questionId,
},
select: {
id: true,
},
});
if (!option) {
return NextResponse.json({ error: "Invalid answer option for this question." }, { status: 400 });
}
try {
await prisma.response.upsert({
where: {
userId_questionId: {
userId: session.userId,
questionId,
},
},
update: {
answerOptionId,
evidence: normalizedEvidence,
},
create: {
userId: session.userId,
questionId,
answerOptionId,
evidence: normalizedEvidence,
},
});
} catch (error) {
if (!isLegacyResponseEvidenceSchemaError(error)) {
throw error;
}
await prisma.response.upsert({
where: {
userId_questionId: {
userId: session.userId,
questionId,
},
},
update: {
answerOptionId,
},
create: {
userId: session.userId,
questionId,
answerOptionId,
},
});
}
const moduleQuestionIds = question.module.questions.map((moduleQuestion) => moduleQuestion.id);
const answeredCount = await prisma.response.count({
where: {
userId: session.userId,
questionId: {
in: moduleQuestionIds,
},
},
});
const totalQuestions = moduleQuestionIds.length;
const completion = totalQuestions > 0 ? Math.round((answeredCount / totalQuestions) * 100) : 0;
return NextResponse.json({
ok: true,
progress: {
answeredCount,
totalQuestions,
completion,
},
});
} catch {
return NextResponse.json({ error: "Unable to save response." }, { status: 400 });
}
}

View File

@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { getSessionPayload } from "@/lib/auth/session";
import { getLicitationRecommendationsForUser } from "@/lib/licitations/recommendations";
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("profile_id");
const payload = await getLicitationRecommendationsForUser(session.userId, profileId);
return NextResponse.json(payload);
}

View File

@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import { getSessionPayload } from "@/lib/auth/session";
import { searchLicitations } from "@/lib/licitations/query";
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;
}
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 take = Math.min(parsePositiveInt(url.searchParams.get("take"), 50), 100);
const skip = parsePositiveInt(url.searchParams.get("skip"), 0);
const payload = await searchLicitations({
state: url.searchParams.get("state"),
municipality: url.searchParams.get("municipality"),
procedureType: url.searchParams.get("procedure_type"),
q: url.searchParams.get("q"),
minAmount: url.searchParams.get("min_amount"),
maxAmount: url.searchParams.get("max_amount"),
dateFrom: url.searchParams.get("date_from"),
dateTo: url.searchParams.get("date_to"),
includeClosed: url.searchParams.get("include_closed") === "true",
take,
skip,
});
return NextResponse.json(payload);
}

View File

@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { getSessionPayload } from "@/lib/auth/session";
import { listMunicipalities } from "@/lib/licitations/query";
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 state = url.searchParams.get("state");
const municipalities = await listMunicipalities({ state });
return NextResponse.json({
municipalities,
});
}

View File

@@ -0,0 +1,408 @@
import { OrganizationDocumentType, Prisma } from "@prisma/client";
import { NextResponse } from "next/server";
import { getSessionPayload } from "@/lib/auth/session";
import { extractActaDataWithAiBaseline } from "@/lib/extraction/aiExtractFields";
import { type ActaFields, type ActaLookupDictionary } from "@/lib/extraction/schema";
import { analyzePdf } from "@/lib/pdf/analyzePdf";
import {
OcrFailedError,
OcrUnavailableError,
PdfEncryptedError,
PdfNoTextDetectedError,
PdfUnreadableError,
} from "@/lib/pdf/errors";
import { prisma } from "@/lib/prisma";
import { MAX_ACTA_PDF_BYTES, removeStoredActaPdf, storeActaPdf } from "@/lib/onboarding/acta-storage";
export const runtime = "nodejs";
function isLegacySchemaError(error: unknown) {
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
}
function toOptionalString(value: string | null | undefined) {
return value ? value.trim() || undefined : undefined;
}
function toNullableString(value: string | null | undefined) {
return value ? value.trim() || null : null;
}
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 getExtractionConfidence(fields: ActaFields) {
const detected = Object.values(fields).filter(Boolean).length;
if (detected >= 6) {
return "high" as const;
}
if (detected >= 3) {
return "medium" as const;
}
return "low" as const;
}
function getDetectedFields(fields: ActaFields) {
return Object.entries(fields)
.filter(([, value]) => Boolean(value))
.map(([field]) => field);
}
function getDetectedLookupDictionaryFields(dictionary: ActaLookupDictionary) {
const entries: string[] = [];
for (const [key, value] of Object.entries(dictionary)) {
if (key === "version") {
continue;
}
if (value === null || value === undefined) {
continue;
}
if (typeof value === "object") {
const nestedKeys = Object.entries(value)
.filter(([, nestedValue]) => {
if (nestedValue === null || nestedValue === undefined) {
return false;
}
if (Array.isArray(nestedValue)) {
return nestedValue.length > 0;
}
return typeof nestedValue === "boolean" || Boolean(String(nestedValue).trim());
})
.map(([nestedKey]) => `${key}.${nestedKey}`);
entries.push(...nestedKeys);
continue;
}
entries.push(key);
}
return entries;
}
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 dañado.",
code: error.code,
};
}
if (error instanceof OcrUnavailableError) {
return {
status: 503,
error: "No se detecto texto suficiente y OCRmyPDF no esta disponible. Revisa instalacion local en README.",
code: error.code,
};
}
if (error instanceof PdfNoTextDetectedError) {
return {
status: 422,
error: "No se detecto texto en el PDF y OCR tampoco pudo recuperarlo.",
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 extraer texto del PDF.",
code: "PDF_ANALYSIS_FAILED",
};
}
export async function POST(request: Request) {
const session = await getSessionPayload();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const formData = await request.formData();
const actaFile = formData.get("file") ?? formData.get("acta");
if (!(actaFile instanceof File)) {
return NextResponse.json({ error: "Debes adjuntar un archivo PDF de Acta constitutiva." }, { status: 400 });
}
if (actaFile.size <= 0) {
return NextResponse.json({ error: "El archivo esta vacio." }, { status: 400 });
}
if (actaFile.size > MAX_ACTA_PDF_BYTES) {
return NextResponse.json({ error: "El archivo excede el limite de 15MB." }, { status: 400 });
}
if (!isPdfFile(actaFile)) {
return NextResponse.json({ error: "Solo se permiten archivos PDF." }, { status: 400 });
}
const fileBuffer = Buffer.from(await actaFile.arrayBuffer());
if (!hasPdfSignature(fileBuffer)) {
return NextResponse.json({ error: "El archivo no parece ser un PDF valido." }, { status: 400 });
}
let fields: ActaFields;
let lookupDictionary: ActaLookupDictionary;
let rawText: string;
let methodUsed: "direct" | "ocr";
let numPages: number;
let warnings: string[];
let extractionEngine: "ai" | "regex_fallback";
let aiModel: string | null;
let aiUsage:
| {
promptTokens: number | null;
completionTokens: number | null;
totalTokens: number | null;
}
| null;
try {
const analyzed = await analyzePdf(fileBuffer);
const extracted = await extractActaDataWithAiBaseline(analyzed.text);
lookupDictionary = extracted.lookupDictionary;
fields = extracted.fields;
rawText = analyzed.text;
methodUsed = analyzed.methodUsed;
numPages = analyzed.numPages;
warnings = [...analyzed.warnings, ...extracted.warnings];
extractionEngine = extracted.engine;
aiModel = extracted.model;
aiUsage = extracted.usage;
} catch (error) {
const mapped = mapAnalysisError(error);
const errorName = error instanceof Error ? error.name : "UnknownError";
const errorMessage = error instanceof Error ? error.message : String(error);
const errorCause = error instanceof Error && "cause" in error ? (error.cause as Error | undefined) : undefined;
const ocrStderr = error instanceof OcrFailedError ? error.stderr : undefined;
console.error("Acta analysis failed", {
mappedCode: mapped.code,
mappedStatus: mapped.status,
errorName,
errorMessage,
causeName: errorCause?.name,
causeMessage: errorCause?.message,
ocrStderr: ocrStderr ? ocrStderr.slice(0, 1200) : undefined,
});
return NextResponse.json(
{ error: mapped.error, code: mapped.code },
{
status: mapped.status,
headers: {
"x-acta-error-code": mapped.code,
},
},
);
}
const extractedFields = getDetectedFields(fields);
const detectedLookupFields = getDetectedLookupDictionaryFields(lookupDictionary);
const confidence = getExtractionConfidence(fields);
const extractionPayload = {
...fields,
industry: lookupDictionary.industry,
country: lookupDictionary.countryOfOperation,
lookupDictionary,
extractedFields,
detectedLookupFields,
confidence,
methodUsed,
numPages,
warnings,
extractionEngine,
aiModel,
aiUsage,
};
const storedFile = await storeActaPdf(session.userId, actaFile.name, fileBuffer);
try {
const existingOrg = await prisma.organization.findUnique({
where: { userId: session.userId },
select: {
id: true,
name: true,
},
});
const fallbackName = session.email.split("@")[0]?.trim() || "empresa";
const organizationName = toOptionalString(fields.name) ?? existingOrg?.name ?? fallbackName;
let organization: { id: string };
try {
organization = await prisma.organization.upsert({
where: { userId: session.userId },
update: {
name: organizationName,
tradeName: toNullableString(fields.name),
legalRepresentative: toNullableString(fields.legalRepresentative),
incorporationDate: toNullableString(fields.incorporationDate),
deedNumber: toNullableString(fields.deedNumber),
notaryName: toNullableString(fields.notaryName),
stateOfIncorporation: toNullableString(fields.stateOfIncorporation),
companyType: toNullableString(lookupDictionary.companyType),
fiscalAddress: toNullableString(fields.fiscalAddress),
businessPurpose: toNullableString(fields.businessPurpose),
industry: toNullableString(lookupDictionary.industry),
country: toNullableString(lookupDictionary.countryOfOperation),
actaExtractedData: extractionPayload as Prisma.InputJsonValue,
actaLookupDictionary: lookupDictionary as Prisma.InputJsonValue,
actaUploadedAt: new Date(),
},
create: {
userId: session.userId,
name: organizationName,
tradeName: toNullableString(fields.name),
legalRepresentative: toNullableString(fields.legalRepresentative),
incorporationDate: toNullableString(fields.incorporationDate),
deedNumber: toNullableString(fields.deedNumber),
notaryName: toNullableString(fields.notaryName),
stateOfIncorporation: toNullableString(fields.stateOfIncorporation),
companyType: toNullableString(lookupDictionary.companyType),
fiscalAddress: toNullableString(fields.fiscalAddress),
businessPurpose: toNullableString(fields.businessPurpose),
industry: toNullableString(lookupDictionary.industry),
country: toNullableString(lookupDictionary.countryOfOperation),
actaExtractedData: extractionPayload as Prisma.InputJsonValue,
actaLookupDictionary: lookupDictionary as Prisma.InputJsonValue,
actaUploadedAt: new Date(),
},
select: { id: true },
});
} catch (error) {
if (!isLegacySchemaError(error)) {
throw error;
}
organization = await prisma.organization.upsert({
where: { userId: session.userId },
update: {
name: organizationName,
},
create: {
userId: session.userId,
name: organizationName,
},
select: { id: true },
});
}
let existingDocument: { filePath: string } | null = null;
try {
existingDocument = await prisma.organizationDocument.findUnique({
where: {
userId_type: {
userId: session.userId,
type: OrganizationDocumentType.ACTA_CONSTITUTIVA,
},
},
select: {
filePath: true,
},
});
await prisma.organizationDocument.upsert({
where: {
userId_type: {
userId: session.userId,
type: OrganizationDocumentType.ACTA_CONSTITUTIVA,
},
},
update: {
organizationId: organization.id,
fileName: storedFile.fileName,
storedFileName: storedFile.storedFileName,
filePath: storedFile.filePath,
mimeType: storedFile.mimeType,
sizeBytes: storedFile.sizeBytes,
checksumSha256: storedFile.checksumSha256,
extractedData: extractionPayload as Prisma.InputJsonValue,
extractedTextSnippet: rawText.slice(0, 1600),
},
create: {
organizationId: organization.id,
userId: session.userId,
type: OrganizationDocumentType.ACTA_CONSTITUTIVA,
fileName: storedFile.fileName,
storedFileName: storedFile.storedFileName,
filePath: storedFile.filePath,
mimeType: storedFile.mimeType,
sizeBytes: storedFile.sizeBytes,
checksumSha256: storedFile.checksumSha256,
extractedData: extractionPayload as Prisma.InputJsonValue,
extractedTextSnippet: rawText.slice(0, 1600),
},
});
} catch (error) {
if (!isLegacySchemaError(error)) {
throw error;
}
}
if (existingDocument?.filePath && existingDocument.filePath !== storedFile.filePath) {
await removeStoredActaPdf(existingDocument.filePath);
}
return NextResponse.json({
ok: true,
fields,
lookupDictionary,
rawText,
methodUsed,
numPages,
warnings,
extractionEngine,
aiModel,
aiUsage,
extractedData: {
...fields,
industry: lookupDictionary.industry,
country: lookupDictionary.countryOfOperation,
lookupDictionary,
extractedFields,
detectedLookupFields,
confidence,
extractionEngine,
},
actaUploadedAt: new Date().toISOString(),
});
} catch (error) {
await removeStoredActaPdf(storedFile.filePath);
console.error("Failed to process acta upload", error);
return NextResponse.json({ error: "No fue posible procesar el acta constitutiva." }, { status: 500 });
}
}

View File

@@ -0,0 +1,157 @@
import { NextResponse } from "next/server";
import { OrganizationDocumentType } from "@prisma/client";
import { getSessionPayload } from "@/lib/auth/session";
import { prisma } from "@/lib/prisma";
function normalizeString(value: unknown) {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function normalizeOptionalBoolean(value: unknown) {
if (typeof value === "boolean") {
return value;
}
if (typeof value !== "string") {
return null;
}
const normalized = value.trim().toLowerCase();
if (normalized === "yes" || normalized === "si" || normalized === "true") {
return true;
}
if (normalized === "no" || normalized === "false") {
return false;
}
return null;
}
export async function POST(request: Request) {
const session = await getSessionPayload();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = (await request.json()) as Record<string, unknown>;
const name = normalizeString(body.name);
const tradeName = normalizeString(body.tradeName);
const rfc = normalizeString(body.rfc);
const legalRepresentative = normalizeString(body.legalRepresentative);
const incorporationDate = normalizeString(body.incorporationDate);
const deedNumber = normalizeString(body.deedNumber);
const notaryName = normalizeString(body.notaryName);
const fiscalAddress = normalizeString(body.fiscalAddress);
const businessPurpose = normalizeString(body.businessPurpose);
const industry = normalizeString(body.industry);
const operatingState = normalizeString(body.operatingState);
const municipality = normalizeString(body.municipality);
const companySize = normalizeString(body.companySize);
const yearsOfOperation = normalizeString(body.yearsOfOperation);
const annualRevenueRange = normalizeString(body.annualRevenueRange);
const hasGovernmentContracts = normalizeOptionalBoolean(body.hasGovernmentContracts);
const country = normalizeString(body.country);
const primaryObjective = normalizeString(body.primaryObjective);
if (!name) {
return NextResponse.json({ error: "El nombre legal de la empresa es obligatorio." }, { status: 400 });
}
try {
const actaDocument = await prisma.organizationDocument.findUnique({
where: {
userId_type: {
userId: session.userId,
type: OrganizationDocumentType.ACTA_CONSTITUTIVA,
},
},
select: { id: true },
});
if (!actaDocument) {
return NextResponse.json({ error: "Debes cargar el Acta constitutiva antes de finalizar onboarding." }, { status: 400 });
}
await prisma.organization.upsert({
where: { userId: session.userId },
update: {
name,
tradeName,
rfc,
legalRepresentative,
incorporationDate,
deedNumber,
notaryName,
fiscalAddress,
businessPurpose,
industry,
operatingState,
municipality,
companySize,
yearsOfOperation,
annualRevenueRange,
hasGovernmentContracts,
country,
primaryObjective,
onboardingCompletedAt: new Date(),
},
create: {
userId: session.userId,
name,
tradeName,
rfc,
legalRepresentative,
incorporationDate,
deedNumber,
notaryName,
fiscalAddress,
businessPurpose,
industry,
operatingState,
municipality,
companySize,
yearsOfOperation,
annualRevenueRange,
hasGovernmentContracts,
country,
primaryObjective,
onboardingCompletedAt: new Date(),
},
});
} catch {
// Backward compatibility for databases without onboarding v2 migration.
await prisma.organization.upsert({
where: { userId: session.userId },
update: {
name,
industry,
companySize,
country,
primaryObjective,
},
create: {
userId: session.userId,
name,
industry,
companySize,
country,
primaryObjective,
},
});
}
return NextResponse.json({ ok: true, redirectTo: "/diagnostic" });
} catch {
return NextResponse.json({ error: "Invalid onboarding payload." }, { status: 400 });
}
}

View File

@@ -0,0 +1,134 @@
import { Prisma } from "@prisma/client";
import { NextResponse } from "next/server";
import { requireAdminApiUser } from "@/lib/auth/admin";
import { prisma } from "@/lib/prisma";
import {
MAX_STRATEGIC_EVIDENCE_BYTES,
isAllowedEvidenceMimeType,
storeStrategicEvidenceFile,
} from "@/lib/strategic-diagnostic/evidence-storage";
import { mapSectionKeyToEnum, recomputeStrategicDiagnosticFromStoredData } from "@/lib/strategic-diagnostic/server";
import { STRATEGIC_SECTION_KEYS, type StrategicSectionKey } from "@/lib/strategic-diagnostic/types";
function parseSection(value: unknown): StrategicSectionKey | null {
if (typeof value !== "string") {
return null;
}
const section = value.trim() as StrategicSectionKey;
return STRATEGIC_SECTION_KEYS.includes(section) ? section : null;
}
function parseCategory(value: unknown) {
if (typeof value !== "string") {
return "";
}
return value.trim();
}
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 });
}
try {
const formData = await request.formData();
const section = parseSection(formData.get("section"));
const category = parseCategory(formData.get("category"));
const file = formData.get("file");
if (!section) {
return NextResponse.json({ error: "Seccion de evidencia invalida." }, { status: 400 });
}
if (!category) {
return NextResponse.json({ error: "Categoria de evidencia requerida." }, { status: 400 });
}
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_STRATEGIC_EVIDENCE_BYTES) {
return NextResponse.json({ error: "El archivo excede el limite de 10MB." }, { status: 400 });
}
if (!isAllowedEvidenceMimeType(file.type)) {
return NextResponse.json({ error: "Tipo de archivo no permitido (usa PDF, DOC, DOCX, JPG o PNG)." }, { status: 400 });
}
const organization = await prisma.organization.findUnique({
where: { userId: user.id },
select: {
id: true,
},
});
if (!organization) {
return NextResponse.json({ error: "No existe un perfil organizacional para este usuario." }, { status: 400 });
}
const fileBuffer = Buffer.from(await file.arrayBuffer());
const stored = await storeStrategicEvidenceFile(user.id, file.name, file.type, fileBuffer);
const row = await prisma.strategicDiagnosticEvidenceDocument.create({
data: {
organizationId: organization.id,
userId: user.id,
section: mapSectionKeyToEnum(section),
category,
fileName: stored.fileName,
storedFileName: stored.storedFileName,
filePath: stored.filePath,
mimeType: stored.mimeType,
sizeBytes: stored.sizeBytes,
checksumSha256: stored.checksumSha256,
},
select: {
id: true,
category: true,
fileName: true,
filePath: true,
mimeType: true,
sizeBytes: true,
createdAt: true,
},
});
const snapshot = await recomputeStrategicDiagnosticFromStoredData(user.id);
return NextResponse.json({
ok: true,
document: {
id: row.id,
section,
category: row.category,
fileName: row.fileName,
filePath: row.filePath,
mimeType: row.mimeType,
sizeBytes: row.sizeBytes,
createdAt: row.createdAt.toISOString(),
},
payload: snapshot,
});
} catch (error) {
if (isSchemaNotReadyError(error)) {
return NextResponse.json(
{ error: "La base de datos aun no tiene las tablas de evidencias de Modulo 2. Ejecuta prisma migrate para continuar." },
{ status: 503 },
);
}
return NextResponse.json({ error: "No fue posible subir la evidencia." }, { status: 400 });
}
}

View File

@@ -0,0 +1,38 @@
import { Prisma } from "@prisma/client";
import { NextResponse } from "next/server";
import { requireAdminApiUser } from "@/lib/auth/admin";
import { saveStrategicDiagnosticData } from "@/lib/strategic-diagnostic/server";
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 });
}
try {
const body = (await request.json()) as Record<string, unknown>;
const payload = await saveStrategicDiagnosticData(user.id, body.data, {
forceCompleted: body.forceCompleted === true,
});
if (!payload) {
return NextResponse.json({ error: "No existe un perfil organizacional para este usuario." }, { status: 400 });
}
return NextResponse.json({ ok: true, payload });
} catch (error) {
if (isSchemaNotReadyError(error)) {
return NextResponse.json(
{ error: "La base de datos aun no tiene las tablas de Modulo 2. Ejecuta prisma migrate para continuar." },
{ status: 503 },
);
}
return NextResponse.json({ error: "No fue posible guardar el Modulo 2." }, { status: 400 });
}
}

View File

@@ -0,0 +1,191 @@
import { Prisma, WorkshopEvidenceValidationStatus, WorkshopProgressStatus } from "@prisma/client";
import { NextResponse } from "next/server";
import { requireAdminApiUser } from "@/lib/auth/admin";
import { prisma } from "@/lib/prisma";
import {
MAX_STRATEGIC_EVIDENCE_BYTES,
isAllowedEvidenceMimeType,
storeStrategicEvidenceFile,
} from "@/lib/strategic-diagnostic/evidence-storage";
import { getTalleresSnapshot } from "@/lib/talleres/server";
import { validateWorkshopEvidenceSync } from "@/lib/talleres/validation";
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() : "";
}
export async function POST(request: Request) {
const user = await requireAdminApiUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const formData = await request.formData();
const workshopId = parseString(formData.get("workshopId"));
const file = formData.get("file");
if (!workshopId) {
return NextResponse.json({ error: "workshopId es requerido." }, { status: 400 });
}
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_STRATEGIC_EVIDENCE_BYTES) {
return NextResponse.json({ error: "El archivo excede el limite de 10MB." }, { status: 400 });
}
if (!isAllowedEvidenceMimeType(file.type)) {
return NextResponse.json({ error: "Tipo de archivo no permitido (usa PDF, DOC, DOCX, JPG o PNG)." }, { status: 400 });
}
const workshop = await prisma.developmentWorkshop.findUnique({
where: { id: workshopId },
select: { id: true },
});
if (!workshop) {
return NextResponse.json({ error: "Taller no encontrado." }, { status: 404 });
}
const fileBuffer = Buffer.from(await file.arrayBuffer());
const stored = await storeStrategicEvidenceFile(user.id, file.name, file.type, fileBuffer);
const now = new Date();
try {
const validation = validateWorkshopEvidenceSync({
fileName: stored.fileName,
mimeType: stored.mimeType,
sizeBytes: stored.sizeBytes,
});
const validationStatus =
validation.status === "APPROVED" ? WorkshopEvidenceValidationStatus.APPROVED : WorkshopEvidenceValidationStatus.REJECTED;
const evidence = await prisma.developmentWorkshopEvidence.create({
data: {
workshopId,
userId: user.id,
validationStatus,
validationReason: validation.reason,
validationConfidence: validation.confidence,
validatedAt: now,
fileName: stored.fileName,
storedFileName: stored.storedFileName,
filePath: stored.filePath,
mimeType: stored.mimeType,
sizeBytes: stored.sizeBytes,
checksumSha256: stored.checksumSha256,
},
});
await prisma.developmentWorkshopProgress.upsert({
where: {
workshopId_userId: {
workshopId,
userId: user.id,
},
},
update: {
status:
validationStatus === WorkshopEvidenceValidationStatus.APPROVED
? WorkshopProgressStatus.APPROVED
: WorkshopProgressStatus.REJECTED,
watchedAt: now,
completedAt: validationStatus === WorkshopEvidenceValidationStatus.APPROVED ? now : null,
},
create: {
workshopId,
userId: user.id,
status:
validationStatus === WorkshopEvidenceValidationStatus.APPROVED
? WorkshopProgressStatus.APPROVED
: WorkshopProgressStatus.REJECTED,
watchedAt: now,
completedAt: validationStatus === WorkshopEvidenceValidationStatus.APPROVED ? now : null,
},
});
const payload = await getTalleresSnapshot(user.id);
return NextResponse.json({
ok: true,
evidence: {
id: evidence.id,
validationStatus: evidence.validationStatus,
validationReason: evidence.validationReason,
validationConfidence: evidence.validationConfidence,
},
payload,
});
} catch {
const evidence = await prisma.developmentWorkshopEvidence.create({
data: {
workshopId,
userId: user.id,
validationStatus: WorkshopEvidenceValidationStatus.ERROR,
validationReason: "La validacion automatica no estuvo disponible. Tu evidencia fue guardada para revision.",
validationConfidence: null,
validatedAt: now,
fileName: stored.fileName,
storedFileName: stored.storedFileName,
filePath: stored.filePath,
mimeType: stored.mimeType,
sizeBytes: stored.sizeBytes,
checksumSha256: stored.checksumSha256,
},
});
await prisma.developmentWorkshopProgress.upsert({
where: {
workshopId_userId: {
workshopId,
userId: user.id,
},
},
update: {
status: WorkshopProgressStatus.EVIDENCE_SUBMITTED,
watchedAt: now,
},
create: {
workshopId,
userId: user.id,
status: WorkshopProgressStatus.EVIDENCE_SUBMITTED,
watchedAt: now,
},
});
const payload = await getTalleresSnapshot(user.id);
return NextResponse.json({
ok: true,
warning: "Tu evidencia se guardo, pero la validacion automatica quedo pendiente.",
evidence: {
id: evidence.id,
validationStatus: evidence.validationStatus,
validationReason: evidence.validationReason,
validationConfidence: evidence.validationConfidence,
},
payload,
});
}
} catch (error) {
if (isSchemaNotReadyError(error)) {
return NextResponse.json(
{ error: "La base de datos aun no tiene las tablas de Talleres. Ejecuta prisma migrate para continuar." },
{ status: 503 },
);
}
return NextResponse.json({ error: "No fue posible subir la evidencia del taller." }, { status: 400 });
}
}

View File

@@ -0,0 +1,120 @@
import { Prisma, WorkshopProgressStatus } from "@prisma/client";
import { NextResponse } from "next/server";
import { requireAdminApiUser } from "@/lib/auth/admin";
import { prisma } from "@/lib/prisma";
import { getTalleresSnapshot } from "@/lib/talleres/server";
type ProgressAction = "WATCHED" | "SKIPPED";
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 parseAction(value: unknown): ProgressAction | null {
if (value === "WATCHED" || value === "SKIPPED") {
return value;
}
return null;
}
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 workshopId = parseString(body.workshopId);
const action = parseAction(body.action);
if (!workshopId || !action) {
return NextResponse.json({ error: "workshopId y action son requeridos." }, { status: 400 });
}
const workshop = await prisma.developmentWorkshop.findUnique({
where: { id: workshopId },
select: { id: true },
});
if (!workshop) {
return NextResponse.json({ error: "Taller no encontrado." }, { status: 404 });
}
const existing = await prisma.developmentWorkshopProgress.findUnique({
where: {
workshopId_userId: {
workshopId,
userId: user.id,
},
},
select: { status: true, watchedAt: true },
});
const now = new Date();
if (action === "WATCHED") {
const nextStatus = existing?.status === WorkshopProgressStatus.APPROVED ? WorkshopProgressStatus.APPROVED : WorkshopProgressStatus.WATCHED;
await prisma.developmentWorkshopProgress.upsert({
where: {
workshopId_userId: {
workshopId,
userId: user.id,
},
},
update: {
status: nextStatus,
watchedAt: existing?.watchedAt ?? now,
},
create: {
workshopId,
userId: user.id,
status: WorkshopProgressStatus.WATCHED,
watchedAt: now,
},
});
}
if (action === "SKIPPED") {
const nextStatus = existing?.status === WorkshopProgressStatus.APPROVED ? WorkshopProgressStatus.APPROVED : WorkshopProgressStatus.SKIPPED;
await prisma.developmentWorkshopProgress.upsert({
where: {
workshopId_userId: {
workshopId,
userId: user.id,
},
},
update: {
status: nextStatus,
skippedAt: now,
},
create: {
workshopId,
userId: user.id,
status: WorkshopProgressStatus.SKIPPED,
skippedAt: now,
},
});
}
const payload = await getTalleresSnapshot(user.id);
return NextResponse.json({ ok: true, payload });
} catch (error) {
if (isSchemaNotReadyError(error)) {
return NextResponse.json(
{ error: "La base de datos aun no tiene las tablas de Talleres. Ejecuta prisma migrate para continuar." },
{ status: 503 },
);
}
return NextResponse.json({ error: "No fue posible actualizar el progreso del taller." }, { status: 400 });
}
}

View File

@@ -0,0 +1,33 @@
import { Prisma } from "@prisma/client";
import { NextResponse } from "next/server";
import { requireAdminApiUser } from "@/lib/auth/admin";
import { getTalleresSnapshot } from "@/lib/talleres/server";
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 dimension = url.searchParams.get("dimension");
const payload = await getTalleresSnapshot(user.id, { dimension });
return NextResponse.json({ ok: true, payload });
} catch (error) {
if (isSchemaNotReadyError(error)) {
return NextResponse.json(
{ error: "La base de datos aun no tiene las tablas de Talleres. Ejecuta prisma migrate para continuar." },
{ status: 503 },
);
}
return NextResponse.json({ error: "No fue posible obtener el snapshot de talleres." }, { status: 400 });
}
}

355
src/app/dashboard/page.tsx Normal file
View File

@@ -0,0 +1,355 @@
import Link from "next/link";
import { isAdminIdentity } from "@/lib/auth/admin";
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 { cn } from "@/lib/utils";
import { recomputeAssessmentResults } from "@/lib/scoring";
import { getTalleresSnapshot } from "@/lib/talleres/server";
type PlanModule = {
id: string;
title: string;
description: string;
href?: string;
};
type PlanGroup = {
key: string;
name: string;
price: string;
benefits: string;
dotClassName: string;
frameClassName: string;
badgeClassName: string;
moduleToneClassName: string;
iconToneClassName: string;
modules: PlanModule[];
};
const planGroups: PlanGroup[] = [
{
key: "plan-1",
name: "Plan 1",
price: "$499 MXN/mes",
benefits: "2 usuarios - 50 creditos IA",
dotClassName: "bg-[#3e7fe6]",
frameClassName: "border-[#82b0ef] bg-[#edf4ff]",
badgeClassName: "border-[#bdd2f3] bg-[#e8f1ff] text-[#4d6993]",
moduleToneClassName: "border-[#c8d7ee]",
iconToneClassName: "bg-[#d8e7ff] text-[#3b74d2]",
modules: [
{
id: "M02",
title: "Perfil Competitivo",
description: "Construye tu perfil estrategico detallado para aumentar tu compatibilidad.",
href: "/strategic-diagnostic",
},
{
id: "M03",
title: "Deteccion de Oportunidades",
description: "Encuentra licitaciones compatibles con tu perfil de forma automatica.",
href: "/licitations",
},
{
id: "M04",
title: "Analisis Normativo",
description: "Analiza las bases de licitacion que te interesen con IA.",
},
],
},
{
key: "plan-2",
name: "Plan 2",
price: "$999 MXN/mes",
benefits: "5 usuarios - 150 creditos IA",
dotClassName: "bg-[#9f63ea]",
frameClassName: "border-[#c8a8f1] bg-[#f6f0ff]",
badgeClassName: "border-[#dbcaf2] bg-[#f1e9ff] text-[#6a5492]",
moduleToneClassName: "border-[#d7c8ec]",
iconToneClassName: "bg-[#ecdfff] text-[#8f52d8]",
modules: [
{
id: "M05",
title: "Gestion Integral de Licitaciones",
description: "Herramienta completa para preparar, revisar y enviar propuestas con checklist.",
},
{
id: "M06",
title: "Detector de Candados y Riesgos",
description: "Analisis automatico de bases para identificar requisitos excluyentes o riesgos.",
},
{
id: "M07",
title: "Alertas de Cumplimiento",
description: "Notificaciones proactivas sobre plazos, documentos vencidos y cambios normativos.",
},
],
},
{
key: "plan-3",
name: "Plan 3",
price: "$1,999 MXN/mes",
benefits: "Usuarios ilimitados - 500 creditos IA",
dotClassName: "bg-[#e6a61d]",
frameClassName: "border-[#e4c06f] bg-[#fff7e8]",
badgeClassName: "border-[#edd7a7] bg-[#fff4dc] text-[#8a6b28]",
moduleToneClassName: "border-[#ecdab0]",
iconToneClassName: "bg-[#ffefcd] text-[#cf9420]",
modules: [
{
id: "M08",
title: "Gestion Estrategica de Contratos",
description: "Dashboard para administrar contratos activos, entregables y pagos.",
},
{
id: "M09",
title: "Proteccion Legal",
description: "Guia legal y plantillas ante incumplimientos, retenciones o sanciones.",
},
{
id: "M10",
title: "Simulador de Auditorias",
description: "Autoevaluacion que prepara a tu empresa para auditorias gubernamentales.",
},
],
},
];
function getReadinessLabel(score: number) {
if (score >= 80) {
return "Lider";
}
if (score >= 60) {
return "Preparado";
}
if (score >= 40) {
return "En Desarrollo";
}
return "Inicial";
}
function getReadinessDescription(score: number) {
if (score >= 80) {
return "Tu empresa muestra capacidades avanzadas para competir en contratacion publica.";
}
if (score >= 60) {
return "Tu empresa tiene bases solidas para participar en contratacion publica con exito.";
}
if (score >= 40) {
return "Tu empresa avanza bien, pero requiere reforzar capacidades clave para escalar resultados.";
}
return "Tu empresa requiere fortalecer fundamentos antes de competir en licitaciones complejas.";
}
function LockIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden>
<path
d="M7 10V8a5 5 0 0 1 10 0v2m-8 0h6m-9 0h12a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1v-8a1 1 0 0 1 1-1Z"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export default async function DashboardPage() {
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 roundedOverallScore = Math.round(snapshot.overallScore);
const readinessLabel = getReadinessLabel(roundedOverallScore);
const strongest = snapshot.strongestModule;
const weakest = snapshot.weakestModule;
return (
<PageShell
title="Dashboard"
description="Resumen ejecutivo y acceso a la ruta de modulos."
className="space-y-6"
action={
<Link href="/diagnostic">
<Button size="sm">Ir al diagnostico</Button>
</Link>
}
>
<section className="rounded-2xl border border-[#dce3ef] bg-[#f2f6fc] px-4 py-4">
<div className="flex flex-wrap items-center justify-center gap-x-8 gap-y-3 text-base font-medium text-[#4a5f84]">
<span className="inline-flex items-center gap-2">
<span className="h-3.5 w-3.5 rounded-full bg-[#23498f]" />
RADAR - Diagnostico
</span>
<span className="inline-flex items-center gap-2">
<span className="h-3.5 w-3.5 rounded-full bg-[#29a67a]" />
CRECE - Acompanamiento
</span>
<span className="inline-flex items-center gap-2">
<span className="h-3.5 w-3.5 rounded-full bg-[#dfb11e]" />
INDICE - Medicion
</span>
</div>
<div className="mt-4 flex flex-wrap items-center justify-center gap-3 text-sm">
<span className="rounded-full border border-[#abdbc5] bg-[#dff4e9] px-4 py-1.5 font-semibold text-[#269f75]">GRATIS Modulo 1</span>
<span className="rounded-full border border-[#b8d0ef] bg-[#dce6f8] px-4 py-1.5 font-semibold text-[#2f5eda]">Plan 1 Modulos 2-4</span>
<span className="rounded-full border border-[#d2beee] bg-[#e8ddf6] px-4 py-1.5 font-semibold text-[#8444d0]">Plan 2 Modulos 5-7</span>
<span className="rounded-full border border-[#e0cc9e] bg-[#f4ead4] px-4 py-1.5 font-semibold text-[#d2840d]">Plan 3 Modulos 8-10</span>
</div>
</section>
<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">
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">
{getReadinessDescription(roundedOverallScore)}
</p>
<div className="mt-8 grid gap-4 md:grid-cols-2">
<article className="rounded-2xl border border-[#abd9c8] bg-[#e9f5ef] px-5 py-5 text-left">
<p className="inline-flex items-center gap-2 text-sm font-semibold uppercase tracking-[0.08em] text-[#1e8f67]">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-[#54b88f] text-xs">v</span>
Principal Fortaleza
</p>
<h3 className="mt-2 text-2xl font-semibold text-[#139768]">
{strongest ? strongest.moduleName : "Aun sin datos suficientes"}
</h3>
<p className="mt-1 text-base text-[#4e6b65]">{strongest ? `${Math.round(strongest.score)}% de madurez` : "Responde el diagnostico para calcular."}</p>
</article>
<article className="rounded-2xl border border-[#efbdc3] bg-[#fff0f2] px-5 py-5 text-left">
<p className="inline-flex items-center gap-2 text-sm font-semibold uppercase tracking-[0.08em] text-[#d14c59]">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-[#ef7b86] text-xs">!</span>
Area Critica de Mejora
</p>
<h3 className="mt-2 text-2xl font-semibold text-[#e14658]">
{weakest ? weakest.moduleName : "Aun sin datos suficientes"}
</h3>
<p className="mt-1 text-base text-[#7a5b63]">{weakest ? `${Math.round(weakest.score)}% de madurez` : "Responde el diagnostico para calcular."}</p>
</article>
</div>
</div>
</CardContent>
</Card>
<section id="modulos" className="space-y-4">
<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>
</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>
<Card className="border-[#a9d9c7] bg-[#f3fbf7]">
<CardContent className="flex flex-wrap items-center justify-between gap-3 py-4">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.08em] text-[#249f74]">Modulo 1 gratuito</p>
<h3 className="mt-1 text-xl font-semibold text-[#17315a]">Diagnostico RADAR Inicial</h3>
<p className="mt-1 text-sm text-[#5d6f8e]">Este modulo siempre esta disponible para todos los usuarios.</p>
</div>
<Link href="/diagnostic">
<Button size="sm" className="rounded-xl bg-[#24a977] hover:bg-[#1d9368]">
Ir a Diagnostico
</Button>
</Link>
</CardContent>
</Card>
{planGroups.map((group) => (
<article key={group.key} className={cn("rounded-2xl border p-3 md:p-4", group.frameClassName)}>
<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)} />
<div>
<p className="text-2xl font-semibold text-[#102041] [font-family:var(--font-display)]">
{group.name} - {group.price}
</p>
<p className="text-sm text-[#576d8f]">{group.benefits}</p>
</div>
</div>
<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"}
</span>
</header>
<div className="mt-3 grid gap-3 md:grid-cols-3">
{group.modules.map((moduleItem) => {
const isLocked = !hasPaidModulesAccess || !moduleItem.href;
return (
<article
key={moduleItem.id}
className={cn(
"rounded-2xl border bg-white p-4 shadow-[0_1px_2px_rgba(16,24,40,0.04)] transition",
group.moduleToneClassName,
isLocked ? "opacity-55 grayscale-[20%]" : "hover:-translate-y-0.5",
)}
>
<div className="flex items-start justify-between gap-2">
<span className={cn("inline-flex h-10 w-10 items-center justify-center rounded-xl text-xs font-bold", group.iconToneClassName)}>
{moduleItem.id}
</span>
<span className="rounded-full border border-[#e5eaf4] bg-[#f7f9fc] px-3 py-0.5 text-xs font-semibold text-[#6d7f9c]">
{moduleItem.id}
</span>
</div>
<h4 className="mt-4 text-lg font-semibold text-[#20304d]">{moduleItem.title}</h4>
<p className="mt-2 text-sm leading-relaxed text-[#6b7891]">{moduleItem.description}</p>
<div className="mt-4">
{isLocked ? (
<span className="inline-flex items-center gap-2 rounded-full border border-[#d8dfea] bg-[#eef2f8] px-3 py-1 text-sm font-semibold text-[#64738c]">
<LockIcon className="h-4 w-4" />
Bloqueado
</span>
) : (
<Link href={moduleItem.href as string}>
<Button size="sm" className="rounded-xl">
Ir al modulo
</Button>
</Link>
)}
</div>
</article>
);
})}
</div>
</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>
<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
</Button>
</Link>
</div>
<DashboardMaturitySection snapshot={talleresSnapshot} />
</section>
</PageShell>
);
}

64
src/app/dev/db/page.tsx Normal file
View File

@@ -0,0 +1,64 @@
import { notFound } from "next/navigation";
import { PageShell } from "@/components/app/page-shell";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { requireOnboardedUser } from "@/lib/auth/user";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function DevDbPage() {
if (process.env.NODE_ENV !== "development") {
notFound();
}
await requireOnboardedUser();
let counts: { label: string; value: number }[] = [];
let dbError: string | null = null;
try {
const [modules, questions, options, recommendations, pages] = await Promise.all([
prisma.diagnosticModule.count(),
prisma.question.count(),
prisma.answerOption.count(),
prisma.recommendation.count(),
prisma.contentPage.count(),
]);
counts = [
{ label: "Diagnostic Modules", value: modules },
{ label: "Questions", value: questions },
{ label: "Answer Options", value: options },
{ label: "Recommendations", value: recommendations },
{ label: "Content Pages", value: pages },
];
} catch (error) {
dbError = error instanceof Error ? error.message : "Unknown database connection error.";
}
return (
<PageShell title="Dev DB Viewer" description="Development-only route for quick seeded data checks.">
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-[#1f2a40]">Database Snapshot</h2>
</CardHeader>
<CardContent>
{dbError ? (
<p className="rounded-lg border border-[#f6d0d0] bg-[#fff3f3] p-3 text-sm text-[#9d3030]">
DB connection unavailable: {dbError}
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{counts.map((item) => (
<div key={item.label} className="rounded-xl border border-[#dde5f2] bg-[#f8faff] p-4">
<p className="text-xs font-semibold uppercase text-[#5f6d86]">{item.label}</p>
<p className="mt-1 text-2xl font-bold text-[#1e2b45]">{item.value}</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
</PageShell>
);
}

View File

@@ -0,0 +1,62 @@
import { notFound } from "next/navigation";
import { ModuleQuestionnaire } from "@/components/app/module-questionnaire";
import { PageShell } from "@/components/app/page-shell";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { requireOnboardedUser } from "@/lib/auth/user";
import { getDiagnosticModuleQuestions, getDiagnosticOverview } from "@/lib/diagnostic";
type ModuleQuestionPageProps = {
params: Promise<{ moduleId: string }>;
};
export default async function ModuleQuestionPage({ params }: ModuleQuestionPageProps) {
const user = await requireOnboardedUser();
const { moduleId } = await params;
const moduleData = await getDiagnosticModuleQuestions(user.id, moduleId);
const overview = await getDiagnosticOverview(user.id);
if (!moduleData) {
notFound();
}
const currentModuleIndex = overview.modules.findIndex((module) => module.key === moduleData.module.key);
const previousModule = currentModuleIndex > 0 ? overview.modules[currentModuleIndex - 1] : null;
const nextModule = currentModuleIndex >= 0 && currentModuleIndex < overview.modules.length - 1 ? overview.modules[currentModuleIndex + 1] : null;
return (
<PageShell
title={`Modulo: ${moduleData.module.name}`}
description={moduleData.module.description ?? "Responde cada pregunta para calcular el puntaje por modulo."}
>
{moduleData.questions.length === 0 ? (
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-[#1f2a40]">No hay preguntas configuradas</h2>
</CardHeader>
<CardContent>
<p className="text-sm text-[#64718a]">
Este modulo aun no contiene preguntas. Regresa al listado e intenta con otro modulo.
</p>
</CardContent>
</Card>
) : (
<ModuleQuestionnaire
moduleKey={moduleData.module.key}
moduleName={moduleData.module.name}
moduleDescription={moduleData.module.description}
questions={moduleData.questions}
moduleTabs={overview.modules.map((module) => ({
key: module.key,
label: module.name,
href: `/diagnostic/${module.key}`,
active: module.key === moduleData.module.key,
}))}
previousModuleHref={previousModule ? `/diagnostic/${previousModule.key}` : null}
nextModuleHref={nextModule ? `/diagnostic/${nextModule.key}` : null}
isLastModule={currentModuleIndex >= 0 && currentModuleIndex === overview.modules.length - 1}
/>
)}
</PageShell>
);
}

View File

@@ -0,0 +1,79 @@
import Link from "next/link";
import { ModuleCard } from "@/components/app/module-card";
import { PageShell } from "@/components/app/page-shell";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { requireOnboardedUser } from "@/lib/auth/user";
import { getDiagnosticOverview } from "@/lib/diagnostic";
export default async function DiagnosticPage() {
const user = await requireOnboardedUser();
const overview = await getDiagnosticOverview(user.id);
return (
<PageShell
title="Diagnostico"
description="Selecciona un modulo para iniciar o reanudar el cuestionario."
action={
overview.resumeHref ? (
<Link href={overview.resumeHref}>
<Button>Continuar ultimo modulo</Button>
</Link>
) : undefined
}
>
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-[#1f2a40]">Estado general</h2>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-4">
<div className="rounded-xl bg-[#edf2fa] p-4">
<p className="text-xs font-semibold uppercase text-[#5f6d86]">Modulos</p>
<p className="mt-1 text-2xl font-bold text-[#1e2b45]">{overview.stats.modules}</p>
</div>
<div className="rounded-xl bg-[#edf2fa] p-4">
<p className="text-xs font-semibold uppercase text-[#5f6d86]">Completados</p>
<p className="mt-1 text-2xl font-bold text-[#1e2b45]">{overview.stats.completedModules}</p>
</div>
<div className="rounded-xl bg-[#edf2fa] p-4">
<p className="text-xs font-semibold uppercase text-[#5f6d86]">Progreso total</p>
<p className="mt-1 text-2xl font-bold text-[#1e2b45]">{overview.stats.overallCompletion}%</p>
</div>
<div className="rounded-xl bg-[#edf2fa] p-4">
<p className="text-xs font-semibold uppercase text-[#5f6d86]">Respuestas</p>
<p className="mt-1 text-2xl font-bold text-[#1e2b45]">
{overview.stats.answeredQuestions}/{overview.stats.totalQuestions}
</p>
</div>
</CardContent>
</Card>
{overview.modules.length === 0 ? (
<Card>
<CardHeader>
<h3 className="text-base font-semibold text-[#22314b]">No hay modulos configurados</h3>
</CardHeader>
<CardContent>
<p className="text-sm text-[#64718a]">
Ejecuta el seed de base de datos para cargar modulos, preguntas y opciones antes de iniciar el diagnostico.
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2">
{overview.modules.map((module) => (
<ModuleCard
key={module.key}
name={module.name}
completion={module.completion}
status={module.status}
href={`/diagnostic/${module.key}?q=${module.resumeQuestionIndex}`}
answeredQuestions={module.answeredQuestions}
totalQuestions={module.totalQuestions}
/>
))}
</div>
)}
</PageShell>
);
}

33
src/app/error.tsx Normal file
View File

@@ -0,0 +1,33 @@
"use client";
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div className="min-h-screen bg-[#f4f7fb] px-4 py-6 sm:px-6 lg:px-8">
<div className="mx-auto w-full max-w-3xl">
<Card>
<CardHeader>
<h2 className="text-xl font-semibold text-[#1f2a40]">Ocurrio un error inesperado</h2>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-[#64718a]">No pudimos completar la accion solicitada. Puedes intentar nuevamente.</p>
<Button onClick={reset}>Reintentar</Button>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -1,26 +1,21 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
--background: #f4f7fb;
--foreground: #12213f;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
* {
box-sizing: border-box;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
font-family: var(--font-sans), "Segoe UI", sans-serif;
}

View File

@@ -1,20 +1,23 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Manrope, Playfair_Display } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
const sansFont = Manrope({
subsets: ["latin"],
variable: "--font-sans",
display: "swap",
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
const displayFont = Playfair_Display({
subsets: ["latin"],
variable: "--font-display",
weight: ["600", "700", "800"],
display: "swap",
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Kontia | Diagnostico y Madurez Empresarial",
description: "Plataforma para diagnosticar, acompanar y medir tu crecimiento en contratacion publica.",
};
export default function RootLayout({
@@ -23,12 +26,8 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
<html lang="es">
<body className={`${sansFont.variable} ${displayFont.variable} antialiased`}>{children}</body>
</html>
);
}

View File

@@ -0,0 +1,204 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { isAdminIdentity } from "@/lib/auth/admin";
import { requireOnboardedUser } from "@/lib/auth/user";
import { getCategoryLabel, getProcedureTypeLabel, getSourceLabel } from "@/lib/licitations/labels";
import { prisma } from "@/lib/prisma";
import { PageShell } from "@/components/app/page-shell";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
type LicitationDetailPageProps = {
params: Promise<{ id: string }>;
};
function formatDate(value: Date | null) {
if (!value) {
return "Sin fecha";
}
return value.toLocaleDateString("es-MX", {
year: "numeric",
month: "long",
day: "2-digit",
});
}
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);
}
function toEventDates(value: unknown) {
if (!value || typeof value !== "object") {
return [] as Array<{ key: string; date: Date | null }>;
}
return Object.entries(value as Record<string, unknown>).map(([key, raw]) => {
const parsed = typeof raw === "string" ? new Date(raw) : null;
return {
key,
date: parsed && !Number.isNaN(parsed.getTime()) ? parsed : null,
};
});
}
export default async function LicitationDetailPage({ params }: LicitationDetailPageProps) {
const user = await requireOnboardedUser();
const hasPaidModulesAccess = isAdminIdentity(user.email, user.role);
if (!hasPaidModulesAccess) {
return (
<PageShell title="Detalle de licitacion" description="Acceso restringido al modulo premium.">
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-[#1f2a40]">Acceso restringido</h2>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-[#64718a]">Necesitas un plan con acceso al Modulo 3 para ver los detalles de oportunidades.</p>
<Link href="/dashboard#modulos">
<Button>Ver modulos y planes</Button>
</Link>
</CardContent>
</Card>
</PageShell>
);
}
const { id } = await params;
const licitation = await prisma.licitation.findUnique({
where: { id },
include: {
municipality: {
select: {
stateName: true,
municipalityName: true,
},
},
},
});
if (!licitation) {
notFound();
}
const documents = toDocumentArray(licitation.documents);
const timeline = toEventDates(licitation.eventDates);
return (
<PageShell
title="Detalle de licitacion"
description={`${licitation.municipality.municipalityName}, ${licitation.municipality.stateName}`}
action={
<Link href="/licitations">
<Button variant="secondary">Volver a resultados</Button>
</Link>
}
>
<Card>
<CardHeader>
<h2 className="text-2xl font-semibold text-[#1f2a40]">{licitation.title}</h2>
<p className="text-sm text-[#5f6f8c]">{licitation.description ?? "Sin descripcion"}</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-wrap gap-2 text-xs text-[#5f6f8c]">
<span className={`rounded-full border px-2 py-1 ${licitation.isOpen ? "border-[#acd8c7] bg-[#e8f7ef] text-[#1c8f67]" : "border-[#d8e1ef]"}`}>
{licitation.isOpen ? "Abierta" : "Cerrada"}
</span>
<span className="rounded-full border border-[#d8e1ef] px-2 py-1">{getProcedureTypeLabel(licitation.procedureType)}</span>
<span className="rounded-full border border-[#d8e1ef] px-2 py-1">{getCategoryLabel(licitation.category)}</span>
<span className="rounded-full border border-[#d8e1ef] px-2 py-1">Fuente: {getSourceLabel(licitation.source)}</span>
<span className="rounded-full border border-[#d8e1ef] px-2 py-1">Publicado: {formatDate(licitation.publishDate)}</span>
<span className="rounded-full border border-[#d8e1ef] px-2 py-1">Apertura: {formatDate(licitation.openingDate)}</span>
<span className="rounded-full border border-[#d8e1ef] px-2 py-1">Cierre: {formatDate(licitation.closingDate)}</span>
</div>
<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>
<p className="text-sm text-[#1f2a40]">
{licitation.amount == null
? "Monto no disponible"
: `${licitation.currency ?? "MXN"} ${Number(licitation.amount).toLocaleString("es-MX", { maximumFractionDigits: 2 })}`}
</p>
</div>
<div className="rounded-xl border border-[#d8e1ef] p-3">
<p className="text-xs font-semibold uppercase text-[#60718f]">Codigo de licitacion</p>
<p className="text-sm text-[#1f2a40]">{licitation.tenderCode ?? "No disponible"}</p>
</div>
</div>
<div className="rounded-xl border border-[#d8e1ef] p-3">
<p className="text-xs font-semibold uppercase text-[#60718f]">Proveedor adjudicado</p>
<p className="text-sm text-[#1f2a40]">{licitation.supplierAwarded ?? "No disponible"}</p>
</div>
{licitation.status ? (
<div className="rounded-xl border border-[#d8e1ef] p-3">
<p className="text-xs font-semibold uppercase text-[#60718f]">Estatus</p>
<p className="text-sm text-[#1f2a40]">{licitation.status}</p>
</div>
) : null}
{licitation.rawSourceUrl ? (
<a href={licitation.rawSourceUrl} target="_blank" rel="noreferrer" className="inline-flex text-sm font-semibold text-[#1f3f84]">
Abrir fuente original
</a>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<h3 className="text-lg font-semibold text-[#1f2a40]">Documentos</h3>
</CardHeader>
<CardContent className="space-y-2">
{documents.length === 0 ? (
<p className="text-sm text-[#64718a]">No hay documentos asociados.</p>
) : (
documents.map((document, index) => (
<a key={`${document.url}-${index}`} href={document.url} target="_blank" rel="noreferrer" className="block rounded-xl border border-[#d8e1ef] p-3 text-sm text-[#1f3f84]">
{document.name} {document.type ? `(${document.type})` : ""}
</a>
))
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<h3 className="text-lg font-semibold text-[#1f2a40]">Linea de tiempo</h3>
</CardHeader>
<CardContent className="space-y-2">
{timeline.length === 0 ? (
<p className="text-sm text-[#64718a]">No hay fechas de eventos registradas.</p>
) : (
timeline.map((event) => (
<div key={event.key} className="rounded-xl border border-[#d8e1ef] p-3 text-sm text-[#1f2a40]">
<p className="font-semibold text-[#2b3d5d]">{event.key}</p>
<p>{formatDate(event.date)}</p>
</div>
))
)}
</CardContent>
</Card>
</PageShell>
);
}

View File

@@ -0,0 +1,261 @@
import Link from "next/link";
import { LicitationProcedureType } from "@prisma/client";
import { isAdminIdentity } from "@/lib/auth/admin";
import { requireOnboardedUser } from "@/lib/auth/user";
import { getCategoryLabel, getProcedureTypeLabel, getSourceLabel } from "@/lib/licitations/labels";
import { listMunicipalities, searchLicitations } from "@/lib/licitations/query";
import { getLicitationRecommendationsForUser } from "@/lib/licitations/recommendations";
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";
type LicitationsPageProps = {
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 formatDate(value: Date | null) {
if (!value) {
return "Sin fecha";
}
return value.toLocaleDateString("es-MX", {
year: "numeric",
month: "short",
day: "2-digit",
});
}
function formatAmount(amount: unknown, currency: string | null) {
if (amount == null) {
return "Monto no disponible";
}
const numeric = Number(amount);
if (!Number.isFinite(numeric)) {
return "Monto no disponible";
}
return `${currency ?? "MXN"} ${numeric.toLocaleString("es-MX", { maximumFractionDigits: 2 })}`;
}
export default async function LicitationsPage({ searchParams }: LicitationsPageProps) {
const user = await requireOnboardedUser();
const hasPaidModulesAccess = isAdminIdentity(user.email, user.role);
if (!hasPaidModulesAccess) {
return (
<PageShell
title="Modulo 3: Deteccion de Oportunidades"
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>
<CardHeader>
<h2 className="text-lg font-semibold text-[#1f2a40]">Acceso restringido</h2>
</CardHeader>
<CardContent className="space-y-3">
<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>
<Link href="/dashboard#modulos">
<Button>Ver modulos y planes</Button>
</Link>
</CardContent>
</Card>
</PageShell>
);
}
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 [municipalities, records, recommendations] = await Promise.all([
listMunicipalities(),
searchLicitations({
state,
municipality,
procedureType,
q,
minAmount,
maxAmount,
dateFrom,
dateTo,
take: 100,
}),
getLicitationRecommendationsForUser(user.id),
]);
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;
return (
<PageShell
title="Modulo 3: Deteccion de Oportunidades"
description="Encuentra licitaciones compatibles con tu perfil empresarial"
action={<LicitationsSyncButton />}
className="space-y-6"
>
<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>
<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]">
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]">
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]">
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]">
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>
<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]">
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>
<div className="md:col-span-3 flex gap-2">
<Button type="submit">Aplicar filtros</Button>
<Link href="/licitations">
<Button type="button" variant="secondary">
Limpiar
</Button>
</Link>
</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>
</div>
<span className="rounded-full bg-[#eef3ff] px-2 py-1 text-xs font-semibold text-[#1f3f84]">{getProcedureTypeLabel(record.procedureType)}</span>
</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"}
</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>
<Link href={`/licitations/${record.id}`} className="mt-3 inline-flex text-sm font-semibold text-[#1f3f84]">
Ver detalle
</Link>
</article>
))
)}
</CardContent>
</Card>
</PageShell>
);
}

18
src/app/loading.tsx Normal file
View File

@@ -0,0 +1,18 @@
import { Card, CardContent } from "@/components/ui/card";
export default function Loading() {
return (
<div className="min-h-screen bg-[#f4f7fb] px-4 py-6 sm:px-6 lg:px-8">
<div className="mx-auto w-full max-w-6xl space-y-4">
<div className="h-10 w-64 animate-pulse rounded-lg bg-[#e7edf7]" />
<Card>
<CardContent className="space-y-3 py-6">
<div className="h-4 w-1/2 animate-pulse rounded bg-[#e7edf7]" />
<div className="h-4 w-2/3 animate-pulse rounded bg-[#e7edf7]" />
<div className="h-4 w-1/3 animate-pulse rounded bg-[#e7edf7]" />
</CardContent>
</Card>
</div>
</div>
);
}

83
src/app/login/page.tsx Normal file
View File

@@ -0,0 +1,83 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { PageShell } from "@/components/app/page-shell";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { getCurrentUser } from "@/lib/auth/user";
type LoginPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
const loginErrorMap: Record<string, string> = {
auth_required: "Debes iniciar sesion para acceder a esta seccion.",
invalid_credentials: "Credenciales invalidas. Verifica correo y contrasena.",
server_error: "No fue posible iniciar sesion. Intenta nuevamente.",
};
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 LoginPage({ searchParams }: LoginPageProps) {
const currentUser = await getCurrentUser();
if (currentUser) {
redirect("/dashboard");
}
const params = await searchParams;
const errorCode = getParam(params, "error");
const errorMessage = errorCode ? loginErrorMap[errorCode] : null;
const logoutMessage = getParam(params, "logged_out") === "1" ? "Sesion cerrada correctamente." : null;
return (
<PageShell title="Iniciar Sesion" description="Accede para continuar tu diagnostico y revisar tus resultados.">
<Card className="mx-auto w-full max-w-xl">
<CardHeader>
<h2 className="text-xl font-semibold text-[#1f2a40]">Bienvenido de nuevo</h2>
<p className="text-sm text-[#67738c]">Ingresa tus credenciales para continuar.</p>
</CardHeader>
<CardContent className="space-y-4">
{logoutMessage ? (
<p className="rounded-lg border border-[#ccead8] bg-[#ebf8f0] px-3 py-2 text-sm text-[#206546]">{logoutMessage}</p>
) : null}
{errorMessage ? (
<p className="rounded-lg border border-[#f6d0d0] bg-[#fff3f3] px-3 py-2 text-sm text-[#9d3030]">{errorMessage}</p>
) : null}
<form action="/api/auth/login" method="post" className="space-y-4">
<div>
<Label htmlFor="email">Correo</Label>
<Input id="email" name="email" type="email" placeholder="ana@empresa.com" autoComplete="email" required />
</div>
<div>
<Label htmlFor="password">Contrasena</Label>
<Input
id="password"
name="password"
type="password"
placeholder="********"
autoComplete="current-password"
required
/>
</div>
<Button className="w-full" type="submit">
Ingresar
</Button>
</form>
<p className="text-center text-sm text-[#66738b]">
No tienes cuenta?{" "}
<Link href="/register" className="font-semibold text-[#0f2a5f]">
Registrate
</Link>
</p>
</CardContent>
</Card>
</PageShell>
);
}

52
src/app/manual/page.tsx Normal file
View File

@@ -0,0 +1,52 @@
import { PageShell } from "@/components/app/page-shell";
import { Accordion } from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { requireOnboardedUser } from "@/lib/auth/user";
import { getManualContentSnapshot } from "@/lib/content-pages";
export default async function ManualPage() {
await requireOnboardedUser();
const contentSnapshot = await getManualContentSnapshot();
return (
<PageShell
title="Manual y FAQ"
description="Guia operativa, preguntas frecuentes y soporte para usuarios de la plataforma."
action={<Button variant="secondary">Descargar manual (PDF)</Button>}
>
{contentSnapshot.manualItems.length > 0 ? (
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-[#1f2a40]">Manual operativo</h2>
</CardHeader>
<CardContent>
<Accordion items={contentSnapshot.manualItems} />
</CardContent>
</Card>
) : null}
{contentSnapshot.faqItems.length > 0 ? (
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-[#1f2a40]">Preguntas frecuentes</h2>
</CardHeader>
<CardContent>
<Accordion items={contentSnapshot.faqItems} />
</CardContent>
</Card>
) : null}
{contentSnapshot.manualItems.length === 0 && contentSnapshot.faqItems.length === 0 ? (
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-[#1f2a40]">Sin contenido publicado</h2>
</CardHeader>
<CardContent>
<p className="text-sm text-[#64718a]">No hay entradas de manual o FAQ en la base de datos para mostrar en esta seccion.</p>
</CardContent>
</Card>
) : null}
</PageShell>
);
}

152
src/app/onboarding/page.tsx Normal file
View File

@@ -0,0 +1,152 @@
import { redirect } from "next/navigation";
import { OrganizationDocumentType } from "@prisma/client";
import { OnboardingWizard } from "@/components/app/onboarding-wizard";
import { PageShell } from "@/components/app/page-shell";
import { requireUser } from "@/lib/auth/user";
import { prisma } from "@/lib/prisma";
export default async function OnboardingPage() {
const user = await requireUser();
let existingOrganization: {
name: string;
tradeName: string | null;
rfc: string | null;
legalRepresentative: string | null;
incorporationDate: string | null;
deedNumber: string | null;
notaryName: string | null;
fiscalAddress: string | null;
businessPurpose: string | null;
industry: string | null;
operatingState: string | null;
municipality: string | null;
companySize: string | null;
yearsOfOperation: string | null;
annualRevenueRange: string | null;
hasGovernmentContracts: boolean | null;
country: string | null;
primaryObjective: string | null;
actaUploadedAt: Date | null;
onboardingCompletedAt: Date | null;
} | null = null;
let hasActaDocument = false;
try {
existingOrganization = await prisma.organization.findUnique({
where: { userId: user.id },
select: {
name: true,
tradeName: true,
rfc: true,
legalRepresentative: true,
incorporationDate: true,
deedNumber: true,
notaryName: true,
fiscalAddress: true,
businessPurpose: true,
industry: true,
operatingState: true,
municipality: true,
companySize: true,
yearsOfOperation: true,
annualRevenueRange: true,
hasGovernmentContracts: true,
country: true,
primaryObjective: true,
actaUploadedAt: true,
onboardingCompletedAt: true,
},
});
const existingActa = await prisma.organizationDocument.findUnique({
where: {
userId_type: {
userId: user.id,
type: OrganizationDocumentType.ACTA_CONSTITUTIVA,
},
},
select: { id: true },
});
hasActaDocument = Boolean(existingActa);
} catch {
// Backward compatibility for older schema before onboarding v2 migration.
const legacyOrganization = await prisma.organization.findUnique({
where: { userId: user.id },
select: {
name: true,
industry: true,
companySize: true,
country: true,
primaryObjective: true,
},
});
existingOrganization = legacyOrganization
? {
name: legacyOrganization.name,
tradeName: null,
rfc: null,
legalRepresentative: null,
incorporationDate: null,
deedNumber: null,
notaryName: null,
fiscalAddress: null,
businessPurpose: null,
industry: legacyOrganization.industry,
operatingState: null,
municipality: null,
companySize: legacyOrganization.companySize,
yearsOfOperation: null,
annualRevenueRange: null,
hasGovernmentContracts: null,
country: legacyOrganization.country,
primaryObjective: legacyOrganization.primaryObjective,
actaUploadedAt: null,
onboardingCompletedAt: null,
}
: null;
}
if (existingOrganization?.onboardingCompletedAt) {
redirect("/diagnostic");
}
return (
<PageShell
title="Onboarding Empresarial"
description="Paso 1: carga tu Acta constitutiva en PDF para extraer datos base. Luego confirma y completa el perfil."
>
<OnboardingWizard
initialValues={{
name: existingOrganization?.name ?? "",
tradeName: existingOrganization?.tradeName ?? "",
rfc: existingOrganization?.rfc ?? "",
legalRepresentative: existingOrganization?.legalRepresentative ?? "",
incorporationDate: existingOrganization?.incorporationDate ?? "",
deedNumber: existingOrganization?.deedNumber ?? "",
notaryName: existingOrganization?.notaryName ?? "",
fiscalAddress: existingOrganization?.fiscalAddress ?? "",
businessPurpose: existingOrganization?.businessPurpose ?? "",
industry: existingOrganization?.industry ?? "",
operatingState: existingOrganization?.operatingState ?? "",
municipality: existingOrganization?.municipality ?? "",
companySize: existingOrganization?.companySize ?? "",
yearsOfOperation: existingOrganization?.yearsOfOperation ?? "",
annualRevenueRange: existingOrganization?.annualRevenueRange ?? "",
hasGovernmentContracts:
existingOrganization?.hasGovernmentContracts === null || existingOrganization?.hasGovernmentContracts === undefined
? ""
: existingOrganization.hasGovernmentContracts
? "yes"
: "no",
country: existingOrganization?.country ?? "",
primaryObjective: existingOrganization?.primaryObjective ?? "",
}}
hasActaDocument={hasActaDocument}
actaUploadedAt={existingOrganization?.actaUploadedAt?.toISOString() ?? null}
/>
</PageShell>
);
}

View File

@@ -1,65 +1,704 @@
import Image from "next/image";
import Link from "next/link";
import { KontiaMark } from "@/components/app/kontia-mark";
export default function Home() {
type Pillar = {
title: string;
tag: string;
description: string;
bullets: string[];
iconLabel: string;
topBorderColor: string;
tagClassName: string;
dotClassName: string;
iconWrapperClassName: string;
iconLabelClassName: string;
};
type ModuleCard = {
module: string;
title: string;
description: string;
track: "RADAR" | "CRECE" | "INDICE";
plan: string;
available: boolean;
iconLabel: string;
};
const pillars: Pillar[] = [
{
title: "Radar de Contratacion con Proposito",
tag: "DIAGNOSTICO",
description: "Evalua el punto de partida de tu empresa en 5 dimensiones estrategicas.",
bullets: [
"Evaluacion de liderazgo y vision estrategica",
"Analisis de cultura organizacional",
"Revision de procesos internos",
"Medicion de capacidad de innovacion",
"Evaluacion de impacto social y territorial",
],
iconLabel: "R",
topBorderColor: "border-t-[#203f82]",
tagClassName: "bg-[#1f3d7a] text-white",
dotClassName: "bg-[#214183]",
iconWrapperClassName: "bg-[#bac3d0]",
iconLabelClassName: "text-[#1f3f80]",
},
{
title: "Programa CRECE",
tag: "ACOMPANAMIENTO",
description: "Ruta progresiva de fortalecimiento con capacitacion y rediseno de procesos.",
bullets: [
"Capacitacion estrategica personalizada",
"Rediseno de procesos internos",
"Acompanamiento practico continuo",
"Preparacion para licitaciones",
"Integracion a cadenas de valor",
],
iconLabel: "C",
topBorderColor: "border-t-[#2eb280]",
tagClassName: "bg-[#24aa77] text-white",
dotClassName: "bg-[#2aad7e]",
iconWrapperClassName: "bg-[#c2e3d8]",
iconLabelClassName: "text-[#24a977]",
},
{
title: "Indice de Madurez Estrategica",
tag: "MEDICION",
description: "Mide avances en el tiempo y compara diagnostico inicial vs evolucion.",
bullets: [
"Seguimiento de avances en tiempo real",
"Comparativa diagnostico vs evolucion",
"Clasificacion de madurez progresiva",
"Reportes de progreso detallados",
"Certificacion de nivel alcanzado",
],
iconLabel: "I",
topBorderColor: "border-t-[#e2b420]",
tagClassName: "bg-[#dfb21a] text-[#1a2238]",
dotClassName: "bg-[#ddb022]",
iconWrapperClassName: "bg-[#ece0be]",
iconLabelClassName: "text-[#d9a706]",
},
];
const modules: ModuleCard[] = [
{
module: "Modulo 01",
title: "Registro Inteligente y Diagnostico Estrategico Inicial",
description: "Onboarding guiado para capturar datos clave y construir tu punto de partida.",
track: "RADAR",
plan: "GRATIS",
available: true,
iconLabel: "01",
},
{
module: "Modulo 02",
title: "Diagnostico Estrategico Avanzado de Valor Publico",
description: "Evaluacion profunda de dimensiones RADAR para identificar fortalezas.",
track: "RADAR",
plan: "Plan 1",
available: true,
iconLabel: "02",
},
{
module: "Modulo 03",
title: "Deteccion Inteligente de Oportunidades de Licitacion",
description: "Busqueda con IA para detectar oportunidades alineadas a tu perfil.",
track: "CRECE",
plan: "Plan 1",
available: true,
iconLabel: "03",
},
{
module: "Modulo 04",
title: "Normatividad Actualizada en Contratacion Publica",
description: "Base de conocimiento explicada en lenguaje claro para tomar decisiones.",
track: "CRECE",
plan: "Plan 1",
available: true,
iconLabel: "04",
},
{
module: "Modulo 05",
title: "Gestion Integral de Licitaciones",
description: "Herramienta para preparar, revisar y enviar propuestas con checklist.",
track: "CRECE",
plan: "Plan 2",
available: false,
iconLabel: "05",
},
{
module: "Modulo 06",
title: "Detector de Candados y Riesgos en Licitaciones",
description: "Analisis automatico de bases para identificar requisitos excluyentes.",
track: "CRECE",
plan: "Plan 2",
available: false,
iconLabel: "06",
},
{
module: "Modulo 07",
title: "Alertas de Cumplimiento Normativo y Seguimiento",
description: "Sistema de notificaciones sobre plazos, documentos y cambios normativos.",
track: "INDICE",
plan: "Plan 2",
available: false,
iconLabel: "07",
},
{
module: "Modulo 08",
title: "Gestion Estrategica de Contratos",
description: "Dashboard para administrar contratos activos, entregables y obligaciones.",
track: "INDICE",
plan: "Plan 3",
available: false,
iconLabel: "08",
},
{
module: "Modulo 09",
title: "Proteccion Legal ante Incumplimientos Contractuales",
description: "Guia legal y plantillas para responder ante riesgos de incumplimiento.",
track: "CRECE",
plan: "Plan 3",
available: false,
iconLabel: "09",
},
{
module: "Modulo 10",
title: "Simulador de Auditorias y Revision Preventiva",
description: "Herramienta de autoevaluacion para auditorias y revisiones gubernamentales.",
track: "INDICE",
plan: "Plan 3",
available: false,
iconLabel: "10",
},
];
const trackColorClass: Record<ModuleCard["track"], string> = {
RADAR: "text-[#28498d] bg-[#d8dfef]",
CRECE: "text-[#28a877] bg-[#d8eee5]",
INDICE: "text-[#946f00] bg-[#efe5c5]",
};
const maturityStages = [
{
name: "Inicial",
text: "Empresa comenzando su camino hacia la contratacion publica.",
checks: [
"Procesos basicos documentados",
"Conocimiento limitado de normativa",
"Sin experiencia en licitaciones",
"Estructura organizacional basica",
],
chip: "bg-[#dee3ee] text-[#8192ad]",
},
{
name: "En Desarrollo",
text: "Construyendo capacidades y adquiriendo experiencia competitiva.",
checks: [
"Procesos en mejora continua",
"Capacitacion en normativa activa",
"Primeras participaciones en licitaciones",
"Equipo con roles definidos",
],
chip: "bg-[#d2e8f4] text-[#289ad7]",
},
{
name: "Avanzado",
text: "Listo para competir efectivamente en contratacion publica.",
checks: [
"Procesos optimizados y certificados",
"Dominio de normativa vigente",
"Historial de contratos exitosos",
"Cultura de innovacion establecida",
],
chip: "bg-[#d8ebe5] text-[#23a676]",
},
{
name: "Lider",
text: "Referente del sector con impacto territorial demostrado.",
checks: [
"Excelencia operacional reconocida",
"Contribucion al desarrollo normativo",
"Mentoria para otras MiPYMEs",
"Impacto social medible",
],
chip: "bg-[#f0e5c2] text-[#9c7a0d]",
},
];
export default function HomePage() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
<div className="min-h-screen bg-[#eff2f7] text-[#1a2a47]">
<header className="sticky top-0 z-40 border-b border-[#d8dde7] bg-[#eff2f7]/90 backdrop-blur">
<div className="mx-auto flex w-full max-w-[1500px] items-center justify-between px-4 py-4 md:px-8">
<Link href="/">
<KontiaMark />
</Link>
<nav className="hidden items-center gap-10 text-base font-medium text-[#4f5f7d] md:flex">
<a href="#metodologia" className="transition-colors hover:text-[#1a2f63]">
Metodologia
</a>
<a href="#modulos" className="transition-colors hover:text-[#1a2f63]">
Modulos
</a>
<a href="#beneficios" className="transition-colors hover:text-[#1a2f63]">
Beneficios
</a>
<a href="#contacto" className="transition-colors hover:text-[#1a2f63]">
Contacto
</a>
</nav>
<div className="flex items-center gap-2 md:gap-4">
<Link
href="/login"
className="rounded-xl px-3 py-2 text-sm font-semibold text-[#0f1f3f] transition-colors hover:text-[#223d79] md:px-4 md:text-base"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
Iniciar Sesion
</Link>
<Link
href="/register"
className="rounded-2xl bg-[#1c3773] px-4 py-2 text-sm font-semibold text-white transition hover:bg-[#1f438d] md:px-6 md:py-3 md:text-base"
>
Learning
</a>{" "}
center.
Comenzar Gratis
</Link>
</div>
</div>
</header>
<main>
<section className="border-b border-[#dde3ee]">
<div className="mx-auto grid w-full max-w-[1500px] gap-8 px-4 py-10 md:grid-cols-[1.18fr_0.82fr] md:px-8 md:py-12">
<div>
<span className="inline-flex items-center gap-2 rounded-full border border-[#b8e0d3] bg-[#dff3eb] px-6 py-2 text-sm font-semibold text-[#2c9a76] md:text-base">
<span className="h-2.5 w-2.5 rounded-full bg-[#48b995]" />
Plataforma GovTech para MiPYMEs
</span>
<h1 className="mt-6 text-4xl font-bold leading-[0.95] text-[#101d3a] [font-family:var(--font-display)] md:max-w-[860px] md:text-[64px] md:tracking-[-0.02em]">
Transforma tu empresa para{" "}
<span className="bg-gradient-to-r from-[#24468e] via-[#30638f] to-[#2ea181] bg-clip-text text-transparent">
competir y ganar
</span>{" "}
en contratacion publica
</h1>
<p className="mt-5 max-w-[760px] text-base leading-relaxed text-[#4f6183] md:text-[19px] md:leading-[1.45]">
KONTIA fortalece tu MiPYME de forma integral: diagnostico estrategico, acompanamiento personalizado y
herramientas para participar exitosamente en licitaciones publicas.
</p>
<div className="mt-6 flex flex-wrap gap-3 md:gap-4">
<Link
href="/register"
className="inline-flex items-center justify-center rounded-2xl bg-gradient-to-r from-[#1f3f83] to-[#21a47b] px-6 py-4 text-base font-semibold text-white shadow-[0_8px_20px_rgba(31,63,131,0.25)] transition-transform hover:-translate-y-0.5 md:min-w-[320px] md:text-[18px]"
>
Diagnostica tu empresa gratis
</Link>
<a
href="#planes"
className="inline-flex items-center justify-center rounded-2xl border border-[#cad2df] bg-white px-6 py-4 text-base font-semibold text-[#12203f] transition-colors hover:border-[#9fb2d2] hover:text-[#1e3976] md:min-w-[230px] md:text-[18px]"
>
Ver demostracion
</a>
</div>
<div className="mt-6 grid gap-4 border-t border-[#d9dfeb] pt-5 sm:grid-cols-3">
<div>
<p className="text-4xl font-extrabold text-[#0f1f3f] md:text-[42px]">500+</p>
<p className="mt-1 text-base text-[#5e7090] md:text-[17px]">MiPYMEs fortalecidas</p>
</div>
<div>
<p className="text-4xl font-extrabold text-[#0f1f3f] md:text-[42px]">$2.5M</p>
<p className="mt-1 text-base text-[#5e7090] md:text-[17px]">En contratos ganados</p>
</div>
<div>
<p className="text-4xl font-extrabold text-[#0f1f3f] md:text-[42px]">95%</p>
<p className="mt-1 text-base text-[#5e7090] md:text-[17px]">Tasa de satisfaccion</p>
</div>
</div>
</div>
<div className="relative self-center">
<div className="absolute -top-3 right-0 rounded-2xl border border-[#d9dee8] bg-white px-4 py-3 text-base font-semibold text-[#203150] shadow-lg md:right-7 md:text-[16px]">
Proteccion Legal
</div>
<article className="rounded-3xl border border-[#dde3ee] bg-white p-7 shadow-[0_20px_38px_rgba(64,81,119,0.12)] md:min-h-[520px] md:p-10">
<h3 className="text-center text-3xl font-semibold text-[#122143] [font-family:var(--font-display)] md:text-[44px]">
Radar de Contratacion
</h3>
<p className="mt-2 text-center text-base text-[#5b6f8f] md:text-[17px]">
Evaluacion de las 5 dimensiones clave
</p>
<div className="mt-7 flex justify-center">
<svg viewBox="0 0 300 250" className="h-[240px] w-[290px] md:h-[250px] md:w-[290px]">
<polygon points="150,35 245,102 210,213 90,213 55,102" fill="none" stroke="#d6ddeb" strokeWidth="2" />
<polygon points="150,65 215,112 191,187 109,187 84,112" fill="none" stroke="#d6ddeb" strokeWidth="2" />
<polygon points="150,95 185,122 173,160 127,160 115,122" fill="none" stroke="#d6ddeb" strokeWidth="2" />
<polygon points="150,75 204,116 188,176 112,176 95,116" fill="#9fd1f5" fillOpacity="0.34" stroke="#25a580" strokeWidth="3" />
<line x1="150" y1="15" x2="150" y2="220" stroke="#d8deea" />
<line x1="35" y1="102" x2="260" y2="102" stroke="#d8deea" />
<line x1="75" y1="215" x2="220" y2="35" stroke="#d8deea" />
<line x1="80" y1="35" x2="225" y2="215" stroke="#d8deea" />
<circle cx="150" cy="75" r="7" fill="#26b286" />
<circle cx="204" cy="116" r="7" fill="#26b286" />
<circle cx="188" cy="176" r="7" fill="#26b286" />
<circle cx="112" cy="176" r="7" fill="#26b286" />
<circle cx="95" cy="116" r="7" fill="#26b286" />
<text x="140" y="26" fill="#3c4f72" fontSize="14">
Liderazgo
</text>
<text x="232" y="110" fill="#3c4f72" fontSize="14">
Cultura
</text>
<text x="199" y="233" fill="#3c4f72" fontSize="14">
Procesos
</text>
<text x="39" y="233" fill="#3c4f72" fontSize="14">
Innovacion
</text>
<text x="6" y="110" fill="#3c4f72" fontSize="14">
Impacto
</text>
</svg>
</div>
<div className="mt-3 flex items-center justify-between gap-3">
<span className="rounded-full border border-[#d5e8df] bg-[#dff3ea] px-5 py-2 text-sm font-semibold text-[#28a77a] md:text-[16px]">
Nivel Avanzado
</span>
<span className="rounded-2xl border border-[#d8dfeb] bg-white px-4 py-3 text-sm font-semibold text-[#314465] md:text-[16px]">
+45% Madurez
</span>
</div>
</article>
</div>
</div>
</section>
<section id="metodologia" className="mx-auto w-full max-w-[1400px] px-4 py-20 md:px-8">
<p className="text-center text-base font-bold tracking-[0.18em] text-[#23a777]">METODOLOGIA KONTIA</p>
<h2 className="mt-4 text-center text-4xl leading-tight text-[#102042] [font-family:var(--font-display)] md:text-[56px]">
Tres pilares para tu transformacion{" "}
<span className="bg-gradient-to-r from-[#25498e] to-[#2ca47f] bg-clip-text text-transparent">empresarial</span>
</h2>
<p className="mx-auto mt-6 max-w-4xl text-center text-lg leading-relaxed text-[#576c8e] md:text-xl md:leading-relaxed">
Un modelo holistico que diagnostica, acompana y mide tu progreso hacia la excelencia en contratacion publica
estrategica.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
<div className="mt-12 grid gap-6 lg:grid-cols-3">
{pillars.map((pillar) => (
<article
key={pillar.title}
className={`rounded-3xl border border-[#dbe2ed] bg-white p-6 shadow-[0_10px_28px_rgba(65,82,120,0.12)] ${pillar.topBorderColor} border-t-8`}
>
<div className="flex items-start justify-between gap-4">
<span className={`inline-flex h-14 w-14 items-center justify-center rounded-2xl text-lg font-bold ${pillar.iconWrapperClassName} ${pillar.iconLabelClassName}`}>
{pillar.iconLabel}
</span>
<span className={`rounded-full px-4 py-2 text-sm font-bold ${pillar.tagClassName}`}>{pillar.tag}</span>
</div>
<h3 className="mt-5 text-3xl leading-tight text-[#172542] [font-family:var(--font-display)] md:text-[32px]">
{pillar.title}
</h3>
<p className="mt-4 text-base leading-relaxed text-[#5f7293] md:text-lg md:leading-relaxed">{pillar.description}</p>
<ul className="mt-5 space-y-3 text-base text-[#596f92] md:text-[17px] md:leading-[1.55]">
{pillar.bullets.map((item) => (
<li key={item} className="flex items-start gap-3">
<span className={`mt-2 inline-block h-2.5 w-2.5 rounded-full ${pillar.dotClassName}`} />
<span>{item}</span>
</li>
))}
</ul>
</article>
))}
</div>
</section>
<section id="modulos" className="border-y border-[#dce2ec] bg-[#ecf1f7] py-20">
<div className="mx-auto w-full max-w-[1400px] px-4 md:px-8">
<p className="text-center text-base font-bold tracking-[0.18em] text-[#24a778]">FUNCIONALIDADES COMPLETAS</p>
<h2 className="mt-4 text-center text-4xl leading-tight text-[#112245] [font-family:var(--font-display)] md:text-[56px]">
10 modulos disenados para tu exito{" "}
<span className="bg-gradient-to-r from-[#25498d] to-[#2ba57f] bg-clip-text text-transparent">integral</span>
</h2>
<p className="mx-auto mt-6 max-w-4xl text-center text-lg leading-relaxed text-[#586c8c] md:text-xl md:leading-relaxed">
Desde el diagnostico inicial hasta la gestion de contratos, KONTIA te acompana en cada etapa.
</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-x-8 gap-y-3 text-base font-medium text-[#4a5f84] md:text-[17px]">
<span className="inline-flex items-center gap-2">
<span className="h-4 w-4 rounded-full bg-[#24488c]" />
RADAR - Diagnostico
</span>
<span className="inline-flex items-center gap-2">
<span className="h-4 w-4 rounded-full bg-[#2ba67b]" />
CRECE - Acompanamiento
</span>
<span className="inline-flex items-center gap-2">
<span className="h-4 w-4 rounded-full bg-[#e0b51f]" />
INDICE - Medicion
</span>
</div>
<div className="mt-6 flex flex-wrap justify-center gap-3 text-sm font-semibold md:text-base">
<span className="rounded-full border border-[#abdbc5] bg-[#dff4e9] px-4 py-2 text-[#269f75]">GRATIS Modulo 1</span>
<span className="rounded-full border border-[#b8d0ef] bg-[#dce6f8] px-4 py-2 text-[#2f5eda]">Plan 1 Modulos 2-4</span>
<span className="rounded-full border border-[#d2beee] bg-[#e8ddf6] px-4 py-2 text-[#8444d0]">Plan 2 Modulos 5-7</span>
<span className="rounded-full border border-[#e0cc9e] bg-[#f4ead4] px-4 py-2 text-[#d2840d]">Plan 3 Modulos 8-10</span>
</div>
<div className="mt-10 grid gap-5 md:grid-cols-2 xl:grid-cols-4">
{modules.map((module) => (
<article
key={module.module}
className="rounded-3xl border border-[#dbe2ed] bg-white p-6 shadow-[0_8px_22px_rgba(61,78,117,0.11)] transition-transform hover:-translate-y-1"
>
<div className="flex items-start justify-between gap-2">
<span className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#dde6ef] text-sm font-bold text-[#345081]">
{module.iconLabel}
</span>
<span className={`rounded-full px-3 py-1 text-sm font-bold ${trackColorClass[module.track]}`}>
{module.plan} {module.track}
</span>
</div>
<p className="mt-5 text-lg font-semibold text-[#7486a3] md:text-xl">{module.module}</p>
<h3 className="mt-2 text-2xl leading-tight text-[#1a2845] [font-family:var(--font-display)] md:text-[30px]">
{module.title}
</h3>
<p className="mt-4 text-base leading-relaxed text-[#617392] md:text-[17px] md:leading-relaxed">{module.description}</p>
{module.available ? (
<Link
href="/register"
className="mt-5 inline-flex items-center gap-2 text-base font-semibold text-[#1c3d80] transition-colors hover:text-[#2a5cc0] md:text-lg"
>
Explorar modulo
<span aria-hidden>{"->"}</span>
</Link>
) : (
<p className="mt-5 text-base font-semibold text-[#7e8da5] md:text-base">En construccion</p>
)}
</article>
))}
</div>
</div>
</section>
<section id="beneficios" className="mx-auto w-full max-w-[1400px] px-4 py-20 md:px-8">
<p className="text-center text-base font-bold tracking-[0.18em] text-[#24a779]">INDICE DE MADUREZ</p>
<h2 className="mt-4 text-center text-4xl leading-tight text-[#112245] [font-family:var(--font-display)] md:text-[56px]">
Tu camino hacia el liderazgo{" "}
<span className="bg-gradient-to-r from-[#264b8f] to-[#2ea380] bg-clip-text text-transparent">estrategico</span>
</h2>
<p className="mx-auto mt-6 max-w-4xl text-center text-lg leading-relaxed text-[#596d8c] md:text-xl md:leading-relaxed">
KONTIA mide tu progreso en 4 niveles, desde el inicio hasta convertirte en referente.
</p>
<div className="mx-auto mt-10 max-w-5xl rounded-3xl border border-[#dce3ee] bg-white p-7 shadow-[0_12px_30px_rgba(65,83,120,0.13)] md:p-10">
<div className="grid gap-3 text-center text-base font-semibold md:grid-cols-4 md:text-[17px]">
<span className="rounded-full bg-[#dee3ee] px-4 py-2 text-[#7f8fa9]">Inicial</span>
<span className="rounded-full bg-[#d2e8f3] px-4 py-2 text-[#2998d4]">En Desarrollo</span>
<span className="rounded-full bg-[#d8ebe5] px-4 py-2 text-[#24a675]">Avanzado</span>
<span className="rounded-full bg-[#efe5c2] px-4 py-2 text-[#9b790b]">Lider</span>
</div>
<div className="mt-8 h-5 rounded-full bg-[#e2e6ee]">
<div className="relative h-full w-[67%] rounded-full bg-gradient-to-r from-[#7f90ae] via-[#2f9ed9] to-[#e0b51f]">
<span className="absolute -right-2 top-1/2 h-8 w-8 -translate-y-1/2 rounded-full border-4 border-[#2ba57d] bg-white" />
</div>
</div>
<div className="mt-6 grid grid-cols-2 text-base text-[#4f6284] md:grid-cols-4 md:text-base">
<p>Inicial</p>
<p className="text-center">En Desarrollo</p>
<p className="text-center">Avanzado</p>
<p className="text-right">Lider</p>
</div>
</div>
<div className="mt-10 grid gap-5 md:grid-cols-2 xl:grid-cols-4">
{maturityStages.map((stage) => (
<article key={stage.name} className="rounded-3xl border border-[#dde3ee] bg-white p-6 shadow-[0_8px_24px_rgba(61,79,116,0.12)]">
<span className={`inline-flex rounded-full px-4 py-2 text-lg font-semibold ${stage.chip}`}>{stage.name}</span>
<h3 className="mt-4 text-3xl text-[#122143] [font-family:var(--font-display)] md:text-[40px]">{stage.name}</h3>
<p className="mt-3 text-base leading-relaxed text-[#5f7292] md:text-lg md:leading-relaxed">{stage.text}</p>
<ul className="mt-5 space-y-2 text-base text-[#566c8f] md:text-[17px]">
{stage.checks.map((item) => (
<li key={item} className="flex items-start gap-2">
<span className="mt-0.5 text-[#26aa77]">v</span>
<span>{item}</span>
</li>
))}
</ul>
</article>
))}
</div>
</section>
<section id="planes" className="bg-[#1d336e] py-20 text-white">
<div className="mx-auto w-full max-w-[1400px] px-4 md:px-8">
<p className="mx-auto w-fit rounded-full border border-[#49629d] bg-[#334c86] px-5 py-2 text-sm font-semibold md:text-base">
Unete a +500 MiPYMEs transformadas
</p>
<h2 className="mt-6 text-center text-4xl leading-tight text-white [font-family:var(--font-display)] md:text-[56px]">
Comienza tu transformacion empresarial hoy
</h2>
<p className="mx-auto mt-5 max-w-4xl text-center text-lg leading-relaxed text-[#c6d2e9] md:text-xl md:leading-relaxed">
Diagnostica tu empresa, identifica oportunidades y preparate para competir con el acompanamiento de KONTIA.
</p>
<div className="mt-10 grid gap-6 lg:grid-cols-2">
<article className="rounded-3xl border border-[#3f568f] bg-[#2a427a] p-7">
<h3 className="text-3xl text-white [font-family:var(--font-display)] md:text-[38px]">Modulo 1</h3>
<p className="mt-2 text-xl font-bold text-[#42e391] md:text-2xl">100% Gratuito</p>
<ul className="mt-6 space-y-3 text-base text-[#d4def1] md:text-lg">
<li className="flex items-start gap-2">
<span className="text-[#40de8d]">v</span>
Registro inteligente con IA
</li>
<li className="flex items-start gap-2">
<span className="text-[#40de8d]">v</span>
Diagnostico RADAR completo
</li>
<li className="flex items-start gap-2">
<span className="text-[#40de8d]">v</span>
Indice de madurez empresarial
</li>
<li className="flex items-start gap-2">
<span className="text-[#40de8d]">v</span>
Recomendaciones estrategicas
</li>
</ul>
<Link
href="/register"
className="mt-7 inline-flex w-full items-center justify-center rounded-2xl bg-white px-6 py-4 text-base font-semibold text-[#1f3c77] transition hover:bg-[#e7ecf6] md:text-lg"
>
Comenzar gratis
</Link>
</article>
<article className="relative rounded-3xl border border-[#35608a] bg-[#20476e] p-7">
<span className="absolute -top-3 right-6 rounded-full bg-[#24ad7a] px-4 py-1 text-sm font-bold text-white md:text-base">
POPULAR
</span>
<h3 className="text-3xl text-white [font-family:var(--font-display)] md:text-[38px]">Plan 1 - Oportunidades</h3>
<p className="mt-2 text-xl font-bold text-[#24c07f] md:text-2xl">$499 MXN/mes</p>
<ul className="mt-6 space-y-3 text-base text-[#d4def1] md:text-lg">
<li className="flex items-start gap-2">
<span className="text-[#40de8d]">v</span>
Todo lo del Modulo 1 gratuito
</li>
<li className="flex items-start gap-2">
<span className="text-[#40de8d]">v</span>
Perfil competitivo avanzado
</li>
<li className="flex items-start gap-2">
<span className="text-[#40de8d]">v</span>
Deteccion de oportunidades con IA
</li>
<li className="flex items-start gap-2">
<span className="text-[#40de8d]">v</span>
Analisis normativo de licitaciones
</li>
</ul>
<Link
href="/register"
className="mt-7 inline-flex w-full items-center justify-center rounded-2xl bg-[#26ab79] px-6 py-4 text-base font-semibold text-white transition hover:bg-[#1f996c] md:text-lg"
>
Ver planes
</Link>
</article>
</div>
<div className="mt-14 text-center">
<p className="text-base text-[#c6d2e8] md:text-lg">Prefieres una demostracion personalizada?</p>
<a
href="#contacto"
className="mt-5 inline-flex rounded-2xl bg-white/85 px-8 py-4 text-base font-semibold text-[#243b70] transition hover:bg-white md:text-lg"
>
Agendar demostracion
</a>
<p className="mt-10 text-sm text-[#9fb0d3] md:text-base">Plataforma segura y certificada. Tus datos estan protegidos.</p>
</div>
</div>
</section>
</main>
<footer id="contacto" className="bg-[#1d336e] pb-12 text-[#d2dbee]">
<div className="mx-auto w-full max-w-[1400px] border-t border-[#375087] px-4 pt-12 md:px-8">
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
<div>
<div className="flex items-center gap-3">
<span className="inline-flex rounded-md bg-white px-1 py-1">
<KontiaMark compact />
</span>
</div>
<p className="mt-4 text-base leading-relaxed text-[#bac7e0] md:text-base">
Plataforma SaaS para fortalecer MiPYMEs y prepararlas para contratacion publica estrategica.
</p>
</div>
<div>
<h4 className="text-2xl font-semibold text-white md:text-[28px]">Modulos</h4>
<ul className="mt-4 space-y-2 text-base text-[#c0cde4] md:text-base">
<li>Modulo 1 - Registro Inteligente (Gratis)</li>
<li>Modulo 2 - Perfil Competitivo</li>
<li>Modulo 3 - Deteccion de Oportunidades</li>
<li>Modulo 4 - Analisis Normativo</li>
</ul>
</div>
<div>
<h4 className="text-2xl font-semibold text-white md:text-[28px]">Recursos</h4>
<ul className="mt-4 space-y-2 text-base text-[#c0cde4] md:text-base">
<li>
<Link href="/manual" className="hover:text-white">
Manual de Usuario
</Link>
</li>
<li>
<a href="#planes" className="hover:text-white">
Planes y Precios
</a>
</li>
<li>
<a href="#metodologia" className="hover:text-white">
Metodologia KONTIA
</a>
</li>
<li>
<a href="#beneficios" className="hover:text-white">
Beneficios
</a>
</li>
</ul>
</div>
<div>
<h4 className="text-2xl font-semibold text-white md:text-[28px]">Empresa</h4>
<ul className="mt-4 space-y-2 text-base text-[#c0cde4] md:text-base">
<li>Sobre Nosotros</li>
<li>
<a href="mailto:hola@kontia.com.mx" className="hover:text-white">
hola@kontia.com.mx
</a>
</li>
<li>Privacidad (proximamente)</li>
<li>Terminos de Servicio (proximamente)</li>
</ul>
</div>
</div>
<div className="mt-12 flex flex-col items-start justify-between gap-4 border-t border-[#375087] pt-6 text-sm text-[#9caece] md:flex-row md:items-center md:text-base">
<p>(c) 2026 KONTIA. Todos los derechos reservados.</p>
<div className="flex items-center gap-4 text-2xl">
<span>in</span>
<span>x</span>
<span>@</span>
</div>
</div>
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,76 @@
import Link from "next/link";
import { PageShell } from "@/components/app/page-shell";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog } from "@/components/ui/dialog";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { requireOnboardedUser } from "@/lib/auth/user";
import { getRecommendationsForUser } from "@/lib/recommendations";
export default async function RecommendationsPage() {
const user = await requireOnboardedUser();
const recommendationSnapshot = await getRecommendationsForUser(user.id);
return (
<PageShell
title="Recomendaciones"
description="Lista priorizada de acciones sugeridas en funcion del diagnostico."
action={<Button variant="secondary">Descargar reporte (PDF - Proximamente)</Button>}
>
<Card>
<CardContent className="py-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-semibold text-[#4f5d77]">Puntaje global actual: {Math.round(recommendationSnapshot.overallScore)}%</p>
<p className="text-sm text-[#5f6d86]">
Modulos priorizados: {recommendationSnapshot.targetedModuleCount} (umbral: {recommendationSnapshot.lowScoreThreshold}%)
</p>
</div>
</CardContent>
</Card>
{recommendationSnapshot.recommendations.length === 0 ? (
<Card>
<CardHeader>
<h3 className="text-base font-semibold text-[#22314b]">No hay recomendaciones disponibles</h3>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-[#64718a]">Completa el diagnostico o carga recomendaciones en la base de datos para generar sugerencias.</p>
<Link href="/diagnostic">
<Button>Ir al diagnostico</Button>
</Link>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{recommendationSnapshot.recommendations.map((recommendation) => (
<Card key={recommendation.id}>
<CardHeader className="flex flex-row items-start justify-between gap-2">
<div className="space-y-1">
<p className="text-xs font-semibold uppercase text-[#65718b]">{recommendation.moduleLabel}</p>
<h3 className="text-base font-semibold text-[#22314b]">{recommendation.title}</h3>
</div>
<Badge variant={recommendation.priorityVariant}>{recommendation.priorityLabel}</Badge>
</CardHeader>
<CardContent className="flex items-center justify-between gap-3">
<div className="space-y-2">
<p className="text-sm text-[#64718a]">{recommendation.detail}</p>
{recommendation.scoreHint ? <p className="text-xs font-semibold text-[#4f5d77]">{recommendation.scoreHint}</p> : null}
</div>
<Dialog
triggerLabel="Ver detalle"
title={recommendation.title}
description={`Recomendacion ${recommendation.source === "module" ? "priorizada" : "general"}`}
>
<p className="text-sm text-[#5f6b84]">{recommendation.detail}</p>
{recommendation.scoreHint ? <p className="mt-2 text-xs font-semibold text-[#4f5d77]">{recommendation.scoreHint}</p> : null}
</Dialog>
</CardContent>
</Card>
))}
</div>
)}
</PageShell>
);
}

90
src/app/register/page.tsx Normal file
View File

@@ -0,0 +1,90 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { PageShell } from "@/components/app/page-shell";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Stepper } from "@/components/ui/stepper";
import { getCurrentUser } from "@/lib/auth/user";
type RegisterPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
const registerErrorMap: Record<string, string> = {
invalid_input: "Completa todos los campos y usa una contrasena de al menos 8 caracteres.",
email_in_use: "Ese correo ya esta registrado. Inicia sesion o usa otro correo.",
server_error: "No fue posible completar el registro. Intenta nuevamente.",
};
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 RegisterPage({ searchParams }: RegisterPageProps) {
const currentUser = await getCurrentUser();
if (currentUser) {
redirect("/dashboard");
}
const params = await searchParams;
const errorCode = getParam(params, "error");
const errorMessage = errorCode ? registerErrorMap[errorCode] : null;
return (
<PageShell
title="Crear Cuenta"
description="Comienza el diagnostico empresarial con acceso seguro y verificacion por correo."
>
<Stepper steps={["Cuenta", "Verificacion", "Onboarding", "Diagnostico"]} currentStep={1} />
<Card className="mx-auto w-full max-w-xl">
<CardHeader>
<h2 className="text-xl font-semibold text-[#1f2a40]">Registro</h2>
<p className="text-sm text-[#67738c]">Ingresa tus datos para crear tu cuenta.</p>
</CardHeader>
<CardContent className="space-y-4">
{errorMessage ? (
<p className="rounded-lg border border-[#f6d0d0] bg-[#fff3f3] px-3 py-2 text-sm text-[#9d3030]">{errorMessage}</p>
) : null}
<form action="/api/auth/register" method="post" className="space-y-4">
<div>
<Label htmlFor="name">Nombre completo</Label>
<Input id="name" name="name" placeholder="Ana Torres" autoComplete="name" required />
</div>
<div>
<Label htmlFor="email">Correo</Label>
<Input id="email" name="email" type="email" placeholder="ana@empresa.com" autoComplete="email" required />
</div>
<div>
<Label htmlFor="password">Contrasena</Label>
<Input
id="password"
name="password"
type="password"
placeholder="********"
autoComplete="new-password"
minLength={8}
required
/>
</div>
<Button className="w-full" type="submit">
Crear cuenta
</Button>
</form>
<p className="text-center text-sm text-[#66738b]">
Ya tienes cuenta?{" "}
<Link href="/login" className="font-semibold text-[#0f2a5f]">
Inicia sesion
</Link>
</p>
</CardContent>
</Card>
</PageShell>
);
}

87
src/app/results/page.tsx Normal file
View File

@@ -0,0 +1,87 @@
import Link from "next/link";
import { PageShell } from "@/components/app/page-shell";
import { ScoreCard } from "@/components/app/score-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { requireOnboardedUser } from "@/lib/auth/user";
import { recomputeAssessmentResults } from "@/lib/scoring";
export default async function ResultsPage() {
const user = await requireOnboardedUser();
const snapshot = await recomputeAssessmentResults(user.id);
if (!snapshot.moduleScores.length || snapshot.answeredQuestions === 0) {
return (
<PageShell title="Resultados" description="Vista general del puntaje consolidado y hallazgos clave.">
<Card>
<CardHeader>
<h3 className="text-base font-semibold text-[#22314b]">Aun no hay resultados calculables</h3>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-[#64718a]">
Responde preguntas de al menos un modulo para generar puntajes y visualizar fortalezas/debilidades.
</p>
<Link href="/diagnostic">
<Button>Ir al diagnostico</Button>
</Link>
</CardContent>
</Card>
</PageShell>
);
}
const strongest = snapshot.strongestModule;
const weakest = snapshot.weakestModule;
const highlights = [
{
title: "Fortaleza",
description: strongest
? `${strongest.moduleName} lidera con ${Math.round(strongest.score)}% de madurez y ${Math.round(strongest.completion)}% de avance.`
: "No hay suficientes datos para identificar fortalezas.",
kind: "success" as const,
label: "Positivo",
},
{
title: "Oportunidad",
description: weakest
? `${weakest.moduleName} muestra ${Math.round(weakest.score)}%. Prioriza este frente para elevar el puntaje global.`
: "No hay suficientes datos para identificar oportunidades.",
kind: "warning" as const,
label: "Atencion",
},
];
return (
<PageShell title="Resultados" description="Vista general del puntaje consolidado y hallazgos clave.">
<ScoreCard score={Math.round(snapshot.overallScore)} />
<div className="grid gap-4 md:grid-cols-2">
{highlights.map((item) => (
<Card key={item.title}>
<CardHeader className="flex flex-row items-center justify-between">
<h3 className="text-base font-semibold text-[#20304a]">{item.title}</h3>
<Badge variant={item.kind}>{item.label}</Badge>
</CardHeader>
<CardContent>
<p className="text-sm text-[#617089]">{item.description}</p>
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<h3 className="text-base font-semibold text-[#20304a]">Siguiente paso recomendado</h3>
</CardHeader>
<CardContent className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-[#617089]">Revisa la seccion Modulos para desbloquear la ruta premium y continuar tu madurez.</p>
<Link href="/dashboard#modulos">
<Button>Ver modulos</Button>
</Link>
</CardContent>
</Card>
</PageShell>
);
}

View File

@@ -0,0 +1,78 @@
import Link from "next/link";
import { isAdminIdentity } from "@/lib/auth/admin";
import { StrategicDiagnosticWizard } from "@/components/app/strategic-diagnostic-wizard";
import { PageShell } from "@/components/app/page-shell";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { requireOnboardedUser } from "@/lib/auth/user";
import { getStrategicDiagnosticSnapshot } from "@/lib/strategic-diagnostic/server";
export default async function StrategicDiagnosticPage() {
const user = await requireOnboardedUser();
const hasPaidModulesAccess = isAdminIdentity(user.email, user.role);
if (!hasPaidModulesAccess) {
return (
<PageShell
title="Modulo 2: Diagnostico Estrategico"
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>
<CardHeader>
<h2 className="text-lg font-semibold text-[#1f2a40]">Acceso restringido</h2>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-[#64718a]">
El Modulo 2 forma parte de la ruta premium. Tu cuenta actual puede completar el Diagnostico (Modulo 1) y ver los planes en la seccion Modulos.
</p>
<div className="flex flex-wrap gap-2">
<Link href="/dashboard#modulos">
<Button>Ver modulos y planes</Button>
</Link>
<Link href="/diagnostic">
<Button variant="secondary">Ir a diagnostico</Button>
</Link>
</div>
</CardContent>
</Card>
</PageShell>
);
}
const snapshot = await getStrategicDiagnosticSnapshot(user.id);
if (!snapshot) {
return (
<PageShell title="Modulo 2: Diagnostico Estrategico" description="Perfil competitivo para licitaciones publicas.">
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-[#1f2a40]">No se encontro perfil organizacional</h2>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-[#64718a]">Completa primero tu onboarding para habilitar este modulo.</p>
<Link href="/onboarding">
<Button>Ir a onboarding</Button>
</Link>
</CardContent>
</Card>
</PageShell>
);
}
return (
<PageShell
title="Modulo 2: Diagnostico Estrategico"
description="Perfil competitivo para licitaciones publicas"
action={<span className="rounded-full bg-[#0f2a5f] px-4 py-1 text-sm font-semibold text-white">Plan Activo</span>}
className="space-y-6"
>
<StrategicDiagnosticWizard
initialData={snapshot.data}
initialScores={snapshot.scores}
initialEvidenceBySection={snapshot.evidenceBySection}
initialCompletedAt={snapshot.completedAt}
/>
</PageShell>
);
}

View File

@@ -0,0 +1,73 @@
import Link from "next/link";
import { isAdminIdentity } from "@/lib/auth/admin";
import { requireOnboardedUser } from "@/lib/auth/user";
import { PageShell } from "@/components/app/page-shell";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TalleresDesarrolloView } from "@/components/app/talleres-desarrollo-view";
import { getTalleresSnapshot } from "@/lib/talleres/server";
type PageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
function parseDimension(searchParams: Record<string, string | string[] | undefined>) {
const rawValue = searchParams.dimension;
if (typeof rawValue === "string") {
return rawValue.trim() || null;
}
if (Array.isArray(rawValue)) {
return rawValue[0]?.trim() || null;
}
return null;
}
export default async function TalleresDesarrolloPage({ searchParams }: PageProps) {
const user = await requireOnboardedUser();
const hasPaidModulesAccess = isAdminIdentity(user.email, user.role);
if (!hasPaidModulesAccess) {
return (
<PageShell
title="Talleres de Desarrollo"
description="Ruta premium para cerrar brechas por dimension con cursos especializados."
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">
<p className="text-sm text-[#64718a]">Talleres de Desarrollo 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="/strategic-diagnostic">
<Button variant="secondary">Ir a Modulo 2</Button>
</Link>
</div>
</CardContent>
</Card>
</PageShell>
);
}
const resolvedSearchParams = await searchParams;
const selectedDimension = parseDimension(resolvedSearchParams);
const snapshot = await getTalleresSnapshot(user.id);
return (
<PageShell
title="Talleres de Desarrollo"
description="Treisole - Cubre tus brechas con cursos especializados"
action={<span className="rounded-full bg-[#0f2a5f] px-4 py-1 text-sm font-semibold text-white">Plan Activo</span>}
className="space-y-6"
>
<TalleresDesarrolloView initialSnapshot={snapshot} initialDimension={selectedDimension} />
</PageShell>
);
}

147
src/app/verify/page.tsx Normal file
View File

@@ -0,0 +1,147 @@
import Link from "next/link";
import { PageShell } from "@/components/app/page-shell";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { consumeEmailVerificationToken } from "@/lib/auth/verification";
import { getCurrentUser } from "@/lib/auth/user";
type VerifyPageProps = {
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 VerifyPage({ searchParams }: VerifyPageProps) {
const currentUser = await getCurrentUser();
const params = await searchParams;
const token = getParam(params, "token");
const initialEmail = getParam(params, "email") ?? "";
const verificationLinkSent = getParam(params, "sent") === "1";
const wasUnverifiedOnLogin = getParam(params, "unverified") === "1";
const hasMissingEmailError = getParam(params, "error") === "missing_email";
const hasEmailDeliveryError = getParam(params, "error") === "email_delivery_failed";
const hasServerError = getParam(params, "error") === "server_error";
let status: "pending" | "verified" | "invalid" = "pending";
let resolvedEmail = initialEmail;
if (token) {
try {
const verificationResult = await consumeEmailVerificationToken(token);
if (verificationResult.status === "verified") {
status = "verified";
resolvedEmail = verificationResult.email ?? resolvedEmail;
} else {
status = "invalid";
}
} catch {
status = "invalid";
}
}
return (
<PageShell title="Verificar Correo" description="Confirma tu direccion de correo para activar la cuenta.">
<Card className="mx-auto w-full max-w-xl">
<CardHeader>
{status === "verified" ? (
<Badge variant="success" className="w-fit">
Correo verificado
</Badge>
) : status === "invalid" ? (
<Badge variant="warning" className="w-fit">
Enlace invalido o expirado
</Badge>
) : (
<Badge variant="neutral" className="w-fit">
Verificacion pendiente
</Badge>
)}
<h2 className="text-xl font-semibold text-[#1f2a40]">
{status === "verified"
? "Tu cuenta fue activada"
: status === "invalid"
? "No pudimos validar el enlace"
: "Revisa tu bandeja de entrada"}
</h2>
</CardHeader>
<CardContent className="space-y-4">
{status === "verified" ? (
<p className="text-sm text-[#64718a]">
{resolvedEmail ? `El correo ${resolvedEmail} fue verificado.` : "Tu correo fue verificado."} Ya puedes iniciar sesion.
</p>
) : status === "invalid" ? (
<p className="text-sm text-[#64718a]">
El enlace de verificacion no es valido o ya expiro. Solicita un nuevo enlace para continuar.
</p>
) : (
<p className="text-sm text-[#64718a]">
Enviaremos un enlace de verificacion para activar tu cuenta. En modo desarrollo, el enlace se imprime en consola del servidor.
</p>
)}
{wasUnverifiedOnLogin ? (
<p className="rounded-lg border border-[#fff1c2] bg-[#fff8e5] px-3 py-2 text-sm text-[#8a6500]">
Tu cuenta existe, pero aun no esta verificada. Te enviamos un nuevo enlace.
</p>
) : null}
{verificationLinkSent ? (
<p className="rounded-lg border border-[#ccead8] bg-[#ebf8f0] px-3 py-2 text-sm text-[#206546]">
Enlace de verificacion enviado {resolvedEmail ? `a ${resolvedEmail}` : ""}.
</p>
) : null}
{hasEmailDeliveryError ? (
<p className="rounded-lg border border-[#fff1c2] bg-[#fff8e5] px-3 py-2 text-sm text-[#8a6500]">
La cuenta fue creada, pero no pudimos enviar el correo de verificacion. Revisa la configuracion SMTP e intenta reenviar.
</p>
) : null}
{hasMissingEmailError ? (
<p className="rounded-lg border border-[#f6d0d0] bg-[#fff3f3] px-3 py-2 text-sm text-[#9d3030]">
Debes indicar un correo para reenviar la verificacion.
</p>
) : null}
{hasServerError ? (
<p className="rounded-lg border border-[#f6d0d0] bg-[#fff3f3] px-3 py-2 text-sm text-[#9d3030]">
Ocurrio un error al procesar la verificacion. Intenta nuevamente.
</p>
) : null}
<div className="flex flex-wrap gap-2">
{status === "verified" ? (
currentUser ? (
<Link href="/dashboard">
<Button>Ir al dashboard</Button>
</Link>
) : (
<Link href="/login">
<Button>Iniciar sesion</Button>
</Link>
)
) : (
<form action="/api/auth/resend" method="post">
<input type="hidden" name="email" value={resolvedEmail} />
<Button type="submit" disabled={!resolvedEmail}>
Reenviar enlace
</Button>
</form>
)}
<Link href="/login">
<Button variant="secondary">Volver a login</Button>
</Link>
</div>
</CardContent>
</Card>
</PageShell>
);
}

View File

@@ -0,0 +1,233 @@
"use client";
import Link from "next/link";
import dynamic from "next/dynamic";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import type { DimensionSnapshot, TalleresSnapshot } from "@/lib/talleres/types";
const RadarChartCard = dynamic(
() => import("@/components/app/radar-chart-card").then((module) => module.RadarChartCard),
{ ssr: false, loading: () => <div className="h-[360px] rounded-2xl border border-[#dce3ef] bg-white" /> },
);
function gapTone(gap: DimensionSnapshot["gapLevel"]) {
if (gap === "baja") {
return "border-[#b7e5cd] bg-[#e7f8ef] text-[#198e5f]";
}
if (gap === "media") {
return "border-[#efd68d] bg-[#fff6dc] text-[#996c0f]";
}
return "border-[#f0c2c2] bg-[#fff0f0] text-[#a23b3b]";
}
function statusIcon(gap: DimensionSnapshot["gapLevel"]) {
if (gap === "baja") {
return "OK";
}
if (gap === "media") {
return "!";
}
return "X";
}
export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnapshot }) {
const sortedByGap = [...snapshot.dimensions].sort((a, b) => a.displayScore - b.displayScore);
const checklist = sortedByGap.slice(0, 3);
return (
<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>
<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>
<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>
</div>
<div className="space-y-3">
<div className="h-4 overflow-hidden rounded-full bg-[#e2e7f1]">
<div className="h-full bg-gradient-to-r from-[#193769] via-[#2f9a76] to-[#ddb52a]" style={{ width: `${snapshot.overallMaturity}%` }} />
</div>
<div className="grid grid-cols-4 gap-2 text-center text-sm">
<p className="font-semibold text-[#4f6388]">Inicial 0%+</p>
<p className="font-semibold text-[#4f6388]">En Desarrollo 40%+</p>
<p className="font-semibold text-[#17386e]">Preparado 60%+</p>
<p className="font-semibold text-[#4f6388]">Avanzado 80%+</p>
</div>
</div>
<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]">
Proximo nivel: <span className="text-[#d39b1c]">{snapshot.nextLevel?.label ?? "Avanzado"}</span>
</p>
{snapshot.nextLevel ? (
<p className="text-lg font-semibold text-[#445a81]">Te faltan {snapshot.nextLevel.pointsNeeded} puntos</p>
) : (
<p className="text-lg font-semibold text-[#198d62]">Ya alcanzaste el nivel maximo.</p>
)}
</div>
{snapshot.nextLevel ? (
<>
<div className="mt-3 h-3 overflow-hidden rounded-full bg-[#d9e2ee]">
<div className="h-full bg-[#24a979]" style={{ width: `${snapshot.nextLevel.progressToTarget}%` }} />
</div>
<p className="mt-2 text-sm text-[#5e7194]">{snapshot.nextLevel.progressToTarget}% completado hacia {snapshot.nextLevel.label}</p>
</>
) : null}
<div className="mt-4 border-t border-[#dce3ef] pt-4">
<p className="text-xl font-semibold text-[#12274d]">Para subir de nivel:</p>
<ul className="mt-2 space-y-1 text-sm text-[#5f7293]">
{checklist.map((dimension) => (
<li key={dimension.moduleKey}>- Completa talleres y evidencia en {dimension.moduleName}</li>
))}
</ul>
</div>
</div>
</CardContent>
</Card>
<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>
<p className="text-sm text-[#60718f]">Visualiza tus fortalezas y areas de mejora en 5 dimensiones.</p>
</CardHeader>
<CardContent>
<RadarChartCard
data={snapshot.dimensions.map((dimension) => ({
module: dimension.moduleName,
score: Math.round(dimension.displayScore),
}))}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<h3 className="text-4xl font-semibold text-[#152647] [font-family:var(--font-display)]">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">
{snapshot.dimensions.map((dimension) => (
<article key={dimension.moduleKey} className="space-y-2">
<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>
<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
</Button>
</Link>
</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>
</div>
</div>
<div className="h-4 overflow-hidden rounded-full bg-[#d9e2ee]">
<div className="h-full bg-gradient-to-r from-[#19386c] to-[#24a979]" style={{ width: `${dimension.displayScore}%` }} />
</div>
</article>
))}
<Link href="/talleres-desarrollo" className="block pt-1">
<Button className="w-full rounded-xl bg-[#102b60] hover:bg-[#0b214b]">Ir a Talleres de Desarrollo</Button>
</Link>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<h3 className="text-4xl font-semibold text-[#152647] [font-family:var(--font-display)]">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">
<table className="min-w-full text-left">
<thead>
<tr className="border-b border-[#dce3ef] text-sm text-[#506688]">
<th className="px-2 py-2">Dimension</th>
<th className="px-2 py-2">Nivel actual</th>
<th className="px-2 py-2">Brecha</th>
<th className="px-2 py-2">Riesgo en Contratacion Publica</th>
</tr>
</thead>
<tbody>
{snapshot.dimensions.map((dimension) => (
<tr key={dimension.moduleKey} className="border-b border-[#edf1f7] text-sm text-[#293e63]">
<td className="px-2 py-3 font-semibold">{dimension.moduleName}</td>
<td className="px-2 py-3">
<span className="rounded-full border border-[#d3daea] bg-[#f5f7fc] px-3 py-1 text-xs font-semibold text-[#264168]">
{dimension.displayScore >= 80
? "Avanzado"
: dimension.displayScore >= 60
? "Preparado"
: dimension.displayScore >= 40
? "En Desarrollo"
: "Inicial"}
</span>
</td>
<td className="px-2 py-3">
<span className={`rounded-full border px-3 py-1 text-xs font-semibold ${gapTone(dimension.gapLevel)}`}>{dimension.gapLabel.replace("Brecha ", "")}</span>
</td>
<td className="px-2 py-3 text-[#5d7193]">{dimension.riskMessage}</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
<Card>
<CardHeader>
<h3 className="text-4xl font-semibold text-[#152647] [font-family:var(--font-display)]">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]">
{index + 1}. {recommendation.title}
</p>
<p className="mt-1 text-sm text-[#5f7395]">{recommendation.description}</p>
</article>
))}
</CardContent>
</Card>
<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-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-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-sm text-[#60718f]">Mide tu evolucion de Inicial a En Desarrollo, Preparado y Avanzado.</p>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,86 @@
"use client";
import dynamic from "next/dynamic";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Tabs } from "@/components/ui/tabs";
import type { ModuleScoreSummary } from "@/lib/scoring";
type DashboardViewProps = {
moduleScores: ModuleScoreSummary[];
};
const ModuleBarsCard = dynamic(
() => import("@/components/app/module-bars-card").then((module) => module.ModuleBarsCard),
{ ssr: false, loading: () => <div className="h-72 rounded-2xl border border-[#dde4f0] bg-white" /> },
);
const RadarChartCard = dynamic(
() => import("@/components/app/radar-chart-card").then((module) => module.RadarChartCard),
{ ssr: false, loading: () => <div className="h-72 rounded-2xl border border-[#dde4f0] bg-white" /> },
);
export function DashboardView({ moduleScores }: DashboardViewProps) {
const barData = moduleScores.map((moduleScore) => ({
name: moduleScore.moduleName,
value: Math.round(moduleScore.score),
}));
const radarData = moduleScores.map((moduleScore) => ({
module: moduleScore.moduleName,
score: Math.round(moduleScore.score),
}));
return (
<Tabs
items={[
{
id: "overview",
label: "Overview",
content: (
<div className="grid gap-4 lg:grid-cols-2">
<ModuleBarsCard data={barData} />
<RadarChartCard data={radarData} />
</div>
),
},
{
id: "modules",
label: "Modules",
content: (
<Card>
<CardHeader>
<h3 className="text-base font-semibold text-[#22314b]">Estado por modulo</h3>
</CardHeader>
<CardContent className="space-y-4">
{moduleScores.map((moduleScore) => (
<div key={moduleScore.moduleId} className="rounded-lg border border-[#e3eaf5] p-3">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-semibold text-[#32425d]">{moduleScore.moduleName}</p>
<Badge variant={moduleScore.status === "Completado" ? "success" : moduleScore.status === "En curso" ? "warning" : "neutral"}>
{moduleScore.status}
</Badge>
</div>
<div className="mb-1 flex items-center justify-between text-xs font-semibold text-[#5f6d86]">
<span>Score</span>
<span>{Math.round(moduleScore.score)}%</span>
</div>
<Progress value={moduleScore.score} />
<div className="mt-3 mb-1 flex items-center justify-between text-xs font-semibold text-[#5f6d86]">
<span>Avance</span>
<span>{Math.round(moduleScore.completion)}%</span>
</div>
<Progress value={moduleScore.completion} />
</div>
))}
</CardContent>
</Card>
),
},
]}
/>
);
}

View File

@@ -0,0 +1,40 @@
import { cn } from "@/lib/utils";
type KontiaMarkProps = {
compact?: boolean;
variant?: "stacked" | "horizontal";
className?: string;
};
export function KontiaMark({ compact = false, variant = "stacked", className }: KontiaMarkProps) {
const iconSizeClass = compact ? "h-7 w-7" : "h-9 w-9";
const labelClass = compact ? "text-xs" : "text-sm";
const icon = (
<svg viewBox="0 0 64 64" className={iconSizeClass} role="img" aria-label="Kontia logo">
<rect x="8" y="30" width="10" height="18" rx="2" fill="#0f162d" />
<rect x="23" y="22" width="10" height="26" rx="2" fill="#0f162d" />
<rect x="38" y="27" width="10" height="21" rx="2" fill="#0f162d" />
<circle cx="13" cy="12" r="4" fill="#0f162d" />
<circle cx="31" cy="16" r="4" fill="#0f162d" />
<circle cx="49" cy="10" r="4" fill="#0f162d" />
<path d="M13 12 L31 16 L49 10" fill="none" stroke="#0f162d" strokeWidth="3" strokeLinecap="round" />
</svg>
);
if (variant === "horizontal") {
return (
<span className={cn("inline-flex items-center gap-2.5", className)}>
{icon}
<span className={cn("leading-none font-semibold text-[#0f162d]", compact ? "text-lg" : "text-xl")}>Kontia</span>
</span>
);
}
return (
<span className={cn("inline-flex flex-col items-center justify-center leading-none text-[#0f162d]", className)}>
{icon}
<span className={cn("mt-0.5 font-semibold", labelClass)}>Kontia</span>
</span>
);
}

View File

@@ -0,0 +1,53 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
export function LicitationsSyncButton() {
const [isSyncing, setIsSyncing] = useState(false);
const [message, setMessage] = useState<string | null>(null);
async function triggerSync() {
setIsSyncing(true);
setMessage(null);
try {
const response = await fetch("/api/admin/sync", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
const payload = (await response.json().catch(() => ({}))) as {
ok?: boolean;
error?: string;
payload?: {
processedMunicipalities?: number;
};
};
if (!response.ok || !payload.ok) {
setMessage(payload.error ?? "No se pudo ejecutar la sincronizacion.");
return;
}
const processed = payload.payload?.processedMunicipalities ?? 0;
setMessage(`Sincronizacion completada. Municipios procesados: ${processed}.`);
} catch {
setMessage("No se pudo ejecutar la sincronizacion.");
} finally {
setIsSyncing(false);
}
}
return (
<div className="flex flex-col items-end gap-2">
<Button type="button" onClick={triggerSync} disabled={isSyncing}>
{isSyncing ? "Sincronizando..." : "Buscar oportunidades"}
</Button>
{message ? <p className="text-xs text-[#536583]">{message}</p> : null}
</div>
);
}

View File

@@ -0,0 +1,36 @@
"use client";
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
type BarDataPoint = {
name: string;
value: number;
};
type ModuleBarsCardProps = {
data: BarDataPoint[];
};
export function ModuleBarsCard({ data }: ModuleBarsCardProps) {
return (
<Card>
<CardHeader>
<h3 className="text-base font-semibold text-[#22314b]">Comparativo de Modulos</h3>
</CardHeader>
<CardContent>
<div className="h-72 w-full">
<ResponsiveContainer>
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="#ecf1f8" />
<XAxis dataKey="name" tick={{ fill: "#5f6b84", fontSize: 12 }} />
<YAxis tick={{ fill: "#5f6b84", fontSize: 12 }} />
<Tooltip />
<Bar dataKey="value" fill="#0f2a5f" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,41 @@
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
type ModuleCardProps = {
name: string;
completion: number;
status: "Completado" | "En curso" | "Pendiente";
href: string;
answeredQuestions: number;
totalQuestions: number;
};
export function ModuleCard({ name, completion, status, href, answeredQuestions, totalQuestions }: ModuleCardProps) {
return (
<Card>
<CardContent className="space-y-3">
<div className="flex items-start justify-between gap-2">
<h3 className="text-base font-semibold text-[#22314b]">{name}</h3>
<Badge variant={status === "Completado" ? "success" : status === "En curso" ? "warning" : "neutral"}>{status}</Badge>
</div>
<Progress value={completion} showValue />
<p className="text-sm text-[#5f6d86]">
{answeredQuestions}/{totalQuestions} preguntas respondidas
</p>
<div className="flex justify-end">
<Link href={href}>
<Button size="sm" variant="secondary">
{completion >= 100 ? "Revisar" : completion > 0 ? "Continuar" : "Iniciar"}
</Button>
</Link>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,359 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
type ModuleQuestionnaireProps = {
moduleKey: string;
moduleName: string;
moduleDescription: string | null;
questions: {
id: string;
prompt: string;
helpText: string | null;
options: {
id: string;
label: string;
}[];
selectedAnswerOptionId: string | null;
evidence: {
notes: string;
links: string[];
} | null;
}[];
moduleTabs: {
key: string;
label: string;
href: string;
active: boolean;
}[];
previousModuleHref: string | null;
nextModuleHref: string | null;
isLastModule: boolean;
};
type EvidenceDraft = {
notes: string;
links: string;
};
type SaveState = "idle" | "saving" | "saved" | "error";
function toEvidenceDraft(
evidence: {
notes: string;
links: string[];
} | null,
): EvidenceDraft {
return {
notes: evidence?.notes ?? "",
links: evidence?.links.join("\n") ?? "",
};
}
function toEvidencePayload(draft: EvidenceDraft) {
const notes = draft.notes.trim();
const links = draft.links
.split(/\n|,/g)
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
.slice(0, 10);
if (!notes && links.length === 0) {
return null;
}
return {
...(notes ? { notes } : {}),
...(links.length > 0 ? { links } : {}),
};
}
export function ModuleQuestionnaire({
moduleKey,
moduleName,
moduleDescription,
questions,
moduleTabs,
previousModuleHref,
nextModuleHref,
isLastModule,
}: ModuleQuestionnaireProps) {
const [answers, setAnswers] = useState<Record<string, string>>(() => {
return questions.reduce<Record<string, string>>((result, question) => {
if (question.selectedAnswerOptionId) {
result[question.id] = question.selectedAnswerOptionId;
}
return result;
}, {});
});
const [evidenceByQuestionId, setEvidenceByQuestionId] = useState<Record<string, EvidenceDraft>>(() => {
return questions.reduce<Record<string, EvidenceDraft>>((result, question) => {
result[question.id] = toEvidenceDraft(question.evidence);
return result;
}, {});
});
const [saveStateByQuestionId, setSaveStateByQuestionId] = useState<Record<string, SaveState>>({});
const [saveErrorByQuestionId, setSaveErrorByQuestionId] = useState<Record<string, string>>({});
const answeredCount = useMemo(() => {
return questions.reduce((total, question) => {
return total + (answers[question.id] ? 1 : 0);
}, 0);
}, [answers, questions]);
const completion = questions.length > 0 ? Math.round((answeredCount / questions.length) * 100) : 0;
function setQuestionSaveState(questionId: string, saveState: SaveState, errorMessage?: string | null) {
setSaveStateByQuestionId((previous) => ({
...previous,
[questionId]: saveState,
}));
setSaveErrorByQuestionId((previous) => {
const next = { ...previous };
if (errorMessage) {
next[questionId] = errorMessage;
} else {
delete next[questionId];
}
return next;
});
}
async function persistQuestionResponse(questionId: string, answerOptionId: string, evidenceDraft: EvidenceDraft) {
setQuestionSaveState(questionId, "saving");
try {
const response = await fetch("/api/diagnostic/response", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
moduleKey,
questionId,
answerOptionId,
evidence: toEvidencePayload(evidenceDraft),
}),
});
if (!response.ok) {
const payload = (await response.json().catch(() => ({}))) as { error?: string };
setQuestionSaveState(questionId, "error", payload.error ?? "No se pudo guardar la respuesta.");
return;
}
setQuestionSaveState(questionId, "saved");
} catch {
setQuestionSaveState(questionId, "error", "No se pudo guardar la respuesta.");
}
}
function onAnswerChange(questionId: string, answerOptionId: string) {
setAnswers((previous) => ({
...previous,
[questionId]: answerOptionId,
}));
const draft = evidenceByQuestionId[questionId] ?? { notes: "", links: "" };
void persistQuestionResponse(questionId, answerOptionId, draft);
}
function onEvidenceChange(questionId: string, field: keyof EvidenceDraft, value: string) {
setEvidenceByQuestionId((previous) => ({
...previous,
[questionId]: {
...(previous[questionId] ?? { notes: "", links: "" }),
[field]: value,
},
}));
}
function onEvidenceSave(questionId: string) {
const answerOptionId = answers[questionId];
if (!answerOptionId) {
setQuestionSaveState(questionId, "error", "Selecciona primero Si/No para guardar evidencia.");
return;
}
const draft = evidenceByQuestionId[questionId] ?? { notes: "", links: "" };
void persistQuestionResponse(questionId, answerOptionId, draft);
}
if (questions.length === 0) {
return (
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-[#1f2a40]">No hay preguntas disponibles</h2>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-[#64718a]">Este modulo aun no tiene preguntas configuradas.</p>
<Link href="/diagnostic">
<Button variant="secondary">Volver al listado de modulos</Button>
</Link>
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-2 rounded-xl border border-[#dde5f2] bg-white p-3">
{moduleTabs.map((tab) => (
<Link key={tab.key} href={tab.href}>
<Button variant={tab.active ? "primary" : "secondary"} size="sm">
{tab.label}
</Button>
</Link>
))}
</div>
<Card>
<CardContent className="space-y-2 py-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-semibold text-[#4d5974]">
{moduleName}
{moduleDescription ? ` - ${moduleDescription}` : ""}
</p>
<p className="text-sm font-semibold text-[#4d5974]">
{answeredCount} / {questions.length} respondidas
</p>
</div>
<Progress value={completion} showValue />
</CardContent>
</Card>
<Card>
<CardContent className="space-y-4 py-5">
{questions.map((question, index) => {
const selectedOptionId = answers[question.id] ?? null;
const evidenceDraft = evidenceByQuestionId[question.id] ?? { notes: "", links: "" };
const saveState = saveStateByQuestionId[question.id] ?? "idle";
const saveError = saveErrorByQuestionId[question.id] ?? null;
return (
<div key={question.id} className="rounded-xl border border-[#d9e2f0] p-4">
<p className="text-lg font-semibold text-[#1e2b45]">
{index + 1}. {question.prompt}
</p>
{question.helpText ? <p className="mt-1 text-sm text-[#64718a]">{question.helpText}</p> : null}
<div className="mt-3 flex flex-wrap gap-2">
{question.options.map((option) => (
<Label
key={option.id}
className={`flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 font-medium ${
selectedOptionId === option.id
? "border-[#0f2a5f] bg-[#eef3ff] text-[#163061]"
: "border-[#dde5f2] text-[#3a4963] hover:border-[#b7c6e1]"
}`}
>
<input
type="radio"
name={`question-${question.id}`}
className="h-4 w-4 accent-[#0f2a5f]"
checked={selectedOptionId === option.id}
onChange={() => onAnswerChange(question.id, option.id)}
/>
{option.label}
</Label>
))}
</div>
<div className="mt-4 space-y-3">
<div>
<Label htmlFor={`evidence-notes-${question.id}`}>Evidencia (opcional)</Label>
<textarea
id={`evidence-notes-${question.id}`}
value={evidenceDraft.notes}
onChange={(event) => onEvidenceChange(question.id, "notes", event.target.value)}
rows={3}
className="mt-1 w-full rounded-lg border border-[#cfd8e6] bg-white px-3 py-2 text-sm text-[#1f2a3d] placeholder:text-[#8c96ab] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0f2a5f]"
placeholder="Describe evidencia: certificados, fotos, resultados, etc."
/>
</div>
<div>
<Label htmlFor={`evidence-links-${question.id}`}>Links de soporte (opcional)</Label>
<textarea
id={`evidence-links-${question.id}`}
value={evidenceDraft.links}
onChange={(event) => onEvidenceChange(question.id, "links", event.target.value)}
rows={2}
className="mt-1 w-full rounded-lg border border-[#cfd8e6] bg-white px-3 py-2 text-sm text-[#1f2a3d] placeholder:text-[#8c96ab] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0f2a5f]"
placeholder="Un link por linea o separados por coma."
/>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
<Button
type="button"
size="sm"
variant="secondary"
disabled={saveState === "saving"}
onClick={() => onEvidenceSave(question.id)}
>
Guardar evidencia
</Button>
{saveState === "saving" ? <p className="text-sm text-[#60708a]">Guardando...</p> : null}
{saveState === "saved" ? <p className="text-sm text-[#20724a]">Guardado.</p> : null}
{saveState === "error" ? (
<p className="rounded-lg border border-[#f6d0d0] bg-[#fff3f3] px-3 py-2 text-sm text-[#9d3030]">
{saveError ?? "No se pudo guardar la respuesta."}
</p>
) : null}
</div>
</div>
);
})}
</CardContent>
</Card>
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
{previousModuleHref ? (
<Link href={previousModuleHref}>
<Button variant="ghost">Anterior modulo</Button>
</Link>
) : (
<Button variant="ghost" disabled>
Anterior modulo
</Button>
)}
</div>
<div className="flex gap-2">
<Link href="/diagnostic">
<Button variant="secondary">Guardar y salir</Button>
</Link>
{isLastModule ? (
<Link href="/results">
<Button>Ver mis resultados</Button>
</Link>
) : nextModuleHref ? (
<Link href={nextModuleHref}>
<Button>Siguiente modulo</Button>
</Link>
) : (
<Link href="/diagnostic">
<Button>Finalizar modulo</Button>
</Link>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,737 @@
"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Stepper } from "@/components/ui/stepper";
import type { ActaLookupDictionary } from "@/lib/extraction/schema";
type OnboardingValues = {
name: string;
tradeName: string;
rfc: string;
legalRepresentative: string;
incorporationDate: string;
deedNumber: string;
notaryName: string;
fiscalAddress: string;
businessPurpose: string;
industry: string;
operatingState: string;
municipality: string;
companySize: string;
yearsOfOperation: string;
annualRevenueRange: string;
hasGovernmentContracts: string;
country: string;
primaryObjective: string;
};
type OnboardingWizardProps = {
initialValues: OnboardingValues;
hasActaDocument: boolean;
actaUploadedAt: string | null;
};
type OnboardingPrefillPayload = {
[K in keyof OnboardingValues]?: string | null;
};
type ActaUiFields = {
name: string | null;
rfc: string | null;
legalRepresentative: string | null;
incorporationDate: string | null;
deedNumber: string | null;
notaryName: string | null;
fiscalAddress: string | null;
businessPurpose: string | null;
stateOfIncorporation: string | null;
};
type ExtractActaPayload = {
ok?: boolean;
error?: string;
code?: string;
fields?: Partial<ActaUiFields>;
lookupDictionary?: ActaLookupDictionary;
rawText?: string;
methodUsed?: "direct" | "ocr";
extractionEngine?: "ai" | "regex_fallback";
aiModel?: string | null;
numPages?: number;
warnings?: string[];
extractedData?: Partial<OnboardingValues> & {
lookupDictionary?: ActaLookupDictionary;
extractedFields?: string[];
detectedLookupFields?: string[];
confidence?: "low" | "medium" | "high";
extractionEngine?: "ai" | "regex_fallback";
};
actaUploadedAt?: string;
};
const steps = ["Acta constitutiva", "Datos legales", "Perfil", "Confirmacion"];
const companySizeOptions = [
"Micro (1-10 empleados)",
"Pequena (11-50 empleados)",
"Mediana (51-250 empleados)",
"Grande (251+ empleados)",
];
const annualRevenueRangeOptions = [
"Menos de $250,000",
"$250,000 - $500,000",
"$500,001 - $1,000,000",
"Mas de $1,000,000",
];
const MAX_ACTA_UPLOAD_BYTES = 15 * 1024 * 1024;
export function OnboardingWizard({ initialValues, hasActaDocument, actaUploadedAt }: OnboardingWizardProps) {
const router = useRouter();
const [step, setStep] = useState(1);
const [values, setValues] = useState<OnboardingValues>(initialValues);
const [selectedActaFile, setSelectedActaFile] = useState<File | null>(null);
const [actaReady, setActaReady] = useState(hasActaDocument);
const [lastActaUploadAt, setLastActaUploadAt] = useState<string | null>(actaUploadedAt);
const [extractConfidence, setExtractConfidence] = useState<"low" | "medium" | "high" | null>(null);
const [detectedFields, setDetectedFields] = useState<string[]>([]);
const [detectedLookupFields, setDetectedLookupFields] = useState<string[]>([]);
const [latestFields, setLatestFields] = useState<ActaUiFields | null>(null);
const [lookupDictionaryDebug, setLookupDictionaryDebug] = useState<Record<string, unknown> | null>(null);
const [analysisMethod, setAnalysisMethod] = useState<"direct" | "ocr" | null>(null);
const [extractionEngine, setExtractionEngine] = useState<"ai" | "regex_fallback" | null>(null);
const [aiModel, setAiModel] = useState<string | null>(null);
const [analysisWarnings, setAnalysisWarnings] = useState<string[]>([]);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isUploadingActa, setIsUploadingActa] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const completion = useMemo(() => Math.round((step / steps.length) * 100), [step]);
function updateValue(field: keyof OnboardingValues, value: string) {
setValues((previous) => ({ ...previous, [field]: value }));
}
function normalizeField(value: string | null | undefined) {
if (!value) {
return null;
}
const cleaned = value.trim();
return cleaned || null;
}
function toActaUiFields(payload: Partial<ActaUiFields> | undefined): ActaUiFields | null {
if (!payload) {
return null;
}
return {
name: normalizeField(payload.name),
rfc: normalizeField(payload.rfc),
legalRepresentative: normalizeField(payload.legalRepresentative),
incorporationDate: normalizeField(payload.incorporationDate),
deedNumber: normalizeField(payload.deedNumber),
notaryName: normalizeField(payload.notaryName),
fiscalAddress: normalizeField(payload.fiscalAddress),
businessPurpose: normalizeField(payload.businessPurpose),
stateOfIncorporation: normalizeField(payload.stateOfIncorporation),
};
}
function applyExtractedData(payload: (OnboardingPrefillPayload & Partial<ActaUiFields>) | undefined) {
if (!payload) {
return;
}
const fromExtracted = (value: string | null | undefined, previous: string) => (value === undefined ? previous : (value ?? ""));
setValues((previous) => ({
...previous,
name: fromExtracted(payload.name, previous.name),
tradeName: previous.tradeName || fromExtracted(payload.name, previous.tradeName),
// RFC is user-managed; acta extraction should never auto-fill it.
rfc: "",
legalRepresentative: fromExtracted(payload.legalRepresentative, previous.legalRepresentative),
incorporationDate: fromExtracted(payload.incorporationDate, previous.incorporationDate),
deedNumber: fromExtracted(payload.deedNumber, previous.deedNumber),
notaryName: fromExtracted(payload.notaryName, previous.notaryName),
fiscalAddress: fromExtracted(payload.fiscalAddress, previous.fiscalAddress),
businessPurpose: fromExtracted(payload.businessPurpose, previous.businessPurpose),
industry: fromExtracted(payload.industry, previous.industry),
country: fromExtracted(payload.country, previous.country),
}));
}
function compactDictionaryForDebug(dictionary: ActaLookupDictionary | undefined) {
if (!dictionary) {
return null;
}
const compact = JSON.parse(
JSON.stringify(dictionary, (_key, value: unknown) => {
if (value === null || value === undefined) {
return undefined;
}
if (Array.isArray(value) && value.length === 0) {
return undefined;
}
return value;
}),
) as Record<string, unknown>;
return Object.keys(compact).length > 0 ? compact : null;
}
async function handleActaUpload() {
if (!selectedActaFile) {
setErrorMessage("Selecciona primero el archivo PDF del Acta constitutiva.");
return;
}
if (selectedActaFile.size > MAX_ACTA_UPLOAD_BYTES) {
setErrorMessage("El archivo excede el limite de 15MB.");
return;
}
setErrorMessage(null);
setIsUploadingActa(true);
try {
const formData = new FormData();
formData.append("file", selectedActaFile);
const response = await fetch("/api/onboarding/acta", {
method: "POST",
body: formData,
});
let payload: ExtractActaPayload | null = null;
try {
payload = (await response.json()) as ExtractActaPayload;
} catch {
payload = null;
}
if (!response.ok || !payload?.ok) {
if (response.status === 413) {
setErrorMessage("El servidor/proxy rechazo la carga (413). Ajusta el limite de upload en Nginx y vuelve a intentar.");
return;
}
const codeSuffix = payload?.code ? ` [${payload.code}]` : "";
setErrorMessage((payload?.error ?? "No fue posible procesar el Acta constitutiva.") + codeSuffix);
return;
}
const normalizedFields = toActaUiFields(payload.fields);
applyExtractedData(payload.fields ?? payload.extractedData);
setLatestFields(normalizedFields);
setExtractConfidence(payload.extractedData?.confidence ?? null);
setLookupDictionaryDebug(compactDictionaryForDebug(payload.lookupDictionary ?? payload.extractedData?.lookupDictionary));
setDetectedLookupFields(payload.extractedData?.detectedLookupFields ?? []);
setDetectedFields(
payload.extractedData?.extractedFields ??
Object.entries(normalizedFields ?? {})
.filter(([, value]) => Boolean(value))
.map(([field]) => field),
);
setAnalysisMethod(payload.methodUsed ?? null);
setExtractionEngine(payload.extractionEngine ?? payload.extractedData?.extractionEngine ?? null);
setAiModel(payload.aiModel ?? null);
setAnalysisWarnings(payload.warnings ?? []);
setActaReady(true);
setLastActaUploadAt(payload.actaUploadedAt ?? new Date().toISOString());
setStep(2);
} catch {
setErrorMessage("No fue posible subir y analizar el documento.");
} finally {
setIsUploadingActa(false);
}
}
function validateCurrentStep() {
if (step === 1 && !actaReady) {
setErrorMessage("Debes cargar el Acta constitutiva en PDF para continuar.");
return false;
}
if (step === 2 && !values.name.trim()) {
setErrorMessage("Confirma el nombre legal de la empresa.");
return false;
}
if (step === 3 && !values.tradeName.trim()) {
setErrorMessage("Ingresa el nombre comercial.");
return false;
}
if (step === 3 && !values.industry.trim()) {
setErrorMessage("Selecciona el sector o giro de la empresa.");
return false;
}
if (step === 3 && !values.operatingState.trim()) {
setErrorMessage("Ingresa el estado de operacion.");
return false;
}
if (step === 3 && !values.municipality.trim()) {
setErrorMessage("Ingresa el municipio.");
return false;
}
if (step === 3 && !values.companySize.trim()) {
setErrorMessage("Selecciona el tamano de empresa.");
return false;
}
if (step === 3 && !values.yearsOfOperation.trim()) {
setErrorMessage("Ingresa los anos de operacion.");
return false;
}
if (step === 3 && !values.annualRevenueRange.trim()) {
setErrorMessage("Selecciona el rango de facturacion anual.");
return false;
}
if (step === 3 && !values.hasGovernmentContracts.trim()) {
setErrorMessage("Indica si has participado en licitaciones con gobierno.");
return false;
}
setErrorMessage(null);
return true;
}
function goNext() {
if (!validateCurrentStep()) {
return;
}
setStep((previous) => Math.min(previous + 1, steps.length));
}
function goBack() {
setErrorMessage(null);
setStep((previous) => Math.max(previous - 1, 1));
}
async function handleSubmit() {
if (!validateCurrentStep()) {
return;
}
setIsSubmitting(true);
setErrorMessage(null);
try {
const response = await fetch("/api/onboarding", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(values),
});
const payload = (await response.json()) as { ok?: boolean; redirectTo?: string; error?: string };
if (!response.ok || !payload.ok) {
setErrorMessage(payload.error ?? "No fue posible guardar el onboarding.");
return;
}
router.push(payload.redirectTo ?? "/diagnostic");
router.refresh();
} catch {
setErrorMessage("No fue posible guardar el onboarding.");
} finally {
setIsSubmitting(false);
}
}
return (
<div className="space-y-4">
<Stepper steps={steps} currentStep={step} />
<Card>
<CardHeader>
<h2 className="text-xl font-semibold text-[#1f2a40]">Onboarding con Acta constitutiva</h2>
<p className="text-sm text-[#67738c]">
Primero sube el PDF de Acta constitutiva para extraer datos legales, luego confirma y continua al diagnostico.
</p>
</CardHeader>
<CardContent className="space-y-5">
<div className="rounded-lg border border-[#dde5f2] bg-[#f8fbff] px-3 py-2 text-sm font-medium text-[#50607a]">
Progreso: {completion}%
</div>
{step === 1 ? (
<div className="space-y-4">
<div className="rounded-xl border border-[#dbe4f1] bg-[#f9fbff] p-4">
<p className="text-sm font-semibold text-[#2d3c57]">Documento requerido</p>
<p className="mt-1 text-sm text-[#5c6b86]">
Carga el archivo <span className="font-semibold">Acta constitutiva</span> en formato PDF. Este documento se guarda como llave de
referencia de tu empresa.
</p>
</div>
<div>
<Label htmlFor="acta-file">Archivo PDF</Label>
<Input
id="acta-file"
type="file"
accept="application/pdf,.pdf"
onChange={(event) => setSelectedActaFile(event.target.files?.[0] ?? null)}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button onClick={handleActaUpload} disabled={isUploadingActa || isSubmitting}>
{isUploadingActa ? "Procesando..." : actaReady ? "Reemplazar y reanalizar PDF" : "Subir y analizar Acta"}
</Button>
{selectedActaFile ? <p className="text-xs text-[#5e6c86]">Archivo: {selectedActaFile.name}</p> : null}
</div>
{actaReady ? (
<div className="rounded-lg border border-[#ccead8] bg-[#ebf8f0] px-3 py-2 text-sm text-[#206546]">
Acta guardada correctamente{lastActaUploadAt ? ` (${new Date(lastActaUploadAt).toLocaleString()})` : ""}.
</div>
) : null}
{extractConfidence ? (
<p className="text-xs text-[#5d6b86]">
Calidad de extraccion: <span className="font-semibold uppercase">{extractConfidence}</span>.
</p>
) : null}
{analysisMethod === "ocr" ? (
<p className="text-xs font-medium text-[#2f5d94]">OCR aplicado para recuperar texto de un PDF escaneado.</p>
) : null}
{extractionEngine === "ai" ? (
<p className="text-xs font-medium text-[#206546]">
Extraccion legal realizada con AI{aiModel ? ` (${aiModel})` : ""}.
</p>
) : null}
{extractionEngine === "regex_fallback" ? (
<p className="text-xs font-medium text-[#975f08]">
AI no estuvo disponible; se aplico extraccion de respaldo para no bloquear onboarding.
</p>
) : null}
{analysisWarnings.length ? (
<p className="text-xs text-[#60708b]">{analysisWarnings.join(" ")}</p>
) : null}
{latestFields ? (
<div className="rounded-xl border border-[#dbe4f1] bg-white p-4">
<p className="text-sm font-semibold text-[#2d3c57]">Campos extraidos</p>
<div className="mt-3 grid gap-3 text-sm md:grid-cols-2">
<div>
<p className="text-xs uppercase text-[#7886a1]">Razon social / nombre legal</p>
<p className="font-medium text-[#1f2a40]">{latestFields.name ?? "-"}</p>
</div>
<div>
<p className="text-xs uppercase text-[#7886a1]">RFC</p>
<p className="font-medium text-[#1f2a40]">{latestFields.rfc ?? "-"}</p>
</div>
<div>
<p className="text-xs uppercase text-[#7886a1]">Representante legal</p>
<p className="font-medium text-[#1f2a40]">{latestFields.legalRepresentative ?? "-"}</p>
</div>
<div>
<p className="text-xs uppercase text-[#7886a1]">Fecha de constitucion</p>
<p className="font-medium text-[#1f2a40]">{latestFields.incorporationDate ?? "-"}</p>
</div>
<div>
<p className="text-xs uppercase text-[#7886a1]">Numero de escritura</p>
<p className="font-medium text-[#1f2a40]">{latestFields.deedNumber ?? "-"}</p>
</div>
<div>
<p className="text-xs uppercase text-[#7886a1]">Notario</p>
<p className="font-medium text-[#1f2a40]">{latestFields.notaryName ?? "-"}</p>
</div>
<div className="md:col-span-2">
<p className="text-xs uppercase text-[#7886a1]">Domicilio fiscal</p>
<p className="font-medium text-[#1f2a40]">{latestFields.fiscalAddress ?? "-"}</p>
</div>
<div className="md:col-span-2">
<p className="text-xs uppercase text-[#7886a1]">Objeto social</p>
<p className="font-medium text-[#1f2a40]">{latestFields.businessPurpose ?? "-"}</p>
</div>
</div>
</div>
) : null}
{lookupDictionaryDebug ? (
<details className="rounded-xl border border-[#dbe4f1] bg-[#f8fbff] p-4">
<summary className="cursor-pointer text-sm font-semibold text-[#2d3c57]">Debug de extraccion</summary>
{detectedLookupFields.length ? (
<p className="mt-2 text-xs text-[#60708b]">Campos detectados en diccionario: {detectedLookupFields.join(", ")}.</p>
) : null}
<pre className="mt-3 overflow-x-auto rounded-lg bg-white p-3 text-xs text-[#33435f]">
{JSON.stringify(lookupDictionaryDebug, null, 2)}
</pre>
</details>
) : null}
</div>
) : null}
{step === 2 ? (
<div className="space-y-4">
{detectedFields.length ? (
<p className="text-xs text-[#60708b]">Campos detectados automaticamente: {detectedFields.join(", ")}.</p>
) : null}
<div className="grid gap-4 md:grid-cols-2">
<div>
<Label htmlFor="org-name">Razon social / nombre legal</Label>
<Input
id="org-name"
value={values.name}
onChange={(event) => updateValue("name", event.target.value)}
placeholder="Innova S.A. de C.V."
/>
</div>
<div>
<Label htmlFor="org-rfc">RFC</Label>
<Input id="org-rfc" value={values.rfc} onChange={(event) => updateValue("rfc", event.target.value)} placeholder="ABC123456T12" />
</div>
<div>
<Label htmlFor="org-representative">Representante legal</Label>
<Input
id="org-representative"
value={values.legalRepresentative}
onChange={(event) => updateValue("legalRepresentative", event.target.value)}
placeholder="Ana Torres"
/>
</div>
<div>
<Label htmlFor="org-inc-date">Fecha de constitucion</Label>
<Input
id="org-inc-date"
value={values.incorporationDate}
onChange={(event) => updateValue("incorporationDate", event.target.value)}
placeholder="15 de marzo de 2019"
/>
</div>
<div>
<Label htmlFor="org-deed">Numero de escritura</Label>
<Input
id="org-deed"
value={values.deedNumber}
onChange={(event) => updateValue("deedNumber", event.target.value)}
placeholder="12345"
/>
</div>
<div>
<Label htmlFor="org-notary">Notario</Label>
<Input
id="org-notary"
value={values.notaryName}
onChange={(event) => updateValue("notaryName", event.target.value)}
placeholder="Notario Publico No. 15"
/>
</div>
</div>
<div>
<Label htmlFor="org-address">Domicilio fiscal</Label>
<textarea
id="org-address"
value={values.fiscalAddress}
onChange={(event) => updateValue("fiscalAddress", event.target.value)}
rows={2}
className="w-full rounded-lg border border-[#cfd8e6] bg-white px-3 py-2 text-sm text-[#1f2a3d] placeholder:text-[#8c96ab] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0f2a5f]"
placeholder="Calle, numero, colonia, municipio, estado"
/>
</div>
<div>
<Label htmlFor="org-purpose">Objeto social</Label>
<textarea
id="org-purpose"
value={values.businessPurpose}
onChange={(event) => updateValue("businessPurpose", event.target.value)}
rows={3}
className="w-full rounded-lg border border-[#cfd8e6] bg-white px-3 py-2 text-sm text-[#1f2a3d] placeholder:text-[#8c96ab] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0f2a5f]"
placeholder="Actividad principal segun Acta constitutiva"
/>
</div>
</div>
) : null}
{step === 3 ? (
<div className="space-y-5">
<div className="grid gap-4 md:grid-cols-2">
<div>
<Label htmlFor="org-trade-name">Nombre comercial</Label>
<Input
id="org-trade-name"
value={values.tradeName}
onChange={(event) => updateValue("tradeName", event.target.value)}
placeholder="Treisole"
/>
</div>
<div>
<Label htmlFor="org-industry">Sector / Giro</Label>
<Input
id="org-industry"
value={values.industry}
onChange={(event) => updateValue("industry", event.target.value)}
placeholder="Alimentos y Bebidas"
/>
</div>
<div>
<Label htmlFor="org-state">Estado</Label>
<Input
id="org-state"
value={values.operatingState}
onChange={(event) => updateValue("operatingState", event.target.value)}
placeholder="Nuevo Leon"
/>
</div>
<div>
<Label htmlFor="org-municipality">Municipio</Label>
<Input
id="org-municipality"
value={values.municipality}
onChange={(event) => updateValue("municipality", event.target.value)}
placeholder="Monterrey"
/>
</div>
<div>
<Label htmlFor="org-size">Tamano de empresa</Label>
<select
id="org-size"
value={values.companySize}
onChange={(event) => updateValue("companySize", event.target.value)}
className="w-full rounded-lg border border-[#cfd8e6] bg-white px-3 py-2 text-sm text-[#1f2a3d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0f2a5f]"
>
<option value="">Selecciona un tamano</option>
{companySizeOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="org-years">Anos de operacion</Label>
<Input
id="org-years"
value={values.yearsOfOperation}
onChange={(event) => updateValue("yearsOfOperation", event.target.value)}
placeholder="7"
/>
</div>
<div>
<Label htmlFor="org-revenue">Facturacion anual</Label>
<select
id="org-revenue"
value={values.annualRevenueRange}
onChange={(event) => updateValue("annualRevenueRange", event.target.value)}
className="w-full rounded-lg border border-[#cfd8e6] bg-white px-3 py-2 text-sm text-[#1f2a3d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0f2a5f]"
>
<option value="">Selecciona un rango</option>
{annualRevenueRangeOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
</div>
<div>
<p className="text-sm font-medium text-[#2d3c57]">Has participado en licitaciones con gobierno?</p>
<div className="mt-2 flex gap-5">
<Label className="flex cursor-pointer items-center gap-2 text-sm text-[#1f2a3d]">
<input
type="radio"
name="government-contracts"
className="h-4 w-4 accent-[#0f2a5f]"
checked={values.hasGovernmentContracts === "yes"}
onChange={() => updateValue("hasGovernmentContracts", "yes")}
/>
Si
</Label>
<Label className="flex cursor-pointer items-center gap-2 text-sm text-[#1f2a3d]">
<input
type="radio"
name="government-contracts"
className="h-4 w-4 accent-[#0f2a5f]"
checked={values.hasGovernmentContracts === "no"}
onChange={() => updateValue("hasGovernmentContracts", "no")}
/>
No
</Label>
</div>
</div>
</div>
) : null}
{step === 4 ? (
<div className="space-y-3 rounded-xl border border-[#dde5f2] bg-[#fdfefe] p-4 text-sm text-[#52617b]">
<p className="font-semibold text-[#2b3a54]">Confirmacion final</p>
<p>
<span className="font-semibold">Acta cargada:</span> {actaReady ? "Si" : "No"}
</p>
<p>
<span className="font-semibold">Razon social:</span> {values.name || "-"}
</p>
<p>
<span className="font-semibold">Nombre comercial:</span> {values.tradeName || "-"}
</p>
<p>
<span className="font-semibold">RFC:</span> {values.rfc || "-"}
</p>
<p>
<span className="font-semibold">Representante legal:</span> {values.legalRepresentative || "-"}
</p>
<p>
<span className="font-semibold">Tamano de empresa:</span> {values.companySize || "-"}
</p>
<p>
<span className="font-semibold">Facturacion anual:</span> {values.annualRevenueRange || "-"}
</p>
<p>
<span className="font-semibold">Licitaciones con gobierno:</span>{" "}
{values.hasGovernmentContracts ? (values.hasGovernmentContracts === "yes" ? "Si" : "No") : "-"}
</p>
</div>
) : null}
{errorMessage ? (
<p className="rounded-lg border border-[#f6d0d0] bg-[#fff3f3] px-3 py-2 text-sm text-[#9d3030]">{errorMessage}</p>
) : null}
<div className="flex justify-between gap-2">
<Button variant="ghost" onClick={goBack} disabled={step === 1 || isSubmitting || isUploadingActa}>
Atras
</Button>
{step < steps.length ? (
<Button onClick={goNext} disabled={isSubmitting || isUploadingActa}>
Siguiente
</Button>
) : (
<Button onClick={handleSubmit} disabled={isSubmitting || isUploadingActa}>
{isSubmitting ? "Guardando..." : "Finalizar y continuar al diagnostico"}
</Button>
)}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,110 @@
import Link from "next/link";
import { isAdminIdentity } from "@/lib/auth/admin";
import { KontiaMark } from "@/components/app/kontia-mark";
import { getCurrentUser } from "@/lib/auth/user";
import { cn } from "@/lib/utils";
type PageShellProps = {
children: React.ReactNode;
title: string;
description?: string;
action?: React.ReactNode;
className?: string;
};
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" },
];
export async function PageShell({ children, title, description, action, className }: PageShellProps) {
const currentUser = await getCurrentUser();
const isAdmin = currentUser ? isAdminIdentity(currentUser.email, currentUser.role) : false;
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>
<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]">
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>
<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"
>
Logout
</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>
)}
</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>
<section className={cn("space-y-4", className)}>{children}</section>
</main>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
type QuestionCardProps = {
questionNumber: number;
question: string;
options: string[];
};
export function QuestionCard({ questionNumber, question, options }: QuestionCardProps) {
return (
<Card>
<CardHeader>
<p className="text-xs font-semibold uppercase tracking-wide text-[#5d6b84]">Pregunta {questionNumber}</p>
<h2 className="text-lg font-semibold text-[#1e2b45]">{question}</h2>
</CardHeader>
<CardContent className="space-y-3">
{options.map((option) => (
<Label
key={option}
className="flex cursor-pointer items-center gap-2 rounded-lg border border-[#dde5f2] px-3 py-2 font-medium text-[#3a4963] hover:border-[#b7c6e1]"
>
<input type="radio" name="question" className="h-4 w-4 accent-[#0f2a5f]" />
{option}
</Label>
))}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,42 @@
"use client";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import {
PolarAngleAxis,
PolarGrid,
Radar,
RadarChart,
ResponsiveContainer,
Tooltip,
} from "recharts";
type RadarDataPoint = {
module: string;
score: number;
};
type RadarChartCardProps = {
data: RadarDataPoint[];
};
export function RadarChartCard({ data }: RadarChartCardProps) {
return (
<Card>
<CardHeader>
<h3 className="text-base font-semibold text-[#22314b]">Mapa Radar por Modulo</h3>
</CardHeader>
<CardContent>
<div className="h-72 w-full">
<ResponsiveContainer>
<RadarChart data={data}>
<PolarGrid stroke="#dde5f2" />
<PolarAngleAxis dataKey="module" tick={{ fontSize: 12, fill: "#5f6b84" }} />
<Radar dataKey="score" stroke="#0f2a5f" fill="#2451a8" fillOpacity={0.25} />
<Tooltip />
</RadarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,26 @@
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
type ScoreCardProps = {
score: number;
};
export function ScoreCard({ score }: ScoreCardProps) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<p className="text-sm font-semibold text-[#5f6b84]">Puntaje Global</p>
<h2 className="text-3xl font-bold text-[#1f2b44]">{score}%</h2>
</div>
<Badge variant={score >= 70 ? "success" : score >= 50 ? "warning" : "neutral"}>
{score >= 70 ? "Rendimiento alto" : score >= 50 ? "Rendimiento medio" : "Rendimiento inicial"}
</Badge>
</CardHeader>
<CardContent>
<Progress value={score} showValue />
</CardContent>
</Card>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,438 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import type { TalleresSnapshot, WorkshopEvidenceView, WorkshopView } from "@/lib/talleres/types";
type TalleresDesarrolloViewProps = {
initialSnapshot: TalleresSnapshot;
initialDimension?: string | null;
};
type ApiResponse = {
ok?: boolean;
error?: string;
warning?: string;
payload?: TalleresSnapshot;
};
function statusTone(status: WorkshopView["status"]) {
if (status === "APPROVED") {
return "border-[#b6e5cd] bg-[#e7f8ef] text-[#1b8f62]";
}
if (status === "REJECTED") {
return "border-[#efc4c4] bg-[#fff0f0] text-[#a43f3f]";
}
if (status === "EVIDENCE_SUBMITTED") {
return "border-[#d5caef] bg-[#f3edff] text-[#6a4aa8]";
}
if (status === "WATCHED") {
return "border-[#c5d8f0] bg-[#ecf4ff] text-[#2f5ea4]";
}
if (status === "SKIPPED") {
return "border-[#d9dfea] bg-[#f3f6fb] text-[#5e6f8e]";
}
return "border-[#d9dfea] bg-[#f3f6fb] text-[#5e6f8e]";
}
function statusLabel(status: WorkshopView["status"]) {
if (status === "APPROVED") {
return "Aprobado";
}
if (status === "REJECTED") {
return "Rechazado";
}
if (status === "EVIDENCE_SUBMITTED") {
return "Evidencia enviada";
}
if (status === "WATCHED") {
return "Visto";
}
if (status === "SKIPPED") {
return "Lo vere despues";
}
return "Pendiente";
}
function evidenceTone(status: WorkshopEvidenceView["validationStatus"] | undefined) {
if (status === "APPROVED") {
return "text-[#1f865f]";
}
if (status === "REJECTED") {
return "text-[#a43f3f]";
}
if (status === "ERROR") {
return "text-[#8559bd]";
}
return "text-[#476088]";
}
function validationLabel(status: WorkshopEvidenceView["validationStatus"] | undefined) {
if (status === "APPROVED") {
return "Aprobada";
}
if (status === "REJECTED") {
return "Rechazada";
}
if (status === "ERROR") {
return "Pendiente por validar";
}
if (status === "PENDING") {
return "En validacion";
}
return "Sin evidencia";
}
export function TalleresDesarrolloView({ initialSnapshot, initialDimension }: TalleresDesarrolloViewProps) {
const [snapshot, setSnapshot] = useState<TalleresSnapshot>(initialSnapshot);
const [activeDimension, setActiveDimension] = useState<string>(() => {
const keySet = new Set(initialSnapshot.dimensions.map((dimension) => dimension.moduleKey));
if (initialDimension && keySet.has(initialDimension)) {
return initialDimension;
}
return initialSnapshot.dimensions[0]?.moduleKey ?? "";
});
const [activeWorkshop, setActiveWorkshop] = useState<WorkshopView | null>(null);
const [uploadingWorkshopId, setUploadingWorkshopId] = useState<string | null>(null);
const [isSavingProgress, setIsSavingProgress] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const activeDimensionMeta = snapshot.dimensions.find((dimension) => dimension.moduleKey === activeDimension) ?? null;
const workshops = snapshot.workshopsByDimension[activeDimension] ?? [];
useEffect(() => {
if (!activeWorkshop) {
return;
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setActiveWorkshop(null);
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [activeWorkshop]);
const dimensionsWithGapCount = useMemo(() => snapshot.dimensions.filter((dimension) => dimension.gapLevel !== "baja").length, [snapshot]);
function clearMessages() {
setErrorMessage(null);
setSuccessMessage(null);
}
function applyPayload(payload?: TalleresSnapshot) {
if (!payload) {
return;
}
setSnapshot(payload);
if (!payload.dimensions.some((dimension) => dimension.moduleKey === activeDimension)) {
setActiveDimension(payload.dimensions[0]?.moduleKey ?? "");
}
}
async function saveProgress(workshopId: string, action: "WATCHED" | "SKIPPED") {
clearMessages();
setIsSavingProgress(true);
try {
const response = await fetch("/api/talleres/progress", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ workshopId, action }),
});
const payload = (await response.json().catch(() => ({}))) as ApiResponse;
if (!response.ok || !payload.ok || !payload.payload) {
setErrorMessage(payload.error ?? "No fue posible actualizar el avance del taller.");
return;
}
applyPayload(payload.payload);
setSuccessMessage(action === "WATCHED" ? "Taller marcado como visto." : "Guardamos que lo veras despues.");
} catch {
setErrorMessage("No fue posible actualizar el avance del taller.");
} finally {
setIsSavingProgress(false);
}
}
async function uploadEvidence(workshopId: string, file: File) {
clearMessages();
setUploadingWorkshopId(workshopId);
try {
const formData = new FormData();
formData.append("workshopId", workshopId);
formData.append("file", file);
const response = await fetch("/api/talleres/evidence", {
method: "POST",
body: formData,
});
const payload = (await response.json().catch(() => ({}))) as ApiResponse;
if (!response.ok || !payload.ok || !payload.payload) {
setErrorMessage(payload.error ?? "No fue posible subir tu evidencia.");
return;
}
applyPayload(payload.payload);
setSuccessMessage(payload.warning ?? "Evidencia enviada correctamente.");
setActiveWorkshop(null);
} catch {
setErrorMessage("No fue posible subir tu evidencia.");
} finally {
setUploadingWorkshopId(null);
}
}
return (
<div className="space-y-5">
<div className="grid gap-3 md:grid-cols-3">
<Card>
<CardContent className="py-4">
<p className="text-sm text-[#5f7294]">Indice de Madurez</p>
<p className="mt-1 text-5xl font-semibold text-[#12274d]">{Math.round(snapshot.overallMaturity)}%</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<p className="text-sm text-[#5f7294]">Dimensiones con Brechas</p>
<p className="mt-1 text-5xl font-semibold text-[#12274d]">{dimensionsWithGapCount} de {snapshot.dimensions.length}</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<p className="text-sm text-[#5f7294]">Meta</p>
<p className="mt-1 text-5xl font-semibold text-[#1fa574]">100%</p>
</CardContent>
</Card>
</div>
<Card className="border-[#d8e2ef] bg-[#f7fafc]">
<CardContent className="py-4 text-sm text-[#304560]">
<p>
<span className="font-semibold text-[#18325f]">Como funciona?</span> Cada taller te ensena como cumplir un requisito especifico.
Al finalizar, sube tu evidencia (documento, imagen, etc.) y nuestra validacion automatica evaluara el archivo al instante.
</p>
</CardContent>
</Card>
<div className="flex flex-wrap gap-2">
{snapshot.dimensions.map((dimension) => (
<button
key={dimension.moduleKey}
type="button"
onClick={() => setActiveDimension(dimension.moduleKey)}
className={`rounded-full border px-4 py-2 text-sm font-semibold transition-colors ${
activeDimension === dimension.moduleKey
? "border-[#12306a] bg-[#12306a] text-white"
: "border-[#d2dbea] bg-white text-[#52678c] hover:border-[#9fb4d6]"
}`}
>
{dimension.moduleName} <span className="ml-2 rounded-full bg-[#26a879] px-2 py-0.5 text-white">{Math.round(dimension.displayScore)}%</span>
</button>
))}
</div>
<Card>
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-3xl font-semibold text-[#132447] [font-family:var(--font-display)]">{activeDimensionMeta?.moduleName ?? "Dimension"}</h3>
<p className="text-sm text-[#60718f]">{workshops.length} brechas por cubrir con talleres guiados.</p>
</div>
{activeDimensionMeta ? (
<span className="rounded-full border border-[#d3dced] bg-[#f6f8fc] px-3 py-1 text-sm font-semibold text-[#243b63]">
Puntaje: {Math.round(activeDimensionMeta.displayScore)}%
</span>
) : null}
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{workshops.map((workshop) => (
<article key={workshop.id} className="rounded-2xl border border-[#d7dfed] bg-white p-4">
<div className="flex flex-wrap items-start justify-between gap-2">
<h4 className="text-xl font-semibold text-[#152748]">{workshop.title}</h4>
<span className={`rounded-full border px-2 py-1 text-xs font-semibold ${statusTone(workshop.status)}`}>{statusLabel(workshop.status)}</span>
</div>
<p className="mt-1 text-sm text-[#607291]">Duracion: {workshop.durationMinutes} min</p>
<p className="mt-3 text-sm text-[#607291]">{workshop.summary}</p>
<div className="mt-3 rounded-xl border border-[#dbe3ef] bg-[#f9fbfe] p-3 text-sm text-[#314864]">
<p className="font-semibold text-[#173463]">Brecha a cubrir</p>
<p className="mt-1">Al completar este taller y subir evidencia, mejoraras tu puntaje en esta dimension.</p>
</div>
<div className="mt-3">
<p className="text-sm font-semibold text-[#173463]">Aprenderas:</p>
<ul className="mt-1 space-y-1 text-sm text-[#607291]">
{workshop.learningObjectives.map((objective) => (
<li key={objective}>- {objective}</li>
))}
</ul>
</div>
{workshop.latestEvidence ? (
<p className={`mt-3 text-xs font-semibold ${evidenceTone(workshop.latestEvidence.validationStatus)}`}>
Evidencia {validationLabel(workshop.latestEvidence.validationStatus)}: {workshop.latestEvidence.fileName}
</p>
) : null}
<div className="mt-4 space-y-2">
<Button
variant="secondary"
className="w-full"
onClick={() => {
setActiveWorkshop(workshop);
}}
>
Ver Taller
</Button>
<label className="block">
<input
type="file"
className="hidden"
accept=".pdf,.doc,.docx,.jpg,.jpeg,.png"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
void uploadEvidence(workshop.id, file);
}
event.target.value = "";
}}
/>
<span className="inline-flex h-10 w-full items-center justify-center rounded-lg bg-[#0f2a5f] px-4 text-sm font-semibold text-white transition-colors hover:bg-[#0c234e]">
{uploadingWorkshopId === workshop.id ? "Subiendo evidencia..." : "Subir Evidencia"}
</span>
</label>
</div>
</article>
))}
</CardContent>
</Card>
{errorMessage ? <p className="rounded-xl border border-[#efc4c4] bg-[#fff1f1] px-3 py-2 text-sm text-[#ad3f3f]">{errorMessage}</p> : null}
{successMessage ? <p className="rounded-xl border border-[#bde5ce] bg-[#ecf9f1] px-3 py-2 text-sm text-[#1f7f4f]">{successMessage}</p> : null}
{activeWorkshop ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-[#07142d]/55 p-4"
role="dialog"
aria-modal="true"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
setActiveWorkshop(null);
}
}}
>
<div className="max-h-[92vh] w-full max-w-5xl overflow-y-auto rounded-2xl bg-white p-4 md:p-6">
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<h4 className="text-3xl font-semibold text-[#12274d]">{activeWorkshop.title}</h4>
<p className="text-sm text-[#60718f]">{activeWorkshop.summary}</p>
</div>
<Button variant="secondary" onClick={() => setActiveWorkshop(null)}>
Cerrar
</Button>
</div>
<div className="overflow-hidden rounded-xl border border-[#d4dceb]">
<iframe
className="h-[320px] w-full md:h-[460px]"
src={activeWorkshop.videoUrl}
title={`Video de ${activeWorkshop.title}`}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
/>
</div>
<div className="mt-4 space-y-3">
<div className="rounded-xl border border-[#dce3ef] bg-[#f8fbfe] p-3">
<p className="font-semibold text-[#15305d]">Evidencia requerida:</p>
<p className="text-sm text-[#60718f]">{activeWorkshop.evidenceRequired}</p>
</div>
<div className="rounded-xl border border-[#c8d8f2] bg-[#eef5ff] p-3">
<p className="font-semibold text-[#1a3768]">Proceso para completar el taller:</p>
<ol className="mt-2 space-y-1 text-sm text-[#31507b]">
<li>1. Ve el video completo para entender los conceptos.</li>
<li>2. Aplica lo aprendido con tu equipo.</li>
<li>3. Prepara tu evidencia en PDF o imagen.</li>
<li>4. Sube tu evidencia para validacion automatica.</li>
</ol>
</div>
</div>
<div className="mt-4 grid gap-2 md:grid-cols-2">
<Button
disabled={isSavingProgress}
onClick={() => {
void saveProgress(activeWorkshop.id, "WATCHED");
}}
>
{isSavingProgress ? "Guardando..." : "Ya vi el video, quiero subir evidencia"}
</Button>
<Button
variant="secondary"
disabled={isSavingProgress}
onClick={() => {
void saveProgress(activeWorkshop.id, "SKIPPED");
setActiveWorkshop(null);
}}
>
Lo vere despues
</Button>
</div>
<label className="mt-2 block">
<input
type="file"
className="hidden"
accept=".pdf,.doc,.docx,.jpg,.jpeg,.png"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
void uploadEvidence(activeWorkshop.id, file);
}
event.target.value = "";
}}
/>
<span className="inline-flex h-10 w-full items-center justify-center rounded-lg bg-[#0f2a5f] px-4 text-sm font-semibold text-white transition-colors hover:bg-[#0c234e]">
{uploadingWorkshopId === activeWorkshop.id ? "Subiendo evidencia..." : "Subir evidencia ahora"}
</span>
</label>
</div>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
type AccordionItem = {
id: string;
title: string;
content: string;
};
type AccordionProps = {
items: AccordionItem[];
};
export function Accordion({ items }: AccordionProps) {
const [openItem, setOpenItem] = useState<string | null>(items[0]?.id ?? null);
return (
<div className="space-y-3">
{items.map((item) => {
const isOpen = item.id === openItem;
return (
<section key={item.id} className="overflow-hidden rounded-xl border border-[#dde5f1] bg-white">
<button
className="flex w-full items-center justify-between px-4 py-3 text-left"
onClick={() => setOpenItem(isOpen ? null : item.id)}
type="button"
>
<span className="font-semibold text-[#24324c]">{item.title}</span>
<span className={cn("text-sm font-semibold text-[#5f6c85]", isOpen ? "rotate-180" : "")}></span>
</button>
{isOpen ? <p className="border-t border-[#edf2f8] px-4 py-3 text-sm text-[#5f6c85]">{item.content}</p> : null}
</section>
);
})}
</div>
);
}

View File

@@ -0,0 +1,27 @@
import * as React from "react";
import { cn } from "@/lib/utils";
type BadgeVariant = "neutral" | "success" | "warning";
const variantClasses: Record<BadgeVariant, string> = {
neutral: "bg-[#eaf0fb] text-[#2e4c83]",
success: "bg-[#dff6e8] text-[#1c7a4a]",
warning: "bg-[#fff5dd] text-[#916400]",
};
export function Badge({
className,
variant = "neutral",
...props
}: React.HTMLAttributes<HTMLSpanElement> & { variant?: BadgeVariant }) {
return (
<span
className={cn(
"inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold tracking-wide",
variantClasses[variant],
className,
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,42 @@
import * as React from "react";
import { cn } from "@/lib/utils";
type ButtonVariant = "primary" | "secondary" | "ghost";
type ButtonSize = "sm" | "md" | "lg";
const variantClasses: Record<ButtonVariant, string> = {
primary: "bg-[#0f2a5f] text-white hover:bg-[#0c234e]",
secondary: "bg-white text-[#0f2a5f] border border-[#cdd7ea] hover:bg-[#f4f7fc]",
ghost: "bg-transparent text-[#33415c] hover:bg-[#edf2f9]",
};
const sizeClasses: Record<ButtonSize, string> = {
sm: "h-9 px-3 text-sm",
md: "h-10 px-4 text-sm",
lg: "h-11 px-5 text-base",
};
export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "primary", size = "md", type = "button", ...props }, ref) => {
return (
<button
ref={ref}
type={type}
className={cn(
"inline-flex items-center justify-center gap-2 rounded-lg font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0f2a5f] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60",
variantClasses[variant],
sizeClasses[size],
className,
)}
{...props}
/>
);
},
);
Button.displayName = "Button";

View File

@@ -0,0 +1,19 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("rounded-2xl border border-[#dde4f0] bg-white shadow-[0_1px_2px_rgba(16,24,40,0.06)]", className)}
{...props}
/>
);
}
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("space-y-1 border-b border-[#eef2f7] px-6 py-4", className)} {...props} />;
}
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("px-6 py-5", className)} {...props} />;
}

View File

@@ -0,0 +1,58 @@
"use client";
import { useId, useState } from "react";
import { Button } from "@/components/ui/button";
type DialogProps = {
triggerLabel: string;
title: string;
description?: string;
children: React.ReactNode;
};
export function Dialog({ triggerLabel, title, description, children }: DialogProps) {
const [open, setOpen] = useState(false);
const titleId = useId();
const descriptionId = useId();
return (
<>
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
{triggerLabel}
</Button>
{open ? (
<div className="fixed inset-0 z-50 grid place-items-center bg-[#0e1e3d]/40 px-4">
<div
className="w-full max-w-lg rounded-2xl bg-white p-6 shadow-xl"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
>
<div className="mb-4 flex items-start justify-between gap-4">
<div>
<h2 id={titleId} className="text-lg font-semibold text-[#1c2840]">
{title}
</h2>
{description ? (
<p id={descriptionId} className="mt-1 text-sm text-[#65718a]">
{description}
</p>
) : null}
</div>
<button
className="rounded-md px-2 py-1 text-sm text-[#5f6b84] hover:bg-[#eef2f8]"
onClick={() => setOpen(false)}
type="button"
>
Close
</button>
</div>
<div>{children}</div>
</div>
</div>
) : null}
</>
);
}

View File

@@ -0,0 +1,19 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, ...props }, ref) => {
return (
<input
ref={ref}
className={cn(
"h-10 w-full rounded-lg border border-[#cfd8e6] bg-white px-3 text-sm text-[#1f2a3d] placeholder:text-[#8c96ab] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0f2a5f]",
className,
)}
{...props}
/>
);
},
);
Input.displayName = "Input";

View File

@@ -0,0 +1,6 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export function Label({ className, ...props }: React.LabelHTMLAttributes<HTMLLabelElement>) {
return <label className={cn("mb-1.5 block text-sm font-semibold text-[#33415c]", className)} {...props} />;
}

View File

@@ -0,0 +1,27 @@
import { cn } from "@/lib/utils";
type ProgressProps = {
value: number;
className?: string;
showValue?: boolean;
};
export function Progress({ value, className, showValue = false }: ProgressProps) {
const normalizedValue = Math.max(0, Math.min(100, value));
return (
<div className={cn("space-y-1.5", className)}>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-[#e7edf7]">
<div
className="h-full rounded-full bg-[#2ca56f] transition-[width]"
style={{ width: `${normalizedValue}%` }}
aria-valuenow={normalizedValue}
aria-valuemin={0}
aria-valuemax={100}
role="progressbar"
/>
</div>
{showValue ? <p className="text-right text-xs font-semibold text-[#5e6a82]">{normalizedValue}%</p> : null}
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { cn } from "@/lib/utils";
type StepperProps = {
steps: string[];
currentStep: number;
};
export function Stepper({ steps, currentStep }: StepperProps) {
return (
<ol className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{steps.map((step, index) => {
const stepNumber = index + 1;
const isComplete = stepNumber < currentStep;
const isCurrent = stepNumber === currentStep;
return (
<li key={step} className="flex items-center gap-2 rounded-lg border border-[#e4eaf4] bg-white px-3 py-2">
<span
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold",
isComplete && "bg-[#2ca56f] text-white",
isCurrent && "bg-[#0f2a5f] text-white",
!isComplete && !isCurrent && "bg-[#edf1f9] text-[#63708a]",
)}
aria-hidden
>
{stepNumber}
</span>
<span className={cn("text-sm font-medium", isCurrent ? "text-[#0f2a5f]" : "text-[#5e6a82]")}>{step}</span>
</li>
);
})}
</ol>
);
}

View File

@@ -0,0 +1,46 @@
"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
type TabItem = {
id: string;
label: string;
content: React.ReactNode;
};
type TabsProps = {
items: TabItem[];
defaultTab?: string;
};
export function Tabs({ items, defaultTab }: TabsProps) {
const [activeTab, setActiveTab] = useState(defaultTab ?? items[0]?.id);
return (
<div>
<div className="mb-4 flex flex-wrap gap-2 rounded-xl bg-[#edf2fb] p-1">
{items.map((item) => (
<button
key={item.id}
className={cn(
"rounded-lg px-3 py-1.5 text-sm font-semibold transition-colors",
activeTab === item.id ? "bg-white text-[#0f2a5f] shadow-sm" : "text-[#6f7b93] hover:bg-white/70",
)}
onClick={() => setActiveTab(item.id)}
type="button"
>
{item.label}
</button>
))}
</div>
<div>
{items.map((item) => (
<div key={item.id} hidden={item.id !== activeTab}>
{item.id === activeTab ? item.content : null}
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { OverallScoreMethod } from "@prisma/client";
import { computeAssessmentSnapshot } from "@/lib/scoring-core";
describe("computeAssessmentSnapshot", () => {
it("computes module and overall scores from answered questions", () => {
const modules = [
{
moduleId: "m1",
moduleKey: "liderazgo",
moduleName: "Liderazgo",
questionWeights: [
{ questionId: "q1", maxWeight: 5 },
{ questionId: "q2", maxWeight: 5 },
],
},
{
moduleId: "m2",
moduleKey: "datos",
moduleName: "Datos",
questionWeights: [
{ questionId: "q3", maxWeight: 5 },
{ questionId: "q4", maxWeight: 5 },
],
},
];
const answers = new Map<string, number>([
["q1", 5],
["q2", 3],
["q3", 1],
]);
const snapshot = computeAssessmentSnapshot(modules, answers, {
overallScoreMethod: OverallScoreMethod.EQUAL_ALL_MODULES,
moduleWeights: {},
});
expect(snapshot.moduleScores).toHaveLength(2);
expect(snapshot.moduleScores[0]?.score).toBe(80);
expect(snapshot.moduleScores[0]?.completion).toBe(100);
expect(snapshot.moduleScores[0]?.status).toBe("Completado");
expect(snapshot.moduleScores[1]?.score).toBe(20);
expect(snapshot.moduleScores[1]?.completion).toBe(50);
expect(snapshot.moduleScores[1]?.status).toBe("En curso");
expect(snapshot.overallScore).toBe(50);
expect(snapshot.answeredQuestions).toBe(3);
expect(snapshot.totalQuestions).toBe(4);
expect(snapshot.strongestModule?.moduleKey).toBe("liderazgo");
expect(snapshot.weakestModule?.moduleKey).toBe("datos");
});
it("returns zero snapshot for empty modules", () => {
const snapshot = computeAssessmentSnapshot([], new Map<string, number>(), {
overallScoreMethod: OverallScoreMethod.EQUAL_ALL_MODULES,
moduleWeights: {},
});
expect(snapshot.overallScore).toBe(0);
expect(snapshot.moduleScores).toHaveLength(0);
expect(snapshot.strongestModule).toBeNull();
expect(snapshot.weakestModule).toBeNull();
});
});

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { createSessionTokenValue, verifySessionTokenValue } from "@/lib/auth/session-token";
describe("session token", () => {
it("creates and verifies a valid token", () => {
const token = createSessionTokenValue("user-1", "ana@empresa.com", "test-secret", 3600);
const payload = verifySessionTokenValue(token, "test-secret");
expect(payload).not.toBeNull();
expect(payload?.userId).toBe("user-1");
expect(payload?.email).toBe("ana@empresa.com");
});
it("rejects token with wrong secret", () => {
const token = createSessionTokenValue("user-1", "ana@empresa.com", "test-secret", 3600);
const payload = verifySessionTokenValue(token, "other-secret");
expect(payload).toBeNull();
});
it("rejects expired token", () => {
const token = createSessionTokenValue("user-1", "ana@empresa.com", "test-secret", -1);
const payload = verifySessionTokenValue(token, "test-secret");
expect(payload).toBeNull();
});
});

61
src/lib/auth/admin.ts Normal file
View File

@@ -0,0 +1,61 @@
import "server-only";
import { redirect } from "next/navigation";
import { UserRole } from "@prisma/client";
import { getSessionPayload } from "@/lib/auth/session";
import { getCurrentUser } from "@/lib/auth/user";
import { prisma } from "@/lib/prisma";
function parseAdminEmails() {
const raw = process.env.ADMIN_EMAILS ?? "";
return new Set(
raw
.split(",")
.map((email) => email.trim().toLowerCase())
.filter(Boolean),
);
}
export function isConfiguredAdminEmail(email: string) {
return parseAdminEmails().has(email.toLowerCase());
}
export function isAdminIdentity(email: string, role: UserRole) {
return role === UserRole.ADMIN || isConfiguredAdminEmail(email);
}
export async function requireAdminUser() {
const user = await getCurrentUser();
if (!user || !isAdminIdentity(user.email, user.role)) {
redirect("/dashboard?error=admin_required");
}
return user;
}
export async function requireAdminApiUser() {
const session = await getSessionPayload();
if (!session) {
return null;
}
const user = await prisma.user.findUnique({
where: { id: session.userId },
select: {
id: true,
email: true,
role: true,
name: true,
emailVerifiedAt: true,
},
});
if (!user || !isAdminIdentity(user.email, user.role)) {
return null;
}
return user;
}

View File

@@ -0,0 +1,9 @@
import "server-only";
export const SESSION_COOKIE_NAME = "assessment_session";
export const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7;
export const EMAIL_VERIFICATION_TTL_MS = 1000 * 60 * 60 * 24;
export function getSessionSecret() {
return process.env.SESSION_SECRET ?? "dev-only-change-me";
}

31
src/lib/auth/password.ts Normal file
View File

@@ -0,0 +1,31 @@
import "server-only";
import { randomBytes, scrypt as nodeScrypt, timingSafeEqual } from "node:crypto";
import { promisify } from "node:util";
const scrypt = promisify(nodeScrypt);
const KEY_LENGTH = 64;
export async function hashPassword(password: string) {
const salt = randomBytes(16).toString("hex");
const derivedKey = (await scrypt(password, salt, KEY_LENGTH)) as Buffer;
return `${salt}:${derivedKey.toString("hex")}`;
}
export async function verifyPassword(password: string, storedHash: string) {
const [salt, keyHex] = storedHash.split(":");
if (!salt || !keyHex) {
return false;
}
const expectedKey = Buffer.from(keyHex, "hex");
const actualKey = (await scrypt(password, salt, KEY_LENGTH)) as Buffer;
if (expectedKey.length !== actualKey.length) {
return false;
}
return timingSafeEqual(expectedKey, actualKey);
}

View File

@@ -0,0 +1,66 @@
import { createHmac, timingSafeEqual } from "node:crypto";
export type SessionPayload = {
userId: string;
email: string;
exp: number;
};
function signPayload(payloadBase64: string, secret: string) {
return createHmac("sha256", secret).update(payloadBase64).digest("base64url");
}
export function createSessionTokenValue(userId: string, email: string, secret: string, ttlSeconds: number) {
const payload: SessionPayload = {
userId,
email,
exp: Math.floor(Date.now() / 1000) + ttlSeconds,
};
const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString("base64url");
const signature = signPayload(payloadBase64, secret);
return `${payloadBase64}.${signature}`;
}
export function verifySessionTokenValue(token: string | undefined, secret: string) {
if (!token) {
return null;
}
const [payloadBase64, signature] = token.split(".");
if (!payloadBase64 || !signature) {
return null;
}
const expectedSignature = signPayload(payloadBase64, secret);
const providedSignature = Buffer.from(signature);
const expectedSignatureBuffer = Buffer.from(expectedSignature);
if (providedSignature.length !== expectedSignatureBuffer.length) {
return null;
}
if (!timingSafeEqual(providedSignature, expectedSignatureBuffer)) {
return null;
}
let payload: SessionPayload;
try {
payload = JSON.parse(Buffer.from(payloadBase64, "base64url").toString("utf8")) as SessionPayload;
} catch {
return null;
}
if (!payload.userId || !payload.email || !payload.exp) {
return null;
}
if (payload.exp < Math.floor(Date.now() / 1000)) {
return null;
}
return payload;
}

47
src/lib/auth/session.ts Normal file
View File

@@ -0,0 +1,47 @@
import "server-only";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { getSessionSecret, SESSION_COOKIE_NAME, SESSION_TTL_SECONDS } from "@/lib/auth/constants";
import { createSessionTokenValue, type SessionPayload, verifySessionTokenValue } from "@/lib/auth/session-token";
export type { SessionPayload } from "@/lib/auth/session-token";
export function createSessionToken(userId: string, email: string) {
return createSessionTokenValue(userId, email, getSessionSecret(), SESSION_TTL_SECONDS);
}
export function verifySessionToken(token: string | undefined): SessionPayload | null {
return verifySessionTokenValue(token, getSessionSecret());
}
export function setSessionCookie(response: NextResponse, token: string) {
response.cookies.set({
name: SESSION_COOKIE_NAME,
value: token,
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
maxAge: SESSION_TTL_SECONDS,
path: "/",
});
}
export function clearSessionCookie(response: NextResponse) {
response.cookies.set({
name: SESSION_COOKIE_NAME,
value: "",
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
maxAge: 0,
path: "/",
});
}
export async function getSessionPayload() {
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
return verifySessionToken(token);
}

101
src/lib/auth/user.ts Normal file
View File

@@ -0,0 +1,101 @@
import "server-only";
import { redirect } from "next/navigation";
import { OrganizationDocumentType, UserRole } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { getSessionPayload } from "@/lib/auth/session";
type UserRecord = {
id: string;
email: string;
name: string | null;
role: UserRole;
emailVerifiedAt: Date | null;
};
export async function getCurrentUser() {
const session = await getSessionPayload();
if (!session) {
return null;
}
try {
return await prisma.user.findUnique({
where: { id: session.userId },
select: {
id: true,
email: true,
name: true,
role: true,
emailVerifiedAt: true,
},
});
} catch {
return null;
}
}
export async function requireUser() {
const user = await getCurrentUser();
if (!user) {
redirect("/login?error=auth_required");
}
return user as UserRecord;
}
export async function hasOrganization(userId: string) {
const organization = await prisma.organization.findUnique({
where: { userId },
select: { id: true },
});
return Boolean(organization);
}
export async function hasCompletedOnboarding(userId: string) {
try {
const organization = await prisma.organization.findUnique({
where: { userId },
select: {
onboardingCompletedAt: true,
documents: {
where: {
type: OrganizationDocumentType.ACTA_CONSTITUTIVA,
},
select: {
id: true,
},
take: 1,
},
},
});
if (!organization?.onboardingCompletedAt) {
return false;
}
return organization.documents.length > 0;
} catch {
// Backward compatibility while migration is pending in some environments.
const legacyOrganization = await prisma.organization.findUnique({
where: { userId },
select: { id: true },
});
return Boolean(legacyOrganization);
}
}
export async function requireOnboardedUser() {
const user = await requireUser();
const onboarded = await hasCompletedOnboarding(user.id);
if (!onboarded) {
redirect("/onboarding");
}
return user;
}

View File

@@ -0,0 +1,113 @@
import "server-only";
import { randomBytes } from "node:crypto";
import { EMAIL_VERIFICATION_TTL_MS } from "@/lib/auth/constants";
import { sendEmail } from "@/lib/email/smtp";
import { buildAppUrl } from "@/lib/http/url";
import { prisma } from "@/lib/prisma";
export async function issueEmailVerificationToken(userId: string) {
const token = randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + EMAIL_VERIFICATION_TTL_MS);
await prisma.emailVerificationToken.create({
data: {
userId,
token,
expiresAt,
},
});
return { token, expiresAt };
}
export type SendVerificationResult = {
sent: boolean;
verificationUrl: string;
};
export async function sendEmailVerificationLink(request: Request, email: string, token: string) {
const verificationUrl = buildAppUrl(request, "/verify", { token }).toString();
try {
await sendEmail({
to: email,
subject: "Verifica tu correo en Kontia",
text: [
"Gracias por registrarte en Kontia.",
"",
"Para verificar tu correo y activar tu cuenta, abre este enlace:",
verificationUrl,
"",
"Si no solicitaste este registro, puedes ignorar este correo.",
].join("\n"),
html: [
"<p>Gracias por registrarte en <strong>Kontia</strong>.</p>",
"<p>Para verificar tu correo y activar tu cuenta, haz clic en el siguiente enlace:</p>",
`<p><a href="${verificationUrl}">Verificar mi correo</a></p>`,
"<p>Si no solicitaste este registro, puedes ignorar este correo.</p>",
].join(""),
});
if (process.env.NODE_ENV !== "production") {
console.log(`Verification email sent to ${email}: ${verificationUrl}`);
}
return {
sent: true,
verificationUrl,
} satisfies SendVerificationResult;
} catch (error) {
console.error("Failed to send verification email", error);
if (process.env.NODE_ENV !== "production") {
console.log(`Verification email fallback for ${email}: ${verificationUrl}`);
}
return {
sent: false,
verificationUrl,
} satisfies SendVerificationResult;
}
}
type ConsumeEmailTokenResult = {
status: "verified" | "expired_or_invalid";
email?: string;
};
export async function consumeEmailVerificationToken(token: string): Promise<ConsumeEmailTokenResult> {
const now = new Date();
const verificationToken = await prisma.emailVerificationToken.findUnique({
where: { token },
include: {
user: {
select: {
id: true,
email: true,
},
},
},
});
if (!verificationToken || verificationToken.consumedAt || verificationToken.expiresAt < now) {
return { status: "expired_or_invalid" };
}
await prisma.$transaction([
prisma.user.update({
where: { id: verificationToken.user.id },
data: { emailVerifiedAt: now },
}),
prisma.emailVerificationToken.update({
where: { id: verificationToken.id },
data: { consumedAt: now },
}),
]);
return {
status: "verified",
email: verificationToken.user.email,
};
}

52
src/lib/content-pages.ts Normal file
View File

@@ -0,0 +1,52 @@
import "server-only";
import { ContentPageType } from "@prisma/client";
import { prisma } from "@/lib/prisma";
export type ContentSectionItem = {
id: string;
title: string;
content: string;
};
export type ManualContentSnapshot = {
manualItems: ContentSectionItem[];
faqItems: ContentSectionItem[];
};
export async function getManualContentSnapshot(): Promise<ManualContentSnapshot> {
const pages = await prisma.contentPage.findMany({
where: {
isPublished: true,
type: {
in: [ContentPageType.MANUAL, ContentPageType.FAQ],
},
},
orderBy: [{ type: "asc" }, { sortOrder: "asc" }, { createdAt: "asc" }],
select: {
id: true,
type: true,
title: true,
content: true,
},
});
const manualItems: ContentSectionItem[] = [];
const faqItems: ContentSectionItem[] = [];
for (const page of pages) {
const item = {
id: page.id,
title: page.title,
content: page.content,
};
if (page.type === ContentPageType.MANUAL) {
manualItems.push(item);
} else {
faqItems.push(item);
}
}
return { manualItems, faqItems };
}

263
src/lib/diagnostic.ts Normal file
View File

@@ -0,0 +1,263 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
export type DiagnosticModuleProgress = {
key: string;
name: string;
completion: number;
answeredQuestions: number;
totalQuestions: number;
status: "Completado" | "En curso" | "Pendiente";
resumeQuestionIndex: number;
};
type DiagnosticQuestion = {
id: string;
prompt: string;
helpText: string | null;
sortOrder: number;
options: {
id: string;
label: string;
sortOrder: number;
}[];
selectedAnswerOptionId: string | null;
evidence: {
notes: string;
links: string[];
} | null;
};
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 isLegacyResponseEvidenceSchemaError(error: unknown) {
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
}
export async function getDiagnosticOverview(userId: string) {
const modules = await prisma.diagnosticModule.findMany({
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
include: {
questions: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
select: {
id: true,
},
},
},
});
const allQuestionIds = modules.flatMap((module) => module.questions.map((question) => question.id));
const responses = allQuestionIds.length
? await prisma.response.findMany({
where: {
userId,
questionId: {
in: allQuestionIds,
},
},
select: {
questionId: true,
},
})
: [];
const answeredQuestionIdSet = new Set(responses.map((response) => response.questionId));
const modulesWithProgress: DiagnosticModuleProgress[] = modules.map((module) => {
const questionIds = module.questions.map((question) => question.id);
const totalQuestions = questionIds.length;
const answeredQuestions = questionIds.reduce((total, questionId) => {
return total + (answeredQuestionIdSet.has(questionId) ? 1 : 0);
}, 0);
const completion = totalQuestions > 0 ? Math.round((answeredQuestions / totalQuestions) * 100) : 0;
let status: DiagnosticModuleProgress["status"] = "Pendiente";
if (completion >= 100 && totalQuestions > 0) {
status = "Completado";
} else if (completion > 0) {
status = "En curso";
}
const firstUnansweredIndex = questionIds.findIndex((questionId) => !answeredQuestionIdSet.has(questionId));
const resumeQuestionIndex = firstUnansweredIndex >= 0 ? firstUnansweredIndex + 1 : Math.max(totalQuestions, 1);
return {
key: module.key,
name: module.name,
completion,
answeredQuestions,
totalQuestions,
status,
resumeQuestionIndex,
};
});
const totalQuestions = modulesWithProgress.reduce((total, module) => total + module.totalQuestions, 0);
const answeredQuestions = modulesWithProgress.reduce((total, module) => total + module.answeredQuestions, 0);
const overallCompletion = totalQuestions > 0 ? Math.round((answeredQuestions / totalQuestions) * 100) : 0;
const activeResumeModule =
modulesWithProgress.find((module) => module.status === "En curso") ??
modulesWithProgress.find((module) => module.status === "Pendiente") ??
modulesWithProgress[0] ??
null;
const resumeHref = activeResumeModule
? `/diagnostic/${activeResumeModule.key}?q=${activeResumeModule.resumeQuestionIndex}`
: null;
return {
modules: modulesWithProgress,
stats: {
modules: modulesWithProgress.length,
completedModules: modulesWithProgress.filter((module) => module.status === "Completado").length,
overallCompletion,
answeredQuestions,
totalQuestions,
},
resumeHref,
};
}
export async function getDiagnosticModuleQuestions(userId: string, moduleKey: string) {
const moduleRecord = await prisma.diagnosticModule.findUnique({
where: { key: moduleKey },
select: {
id: true,
key: true,
name: true,
description: true,
questions: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
select: {
id: true,
prompt: true,
helpText: true,
sortOrder: true,
answerOptions: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
select: {
id: true,
label: true,
sortOrder: true,
},
},
},
},
},
});
if (!moduleRecord) {
return null;
}
const questionIds = moduleRecord.questions.map((question) => question.id);
let responses: {
questionId: string;
answerOptionId: string;
evidence?: unknown;
}[] = [];
if (questionIds.length) {
try {
responses = await prisma.response.findMany({
where: {
userId,
questionId: {
in: questionIds,
},
},
select: {
questionId: true,
answerOptionId: true,
evidence: true,
},
});
} catch (error) {
if (!isLegacyResponseEvidenceSchemaError(error)) {
throw error;
}
responses = await prisma.response.findMany({
where: {
userId,
questionId: {
in: questionIds,
},
},
select: {
questionId: true,
answerOptionId: true,
},
});
}
}
const answerByQuestionId = new Map(responses.map((response) => [response.questionId, response.answerOptionId]));
const evidenceByQuestionId = new Map(responses.map((response) => [response.questionId, parseResponseEvidence(response.evidence)]));
const questions: DiagnosticQuestion[] = moduleRecord.questions.map((question) => ({
id: question.id,
prompt: question.prompt,
helpText: question.helpText,
sortOrder: question.sortOrder,
options: question.answerOptions.map((option) => ({
id: option.id,
label: option.label,
sortOrder: option.sortOrder,
})),
selectedAnswerOptionId: answerByQuestionId.get(question.id) ?? null,
evidence: evidenceByQuestionId.get(question.id) ?? null,
}));
const answeredCount = questions.reduce((total, question) => total + (question.selectedAnswerOptionId ? 1 : 0), 0);
const totalQuestions = questions.length;
const completion = totalQuestions > 0 ? Math.round((answeredCount / totalQuestions) * 100) : 0;
const firstUnansweredQuestionIndex = questions.findIndex((question) => !question.selectedAnswerOptionId);
return {
module: {
key: moduleRecord.key,
name: moduleRecord.name,
description: moduleRecord.description,
},
questions,
progress: {
answeredCount,
totalQuestions,
completion,
defaultQuestionIndex: firstUnansweredQuestionIndex >= 0 ? firstUnansweredQuestionIndex + 1 : Math.max(totalQuestions, 1),
},
};
}

106
src/lib/email/smtp.ts Normal file
View File

@@ -0,0 +1,106 @@
import "server-only";
import nodemailer from "nodemailer";
type SendEmailInput = {
to: string;
subject: string;
text: string;
html: string;
};
type SmtpConfig = {
host: string;
port: number;
secure: boolean;
user: string;
pass: string;
from: string;
connectionTimeoutMs: number;
greetingTimeoutMs: number;
socketTimeoutMs: number;
};
let transporter: nodemailer.Transporter | null = null;
function asBoolean(value: string | undefined, fallback: boolean) {
if (!value) {
return fallback;
}
const normalized = value.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes";
}
function asPositiveInt(value: string | undefined, fallback: number) {
const parsed = Number.parseInt(value?.trim() ?? "", 10);
if (Number.isNaN(parsed) || parsed <= 0) {
return fallback;
}
return parsed;
}
function getSmtpConfig(): SmtpConfig | null {
const host = process.env.SMTP_HOST?.trim();
const portRaw = process.env.SMTP_PORT?.trim() || "587";
const port = Number.parseInt(portRaw, 10);
const user = process.env.SMTP_USER?.trim();
const pass = process.env.SMTP_PASS?.trim() || process.env.SMTP_PASSWORD?.trim();
const from = process.env.SMTP_FROM?.trim();
if (!host || !port || Number.isNaN(port) || !user || !pass || !from) {
return null;
}
// Port 465 requires TLS from the beginning of the connection.
// Keep SMTP_SECURE for 587/STARTTLS, but force secure mode on 465 to avoid hangs.
const secure = port === 465 ? true : asBoolean(process.env.SMTP_SECURE, false);
const connectionTimeoutMs = asPositiveInt(process.env.SMTP_CONNECTION_TIMEOUT_MS, 10000);
const greetingTimeoutMs = asPositiveInt(process.env.SMTP_GREETING_TIMEOUT_MS, 10000);
const socketTimeoutMs = asPositiveInt(process.env.SMTP_SOCKET_TIMEOUT_MS, 20000);
return { host, port, secure, user, pass, from, connectionTimeoutMs, greetingTimeoutMs, socketTimeoutMs };
}
function getTransporter(config: SmtpConfig) {
if (!transporter) {
transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
connectionTimeout: config.connectionTimeoutMs,
greetingTimeout: config.greetingTimeoutMs,
socketTimeout: config.socketTimeoutMs,
auth: {
user: config.user,
pass: config.pass,
},
});
}
return transporter;
}
export function isSmtpConfigured() {
return Boolean(getSmtpConfig());
}
export async function sendEmail(input: SendEmailInput) {
const config = getSmtpConfig();
if (!config) {
throw new Error("SMTP is not configured. Required env vars: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM.");
}
const smtpTransporter = getTransporter(config);
await smtpTransporter.sendMail({
from: config.from,
to: input.to,
subject: input.subject,
text: input.text,
html: input.html,
});
}

View File

@@ -0,0 +1,132 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { extractActaLookupDictionary, mapLookupDictionaryToActaFields } from "@/lib/extraction/extractFields";
import { extractActaDataWithAiBaseline } from "@/lib/extraction/aiExtractFields";
const SAMPLE_TEXT = `
Escritura publica numero 7789.
En la ciudad de Monterrey, Nuevo Leon.
La denominacion social sera: Comercializadora Delta S.A. de C.V.
Objeto social: Comercializacion e importacion de equipo industrial.
Notario Publico numero 10 del estado de Nuevo Leon, Lic. Carlos Mendoza.
`;
describe("extractActaDataWithAiBaseline", () => {
const originalApiKey = process.env.OPENAI_API_KEY;
const originalModel = process.env.OPENAI_ACTA_MODEL;
afterEach(() => {
process.env.OPENAI_API_KEY = originalApiKey;
process.env.OPENAI_ACTA_MODEL = originalModel;
vi.unstubAllGlobals();
});
it("uses AI as primary engine when OpenAI returns valid JSON", async () => {
process.env.OPENAI_API_KEY = "test-key";
process.env.OPENAI_ACTA_MODEL = "gpt-test-model";
const lookupDictionary = extractActaLookupDictionary(SAMPLE_TEXT);
const fields = mapLookupDictionaryToActaFields(lookupDictionary);
vi.stubGlobal(
"fetch",
vi.fn(async () =>
new Response(
JSON.stringify({
model: "gpt-test-model",
usage: {
prompt_tokens: 111,
completion_tokens: 222,
total_tokens: 333,
},
choices: [
{
message: {
content: JSON.stringify({
fields,
lookupDictionary,
}),
},
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
),
);
const result = await extractActaDataWithAiBaseline(SAMPLE_TEXT);
expect(result.engine).toBe("ai");
expect(result.warnings).toEqual([]);
expect(result.fields.name).toBe(fields.name);
expect(result.lookupDictionary.instrumentNumber).toBe(lookupDictionary.instrumentNumber);
expect(result.usage?.totalTokens).toBe(333);
});
it("normalizes structured AI field values (e.g. object addresses) without fallback", async () => {
process.env.OPENAI_API_KEY = "test-key";
vi.stubGlobal(
"fetch",
vi.fn(async () =>
new Response(
JSON.stringify({
model: "gpt-test-model",
choices: [
{
message: {
content: JSON.stringify({
fields: {
name: "TREISOLE",
rfc: "SHOULD_BE_IGNORED",
legalRepresentative: "MARCELO DARES BALLI",
incorporationDate: "2025-04-04",
deedNumber: null,
notaryName: null,
fiscalAddress: {
calle: "Lazaro Garza Ayala",
numeroExterior: "205",
entidadFederativa: "NUEVO LEON",
},
businessPurpose: "Comercio al por menor",
stateOfIncorporation: "NUEVO LEON",
},
lookupDictionary: {
version: "mx_acta_constitutiva_reference_v1",
},
}),
},
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
),
);
const result = await extractActaDataWithAiBaseline(SAMPLE_TEXT);
expect(result.engine).toBe("ai");
expect(result.fields.rfc).toBeNull();
expect(result.fields.fiscalAddress).toContain("Lazaro Garza Ayala");
expect(result.fields.fiscalAddress).toContain("205");
});
it("falls back to regex extraction when AI request fails", async () => {
process.env.OPENAI_API_KEY = "test-key";
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("network error");
}),
);
const result = await extractActaDataWithAiBaseline(SAMPLE_TEXT);
expect(result.engine).toBe("regex_fallback");
expect(result.warnings[0]).toContain("AI extraction fallo");
expect(result.fields.name).toBe("Comercializadora Delta S.A. de C.V");
expect(result.lookupDictionary.notaryNumber).toBe("10");
});
});

View File

@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import { extractActaLookupDictionary, extractFields } from "@/lib/extraction/extractFields";
describe("extractFields", () => {
it("extracts legal fields from mixed capitalization and punctuation", () => {
const text = `
Razon social: Treisole S.A. de C.V.
R.F.C.: TRE-240101-AB1
Fecha de constitucion: 15 de Marzo de 2019
Escritura publica No. 12345
Representante legal: Ana Torres Ramirez
Notario Publico: Lic. Carlos Mendoza
Domicilio fiscal:
Av. Juarez 123, Centro, Monterrey, Nuevo Leon
Objeto social:
Comercializacion de alimentos y bebidas, importacion y distribucion.
Entidad federativa: Nuevo Leon
`;
const result = extractFields(text);
expect(result.name).toBe("Treisole S.A. de C.V");
expect(result.rfc).toBeNull();
expect(result.legalRepresentative).toContain("Ana Torres");
expect(result.incorporationDate).toContain("15 de Marzo de 2019");
expect(result.deedNumber).toBe("12345");
expect(result.notaryName).toContain("Carlos Mendoza");
expect(result.fiscalAddress).toContain("Monterrey");
expect(result.businessPurpose).toContain("Comercializacion");
expect(result.stateOfIncorporation).toContain("Nuevo Leon");
});
it("avoids overcapturing legal name on sas contract headers and keeps RFC empty", () => {
const text = `
SAS2025858008 Denominación- TREISOLE Contrato Social de Sociedad por Acciones Simplificada
Contrato Social de Sociedad por Acciones Simplificada que celebran MARCELO DARES BALLI.
Primera. Denominación. La sociedad se denominará TREISOLE.
Tercera. Domicilio. El domicilio de la sociedad será el ubicado en CALLE LAZARO GARZA AYALA NÚMERO EXTERIOR 205
COLONIA TAMPIQUITO LOCALIDAD SAN PEDRO GARZA GARCIA MUNICIPIO SAN PEDRO GARZA GARCIA ENTIDAD FEDERATIVA NUEVO LEÓN.
Cuarta. Duración. La duración de la sociedad será Indefinida.
Quinta. Capital social El capital social fijo es la cantidad de 960 pesos.
La porción variable del capital social es la cantidad de 40 pesos.
Fecha de constitución: 25-04-04
RFC del socio: DABM030731AU7
`;
const result = extractFields(text);
const dictionary = extractActaLookupDictionary(text);
expect(result.name).toBe("TREISOLE");
expect(result.rfc).toBeNull();
expect(result.incorporationDate).toContain("25-04-04");
expect(result.deedNumber).toBe("SAS2025858008");
expect(result.fiscalAddress).toContain("SAN PEDRO GARZA GARCIA");
expect(result.stateOfIncorporation).toContain("NUEVO LEÓN");
expect(dictionary.capitalFixed).toContain("960");
expect(dictionary.capitalVariable).toContain("40");
});
it("builds an extended lookup dictionary for company profile storage", () => {
const text = `
Escritura publica numero 7789.
Tomo: 44.
En la ciudad de Monterrey, Nuevo Leon, a los 12 de abril de 2022.
La denominacion social sera: Comercializadora Delta S.A. de C.V.
Duracion: indefinida.
Capital social asciende a $100,000.00 M.N.
Capital fijo: $50,000.00
Capital variable: $50,000.00
Valor nominal: $1,000.00
Objeto social:
Comercializacion e importacion de equipo industrial.
Notario Publico numero 10 del estado de Nuevo Leon, Lic. Carlos Mendoza.
Clausula de extranjeros: se consideraran como nacionales y no invocar la proteccion de su gobierno.
`;
const result = extractActaLookupDictionary(text);
expect(result.instrumentNumber).toBe("7789");
expect(result.protocolVolumeBook).toBe("44");
expect(result.placeOfGranting).toContain("Monterrey");
expect(result.companyType).toBe("SA_DE_CV");
expect(result.capitalTotal).toContain("100,000.00");
expect(result.notaryNumber).toBe("10");
expect(result.foreignersClause).toBe("CLAUSULA_CALVO");
expect(result.derivedFields.isCompanyValidlyConstituted).toBe(true);
});
it("leaves missing fields as null", () => {
const result = extractFields("Acta sin datos suficientes");
expect(result.rfc).toBeNull();
expect(result.notaryName).toBeNull();
});
});

View File

@@ -0,0 +1,386 @@
import { z } from "zod";
import { extractActaLookupDictionary, mapLookupDictionaryToActaFields } from "@/lib/extraction/extractFields";
import {
ActaAdministrationTypeSchema,
ActaFieldsSchema,
ActaForeignersClauseSchema,
ActaLookupCompanyTypeSchema,
ActaLookupDictionarySchema,
type ActaFields,
type ActaLookupDictionary,
} from "@/lib/extraction/schema";
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 ActaAiResponseSchema = z.object({
fields: z.record(z.string(), z.unknown()).nullable().optional(),
lookupDictionary: z.record(z.string(), z.unknown()).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;
};
};
export type ActaAiExtractionEngine = "ai" | "regex_fallback";
export type ActaAiExtractionResult = {
fields: ActaFields;
lookupDictionary: ActaLookupDictionary;
engine: ActaAiExtractionEngine;
warnings: string[];
model: string | null;
usage:
| {
promptTokens: number | null;
completionTokens: number | null;
totalTokens: number | null;
}
| null;
};
function parsePositiveInteger(value: string | undefined, fallback: number) {
const parsed = Number.parseInt((value ?? "").trim(), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function clampActaText(fullText: string) {
const maxChars = parsePositiveInteger(process.env.OPENAI_ACTA_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(rawContent: string) {
const trimmed = rawContent.trim();
try {
return JSON.parse(trimmed) as unknown;
} catch {
// Continue with secondary parsing strategies.
}
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 AI no contiene un JSON valido.");
}
function getErrorMessage(error: unknown) {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function compactString(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 toStringOrNull(value: unknown, maxLength = 600): string | null {
if (value === null || value === undefined) {
return null;
}
if (typeof value === "string") {
return compactString(value, maxLength);
}
if (typeof value === "number" || typeof value === "boolean") {
return compactString(String(value), maxLength);
}
if (Array.isArray(value)) {
const joined = value
.map((item) => toStringOrNull(item, maxLength))
.filter((item): item is string => Boolean(item))
.join(", ");
return compactString(joined, maxLength);
}
if (isRecord(value)) {
const joined = Object.values(value)
.map((item) => toStringOrNull(item, maxLength))
.filter((item): item is string => Boolean(item))
.join(", ");
return compactString(joined, maxLength);
}
return null;
}
type ParseResultSuccess<T> = { success: true; data: T };
type ParseResultFailure = { success: false };
type EnumLikeSchema<T extends string> = {
safeParse: (value: unknown) => ParseResultSuccess<T> | ParseResultFailure;
};
function parseOptionalEnum<T extends string>(schema: EnumLikeSchema<T>, raw: unknown): T | null {
const normalized = toStringOrNull(raw, 80);
if (!normalized) {
return null;
}
const parsed = schema.safeParse(normalized);
return parsed.success ? parsed.data : null;
}
function normalizeAiFields(rawFields: unknown, fallback: ActaFields): ActaFields {
if (!isRecord(rawFields)) {
return fallback;
}
return ActaFieldsSchema.parse({
name: toStringOrNull(rawFields.name, 220),
rfc: null,
legalRepresentative: toStringOrNull(rawFields.legalRepresentative, 220),
incorporationDate: toStringOrNull(rawFields.incorporationDate, 120),
deedNumber: toStringOrNull(rawFields.deedNumber, 80),
notaryName: toStringOrNull(rawFields.notaryName, 220),
fiscalAddress: toStringOrNull(rawFields.fiscalAddress, 800),
businessPurpose: toStringOrNull(rawFields.businessPurpose, 1200),
stateOfIncorporation: toStringOrNull(rawFields.stateOfIncorporation, 120),
});
}
function normalizeAiLookupDictionary(rawLookup: unknown, fallback: ActaLookupDictionary): ActaLookupDictionary {
if (!isRecord(rawLookup)) {
return fallback;
}
const candidate: ActaLookupDictionary = {
...fallback,
version: "mx_acta_constitutiva_reference_v1",
instrumentType: toStringOrNull(rawLookup.instrumentType, 220),
instrumentNumber: toStringOrNull(rawLookup.instrumentNumber, 120),
protocolVolumeBook: toStringOrNull(rawLookup.protocolVolumeBook, 120),
instrumentDate: toStringOrNull(rawLookup.instrumentDate, 160),
placeOfGranting: toStringOrNull(rawLookup.placeOfGranting, 220),
notaryName: toStringOrNull(rawLookup.notaryName, 220),
notaryNumber: toStringOrNull(rawLookup.notaryNumber, 40),
notaryState: toStringOrNull(rawLookup.notaryState, 120),
legalName: toStringOrNull(rawLookup.legalName, 220),
normalizedName: toStringOrNull(rawLookup.normalizedName, 220),
companyType: parseOptionalEnum(ActaLookupCompanyTypeSchema, rawLookup.companyType),
domicile: toStringOrNull(rawLookup.domicile, 800),
duration: toStringOrNull(rawLookup.duration, 160),
corporatePurpose: toStringOrNull(rawLookup.corporatePurpose, 1200),
industry: toStringOrNull(rawLookup.industry, 220),
countryOfOperation: toStringOrNull(rawLookup.countryOfOperation, 120),
stateOfIncorporation: toStringOrNull(rawLookup.stateOfIncorporation, 120),
legalRepresentative: toStringOrNull(rawLookup.legalRepresentative, 220),
rfcCompany: toStringOrNull(rawLookup.rfcCompany, 40),
capitalTotal: toStringOrNull(rawLookup.capitalTotal, 120),
capitalFixed: toStringOrNull(rawLookup.capitalFixed, 120),
capitalVariable: toStringOrNull(rawLookup.capitalVariable, 120),
shareNominalValue: toStringOrNull(rawLookup.shareNominalValue, 120),
administrationType: parseOptionalEnum(ActaAdministrationTypeSchema, rawLookup.administrationType),
foreignersClause: parseOptionalEnum(ActaForeignersClauseSchema, rawLookup.foreignersClause) ?? "NO_DETECTADO",
sreNameAuthorization: toStringOrNull(rawLookup.sreNameAuthorization, 220),
rpcReference: (() => {
if (!isRecord(rawLookup.rpcReference)) {
return null;
}
return {
folioMercantil: toStringOrNull(rawLookup.rpcReference.folioMercantil, 80),
registroPublico: toStringOrNull(rawLookup.rpcReference.registroPublico, 120),
inscriptionDate: toStringOrNull(rawLookup.rpcReference.inscriptionDate, 120),
};
})(),
derivedFields: (() => {
const source = isRecord(rawLookup.derivedFields) ? rawLookup.derivedFields : {};
const rawIsConstituted = source.isCompanyValidlyConstituted;
const isCompanyValidlyConstituted =
typeof rawIsConstituted === "boolean" ? rawIsConstituted : fallback.derivedFields.isCompanyValidlyConstituted;
const keySignersCandidates = Array.isArray(source.keySignersCandidates)
? source.keySignersCandidates
.map((value) => toStringOrNull(value, 120))
.filter((value): value is string => Boolean(value))
: fallback.derivedFields.keySignersCandidates;
return {
isCompanyValidlyConstituted,
keySignersCandidates: keySignersCandidates && keySignersCandidates.length > 0 ? keySignersCandidates : null,
};
})(),
};
const parsed = ActaLookupDictionarySchema.safeParse(candidate);
return parsed.success ? parsed.data : fallback;
}
function getOpenAiApiKey() {
return (
process.env.OPENAI_API_KEY?.trim() ||
process.env.API_KEY?.trim() ||
process.env.api_key?.trim() ||
""
);
}
async function callOpenAiActaExtraction(fullText: string) {
const apiKey = getOpenAiApiKey();
if (!apiKey) {
throw new Error("No se encontro API key para OpenAI (OPENAI_API_KEY o api_key).");
}
const model = process.env.OPENAI_ACTA_MODEL?.trim() || DEFAULT_OPENAI_MODEL;
const timeoutMs = parsePositiveInteger(process.env.OPENAI_ACTA_TIMEOUT_MS, DEFAULT_TIMEOUT_MS);
const baseUrl = (process.env.OPENAI_API_BASE_URL?.trim() || DEFAULT_OPENAI_BASE_URL).replace(/\/+$/, "");
const inputText = clampActaText(fullText);
const systemPrompt = [
"Eres un analista legal experto en actas constitutivas mexicanas.",
"Debes extraer datos solo del texto proporcionado.",
"Responde exclusivamente JSON valido, sin markdown ni explicaciones.",
"Si un dato no aparece de forma clara, usa null.",
"No inventes RFC de empresa; rfc en fields debe ser null.",
"lookupDictionary.version debe ser exactamente 'mx_acta_constitutiva_reference_v1'.",
].join(" ");
const userPrompt = [
"Extrae y devuelve este objeto JSON con dos claves: fields y lookupDictionary.",
"fields debe seguir los campos de onboarding legal (name, rfc, legalRepresentative, incorporationDate, deedNumber, notaryName, fiscalAddress, businessPurpose, stateOfIncorporation).",
"lookupDictionary debe seguir la estructura completa del diccionario de acta constitutiva.",
"Texto de acta:",
inputText,
].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) {
const apiError = payload.error?.message ? ` ${payload.error.message}` : "";
throw new Error(`OpenAI devolvio ${response.status}.${apiError}`);
}
const rawContent = payload.choices?.[0]?.message?.content;
if (!rawContent || typeof rawContent !== "string") {
throw new Error("OpenAI no devolvio contenido util.");
}
const parsedContent = extractJsonObject(rawContent);
const parsed = ActaAiResponseSchema.parse(parsedContent);
if (!parsed.fields && !parsed.lookupDictionary) {
throw new Error("OpenAI no devolvio las claves esperadas: fields/lookupDictionary.");
}
const regexLookup = extractActaLookupDictionary(fullText);
const regexFields = mapLookupDictionaryToActaFields(regexLookup);
const normalizedFields = normalizeAiFields(parsed.fields, regexFields);
const normalizedLookup = normalizeAiLookupDictionary(parsed.lookupDictionary, regexLookup);
return {
fields: normalizedFields,
lookupDictionary: normalizedLookup,
model: payload.model ?? model,
usage: {
promptTokens: payload.usage?.prompt_tokens ?? null,
completionTokens: payload.usage?.completion_tokens ?? null,
totalTokens: payload.usage?.total_tokens ?? null,
},
};
} finally {
clearTimeout(timer);
}
}
function buildRegexFallback(fullText: string, warning: string): ActaAiExtractionResult {
const lookupDictionary = extractActaLookupDictionary(fullText);
const fields = mapLookupDictionaryToActaFields(lookupDictionary);
return {
fields,
lookupDictionary,
engine: "regex_fallback",
warnings: [warning],
model: process.env.OPENAI_ACTA_MODEL?.trim() || DEFAULT_OPENAI_MODEL,
usage: null,
};
}
export async function extractActaDataWithAiBaseline(fullText: string): Promise<ActaAiExtractionResult> {
try {
const ai = await callOpenAiActaExtraction(fullText);
return {
fields: ai.fields,
lookupDictionary: ai.lookupDictionary,
engine: "ai",
warnings: [],
model: ai.model,
usage: ai.usage,
};
} catch (error) {
const reason = getErrorMessage(error);
return buildRegexFallback(fullText, `AI extraction fallo y se uso respaldo regex. Detalle: ${reason}`);
}
}

View File

@@ -0,0 +1,789 @@
import {
ActaFieldsSchema,
ActaLookupDictionarySchema,
type ActaAdministrationType,
type ActaFields,
type ActaForeignersClause,
type ActaLookupCompanyType,
type ActaLookupDictionary,
} from "@/lib/extraction/schema";
const LABEL_HINTS = [
"DENOMINACION",
"RAZON SOCIAL",
"NOMBRE COMERCIAL",
"RFC",
"REPRESENTANTE LEGAL",
"APODERADO LEGAL",
"FECHA DE CONSTITUCION",
"CONSTITUIDA",
"ESCRITURA",
"INSTRUMENTO",
"NOTARIO",
"NOTARIA",
"CORREDOR PUBLICO",
"DOMICILIO FISCAL",
"DOMICILIO SOCIAL",
"OBJETO SOCIAL",
"ENTIDAD FEDERATIVA",
"ESTADO",
"CLAUSULA",
"PRIMERA",
"SEGUNDA",
"TERCERA",
"CUARTA",
"QUINTA",
"SEXTA",
"SEPTIMA",
"OCTAVA",
"NOVENA",
"DECIMA",
];
const CLAUSE_START_PATTERN = /^(CL[AÁ]USULA|PRIMERA|SEGUNDA|TERCERA|CUARTA|QUINTA|SEXTA|S[EÉ]PTIMA|OCTAVA|NOVENA|D[EÉ]CIMA|UND[EÉ]CIMA)\b/i;
function cleanValue(value: string | null | undefined, maxLength = 420) {
if (!value) {
return null;
}
const compact = value
.replace(/^[\s:;,\-.]+/, "")
.replace(/\s+/g, " ")
.trim();
if (!compact) {
return null;
}
return compact.length > maxLength ? compact.slice(0, maxLength) : compact;
}
function normalizeSearchText(value: string) {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toUpperCase()
.replace(/[^A-Z0-9 ]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function extractByPatterns(text: string, patterns: RegExp[]) {
for (const pattern of patterns) {
const match = text.match(pattern);
if (!match?.[1]) {
continue;
}
const value = cleanValue(match[1]);
if (value) {
return value;
}
}
return null;
}
function sanitizeLegalNameCandidate(value: string | null) {
if (!value) {
return null;
}
let cleaned = value
.replace(/^[A-Z]{2,6}\d{4,}\s+/i, "")
.replace(/^(LA\s+SOCIEDAD\s+SE\s+DENOMINAR[ÁA]\s*)/i, "")
.replace(/^DENOMINACI[OÓ]N\s*[-:]\s*/i, "")
.replace(/^(DENOMINACI[OÓ]N|RAZ[OÓ]N\s+SOCIAL)\s*[:\-]?\s*/i, "")
.replace(/["'“”]+/g, "")
.replace(/\s+/g, " ")
.trim();
const stopMatch = cleaned.match(
/\b(CONTRATO\s+SOCIAL|DECLARACIONES|CLAUSULA|CL[AÁ]USULA|PRIMERA|SEGUNDA|TERCERA|CUARTA|QUINTA|SEXTA|S[EÉ]PTIMA|OCTAVA|NOVENA|D[EÉ]CIMA|UND[EÉ]CIMA|QUE\s+CELEBRAN)\b/i,
);
if (stopMatch && typeof stopMatch.index === "number" && stopMatch.index > 0) {
cleaned = cleaned.slice(0, stopMatch.index).trim();
}
cleaned = cleaned.replace(/^[\s:;,\-.]+/, "").replace(/[\s:;,\-.]+$/, "");
if (!cleaned || cleaned.length < 2 || cleaned.length > 120) {
return null;
}
const letters = cleaned.match(/[A-Za-zÁÉÍÓÚÑáéíóúñ]/g)?.length ?? 0;
if (letters < 2) {
return null;
}
return cleanValue(cleaned, 120);
}
function isLikelyLabelLine(line: string) {
const normalized = normalizeSearchText(line);
if (!normalized || normalized.length > 120) {
return false;
}
return LABEL_HINTS.some((hint) => normalized.includes(hint));
}
function hasStrongLabelMatch(line: string, normalizedLine: string, labels: string[]) {
for (const label of labels) {
const normalizedLabel = normalizeSearchText(label);
if (!normalizedLabel) {
continue;
}
if (normalizedLine.startsWith(normalizedLabel)) {
return true;
}
const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const rawPattern = new RegExp(`\\b${escapedLabel}\\b\\s*[:\\-]`, "i");
if (rawPattern.test(line)) {
return true;
}
}
return false;
}
function findValueNearLabel(
lines: string[],
labels: string[],
options?: { maxLinesForward?: number; joinLines?: boolean; requireLineStart?: boolean; maxLineLength?: number },
) {
const maxLinesForward = options?.maxLinesForward ?? 1;
const joinLines = options?.joinLines ?? false;
const requireLineStart = options?.requireLineStart ?? false;
const maxLineLength = options?.maxLineLength ?? 180;
const normalizedLabels = labels.map((label) => normalizeSearchText(label));
for (let index = 0; index < lines.length; index += 1) {
const currentLine = cleanValue(lines[index] ?? "");
if (!currentLine) {
continue;
}
const normalizedLine = normalizeSearchText(currentLine);
const hasLabel = normalizedLabels.some((label) => normalizedLine.includes(label));
const hasStrongLabel = hasStrongLabelMatch(currentLine, normalizedLine, labels);
if (!hasLabel) {
continue;
}
if (requireLineStart && !hasStrongLabel) {
continue;
}
if (!hasStrongLabel && currentLine.length > maxLineLength) {
continue;
}
const inline = currentLine.split(/[:\-]/).slice(1).join("-").trim();
const inlineValue = cleanValue(inline);
if (inlineValue) {
return inlineValue;
}
const collected: string[] = [];
for (let step = 1; step <= maxLinesForward; step += 1) {
const nextLine = cleanValue(lines[index + step] ?? "");
if (!nextLine) {
break;
}
if (isLikelyLabelLine(nextLine) || CLAUSE_START_PATTERN.test(nextLine)) {
break;
}
collected.push(nextLine);
if (!joinLines) {
break;
}
}
if (collected.length > 0) {
return cleanValue(joinLines ? collected.join(" ") : collected[0]);
}
}
return null;
}
function findClauseBlock(lines: string[], labels: string[], options?: { maxLines?: number }) {
const maxLines = options?.maxLines ?? 10;
const normalizedLabels = labels.map((label) => normalizeSearchText(label));
for (let index = 0; index < lines.length; index += 1) {
const currentLine = cleanValue(lines[index] ?? "");
if (!currentLine) {
continue;
}
const normalizedLine = normalizeSearchText(currentLine);
const hasLabel = normalizedLabels.some((label) => normalizedLine.includes(label));
if (!hasLabel) {
continue;
}
const inline = currentLine.split(/[:\-]/).slice(1).join("-").trim();
const collected = inline ? [inline] : [];
for (let step = 1; step <= maxLines; step += 1) {
const nextLine = cleanValue(lines[index + step] ?? "");
if (!nextLine) {
break;
}
if (CLAUSE_START_PATTERN.test(nextLine)) {
break;
}
if (isLikelyLabelLine(nextLine) && step > 1) {
break;
}
collected.push(nextLine);
}
return cleanValue(collected.join(" "), 1200);
}
return null;
}
function normalizeCompanyName(value: string | null) {
if (!value) {
return null;
}
return cleanValue(
value
.replace(/\bS\.?\s*A\.?\s*P\.?\s*I\.?\s*DE\s*C\.?\s*V\.?\b/gi, "")
.replace(/\bS\.?\s*A\.?\s*DE\s*C\.?\s*V\.?\b/gi, "")
.replace(/\bS\.?\s*A\.?\s*S\.?\b/gi, "")
.replace(/\bS\.?\s*DE\s*R\.?\s*L\.?\s*DE\s*C\.?\s*V\.?\b/gi, "")
.replace(/\bS\.?\s*DE\s*R\.?\s*L\.?\b/gi, "")
.replace(/\bS\.?\s*A\.?\b/gi, "")
.replace(/\bS\.?\s*C\.?\b/gi, "")
.replace(/["'“”]+/g, "")
.replace(/\s+/g, " "),
);
}
function normalizeDocumentNumber(value: string | null) {
if (!value) {
return null;
}
return cleanValue(value.replace(/\bO\b/g, "0").replace(/(?<=\d)O/g, "0").replace(/O(?=\d)/g, "0"));
}
function extractLegalName(compactText: string, lines: string[]) {
const byLabel = findValueNearLabel(lines, [
"DENOMINACION SOCIAL",
"DENOMINACION",
"RAZON SOCIAL",
"NOMBRE DE LA SOCIEDAD",
"NOMBRE O DENOMINACION",
]);
const sanitizedByLabel = sanitizeLegalNameCandidate(byLabel);
if (sanitizedByLabel) {
return sanitizedByLabel;
}
const byPattern = extractByPatterns(compactText, [
/(?:DENOMINACI[OÓ]N|RAZ[OÓ]N\s+SOCIAL)\s*(?:SER[AÁ]|ES|DE)?\s*[:\-"“”]?\s*([A-Z0-9&.,'"()\- ]{4,220}?)(?=\s+(?:CONTRATO\s+SOCIAL|DECLARACIONES|CLAUSULA|PRIMERA|SEGUNDA|TERCERA|CUARTA|QUINTA|SEXTA|S[EÉ]PTIMA|OCTAVA|NOVENA|D[EÉ]CIMA|QUE\s+CELEBRAN)\b|$)/i,
/SE\s+DENOMINAR[ÁA]\s*[:\-"“”]?\s*([A-Z0-9&.,'"()\- ]{4,220}?)(?=\s+(?:CONTRATO\s+SOCIAL|DECLARACIONES|CLAUSULA|PRIMERA|SEGUNDA|TERCERA|CUARTA|QUINTA|SEXTA|S[EÉ]PTIMA|OCTAVA|NOVENA|D[EÉ]CIMA|QUE\s+CELEBRAN)\b|$)/i,
/SOCIEDAD\s+DENOMINADA\s*[:\-]?\s*([A-Z0-9&.,'"()\- ]{4,220}?)(?=\s+(?:CONTRATO\s+SOCIAL|DECLARACIONES|CLAUSULA|PRIMERA|SEGUNDA|TERCERA|CUARTA|QUINTA|SEXTA|S[EÉ]PTIMA|OCTAVA|NOVENA|D[EÉ]CIMA|QUE\s+CELEBRAN)\b|$)/i,
/BAJO\s+LA\s+DENOMINACI[OÓ]N\s+DE\s*[:\-]?\s*([A-Z0-9&.,'"()\- ]{4,220}?)(?=\s+(?:CONTRATO\s+SOCIAL|DECLARACIONES|CLAUSULA|PRIMERA|SEGUNDA|TERCERA|CUARTA|QUINTA|SEXTA|S[EÉ]PTIMA|OCTAVA|NOVENA|D[EÉ]CIMA|QUE\s+CELEBRAN)\b|$)/i,
]);
const sanitizedByPattern = sanitizeLegalNameCandidate(byPattern);
if (sanitizedByPattern) {
return sanitizedByPattern;
}
const corporateLine = lines.find(
(line) =>
/\b(S\.?\s*A\.?\s*DE\s*C\.?\s*V\.?|S\.?\s*DE\s*R\.?\s*L\.?\s*DE\s*C\.?\s*V\.?|S\.?\s*A\.?\s*S\.?|SOCIEDAD)\b/i.test(
line,
) && !/\b(CONTRATO\s+SOCIAL|DECLARACIONES|CLAUSULA|PRIMERA|SEGUNDA|TERCERA|CUARTA|QUINTA|SEXTA)\b/i.test(line),
);
return sanitizeLegalNameCandidate(corporateLine ?? null);
}
function detectCompanyType(compactText: string): ActaLookupCompanyType | null {
const text = normalizeSearchText(compactText);
if (/SOCIEDAD ANONIMA PROMOTORA DE INVERSION DE CAPITAL VARIABLE|\bS A P I DE C V\b/.test(text)) {
return "SAPI_DE_CV";
}
if (/SOCIEDAD DE RESPONSABILIDAD LIMITADA DE CAPITAL VARIABLE|\bS DE R L DE C V\b/.test(text)) {
return "S_DE_RL_DE_CV";
}
if (/SOCIEDAD DE RESPONSABILIDAD LIMITADA|\bS DE R L\b/.test(text)) {
return "S_DE_RL";
}
if (/SOCIEDAD POR ACCIONES SIMPLIFICADA|\bS A S\b/.test(text)) {
return "SAS";
}
if (/SOCIEDAD ANONIMA DE CAPITAL VARIABLE|\bS A DE C V\b/.test(text)) {
return "SA_DE_CV";
}
if (/SOCIEDAD ANONIMA|\bS A\b/.test(text)) {
return "SA";
}
if (/SOCIEDAD CIVIL|\bS C\b/.test(text)) {
return "SC";
}
if (/SOCIEDAD\s+/.test(text)) {
return "OTHER";
}
return null;
}
function detectAdministrationType(compactText: string): ActaAdministrationType | null {
const text = normalizeSearchText(compactText);
if (/ADMINISTRADOR UNICO/.test(text)) {
return "ADMIN_UNICO";
}
if (/CONSEJO DE ADMINISTRACION/.test(text)) {
return "CONSEJO_ADMIN";
}
if (/GERENTE UNICO/.test(text)) {
return "GERENTE_UNICO";
}
if (/\bGERENTES\b|\bGERENTE\b/.test(text)) {
return "GERENTES";
}
if (/ADMINISTRACION/.test(text)) {
return "OTHER";
}
return null;
}
function detectForeignersClause(compactText: string): ActaForeignersClause | null {
const text = normalizeSearchText(compactText);
if (
/CLAUSULA DE EXTRANJEROS|CONVENCION DE EXTRANJEROS|SE CONSIDERARAN COMO NACIONALES|NO INVOCAR LA PROTECCION DE SU GOBIERNO/.test(
text,
)
) {
return "CLAUSULA_CALVO";
}
if (/ADMITE EXTRANJEROS|SIN CLAUSULA DE EXCLUSION DE EXTRANJEROS/.test(text)) {
return "ADMITE_EXTRANJEROS";
}
return null;
}
function extractInstrumentType(compactText: string) {
return extractByPatterns(compactText, [
/(ESCRITURA\s+P[UÚ]BLICA|ACTA\s+CONSTITUTIVA|CONSTITUCI[OÓ]N\s+DE\s+LA\s+SOCIEDAD|INSTRUMENTO\s+NOTARIAL)/i,
]);
}
function extractInstrumentNumber(compactText: string) {
const raw = extractByPatterns(compactText, [
/FOLIO\s+DE\s+CONSTITUCI[OÓ]N\s*[:#\-]?\s*([A-Z]{2,6}\d{4,})/i,
/(?:^|\s)(SAS\d{6,})\s+DENOMINACI[OÓ]N/i,
/(?:ESCRITURA(?:\s+P[UÚ]BLICA)?|INSTRUMENTO)\s*(?:N[UÚ]MERO|NO\.?|N\.?|NRO\.?)?[\s:#\-]*([A-Z0-9\-\/]*\d[A-Z0-9\-\/]*)/i,
/N[UÚ]MERO\s+DE\s+ESCRITURA\s*[:#\-]?\s*([A-Z0-9\-\/]*\d[A-Z0-9\-\/]*)/i,
]);
return normalizeDocumentNumber(raw);
}
function extractProtocolVolumeBook(compactText: string) {
const value = extractByPatterns(compactText, [
/(?:TOMO|LIBRO|VOLUMEN|PROTOCOLO)\s*[:#]?\s*([A-Z0-9\-\/\.]*\d[A-Z0-9\-\/\.]*)/i,
]);
return cleanValue(value?.replace(/[.,;:]+$/, "") ?? null);
}
function extractInstrumentDate(compactText: string, lines: string[]) {
const byLabel = findValueNearLabel(lines, [
"FECHA DE CONSTITUCION",
"CONSTITUIDA EL",
"SE CONSTITUYO EL",
"FECHA DEL INSTRUMENTO",
]);
if (byLabel) {
return byLabel;
}
const byPattern = extractByPatterns(compactText, [
/([0-3]?\d\s+DE\s+(?:ENERO|FEBRERO|MARZO|ABRIL|MAYO|JUNIO|JULIO|AGOSTO|SEPTIEMBRE|SETIEMBRE|OCTUBRE|NOVIEMBRE|DICIEMBRE)\s+DE\s+\d{4})/i,
/(A\s+LOS?\s+[^.]{8,120}?\s+DEL\s+MES\s+DE\s+[^.]{4,80}?\s+DEL\s+A[NÑ]O\s+[^.]{2,80})/i,
/(\d{1,2}[\/\.\-]\d{1,2}[\/\.\-]\d{2,4})/i,
]);
return byPattern;
}
function extractPlaceOfGranting(compactText: string) {
return extractByPatterns(compactText, [
/EN\s+LA\s+CIUDAD\s+DE\s+([^,\n\.]{3,120})/i,
/MUNICIPIO\s+DE\s+([^,\n\.]{3,120})/i,
]);
}
function extractNotaryNumber(compactText: string) {
const raw = extractByPatterns(compactText, [
/(?:NOTAR[IÍ]A|NOTARIO\s+P[UÚ]BLICO)\s*(?:N[UÚ]MERO|NO\.?|N\.)\s*([0-9O]{1,4})/i,
]);
return normalizeDocumentNumber(raw);
}
function extractNotaryName(compactText: string, lines: string[]) {
const byLabel = findValueNearLabel(lines, ["NOTARIO PUBLICO", "NOTARIO", "CORREDOR PUBLICO", "TITULAR DE LA NOTARIA"], {
maxLinesForward: 2,
joinLines: true,
requireLineStart: true,
maxLineLength: 120,
});
if (byLabel) {
return byLabel;
}
return extractByPatterns(compactText, [
/NOTARIO\s+P[UÚ]BLICO\s*(?:N[UÚ]MERO|NO\.?|N\.)?\s*[0-9O]{0,4}\s*[^,\n]{0,40},?\s*(?:LIC\.?|LICENCIADO)?\s*([A-ZÁÉÍÓÚÑ][A-ZÁÉÍÓÚÑ\s]{4,140})/i,
]);
}
function extractNotaryState(compactText: string) {
return extractByPatterns(compactText, [
/(?:DEL\s+ESTADO\s+DE|ESTADO\s+DE)\s+([A-ZÁÉÍÓÚÑ][A-ZÁÉÍÓÚÑ\s]{2,80})/i,
]);
}
function extractMoney(compactText: string, patterns: RegExp[]) {
const value = extractByPatterns(compactText, patterns);
if (!value) {
return null;
}
return cleanValue(value.replace(/\s+/g, " "));
}
function parseMoneyAmount(value: string | null) {
if (!value) {
return null;
}
const match = value.match(/[0-9][0-9\.,]*/);
if (!match) {
return null;
}
const normalized = match[0].replace(/,/g, "");
const amount = Number.parseFloat(normalized);
return Number.isFinite(amount) ? amount : null;
}
function normalizeCountryCodeToName(code: string | null) {
if (!code) {
return null;
}
const normalized = code.trim().toUpperCase();
if (normalized === "MEX" || normalized === "MX") {
return "Mexico";
}
return null;
}
function extractIndustry(compactText: string) {
return extractByPatterns(compactText, [
/ACTIVIDAD\s+PRINCIPAL\s+(.+?)(?=\s+(?:DE\s+FORMA|CUARTA\.|QUINTA\.|SEXTA\.|S[EÉ]PTIMA\.|OCTAVA\.|NOVENA\.|D[EÉ]CIMA\.|P[ÁA]GINA))/i,
/INDUSTRIA\s*[:\-]?\s*([A-ZÁÉÍÓÚÑa-záéíóúñ0-9 ,.\-]{3,180})/i,
]);
}
function extractCountryOfOperation(compactText: string) {
const byTag = normalizeCountryCodeToName(
extractByPatterns(compactText, [/<PAIS>\s*([A-Z]{2,3})\s*<\/PAIS>/i, /<PA[IÍ]S>\s*([A-Z]{2,3})\s*<\/PA[IÍ]S>/i]),
);
if (byTag) {
return byTag;
}
const byNationality = extractByPatterns(compactText, [
/NACIONALIDAD\s+DE\s+LA\s+SOCIEDAD\.\s+LA\s+SOCIEDAD\s+SER[ÁA]\s+DE\s+NACIONALIDAD\s+([A-ZÁÉÍÓÚÑa-záéíóúñ]+)/i,
]);
if (byNationality) {
if (/MEXICAN/i.test(byNationality)) {
return "Mexico";
}
return cleanValue(byNationality);
}
if (/REP[ÚU]BLICA\s+MEXICANA|CIUDAD\s+DE\s+M[ÉE]XICO|GOBIERNO\s+FEDERAL/i.test(compactText)) {
return "Mexico";
}
return null;
}
function extractRpcReference(compactText: string) {
const folioMercantil = extractByPatterns(compactText, [/(?:FOLIO\s+MERCANTIL)\s*[:#]?\s*([A-Z0-9\-\/]{2,60})/i]);
const registroPublico = /REGISTRO\s+P[UÚ]BLICO\s+DE\s+COMERCIO/i.test(compactText)
? "Registro Publico de Comercio"
: null;
const inscriptionDate = extractByPatterns(compactText, [
/INSCRIPCI[OÓ]N\s*(?:DE\s+FECHA)?\s*[:\-]?\s*([0-3]?\d\s+DE\s+[A-ZÁÉÍÓÚÑ]+\s+DE\s+\d{4})/i,
/INSCRITA\s+EL\s*[:\-]?\s*([0-3]?\d\s+DE\s+[A-ZÁÉÍÓÚÑ]+\s+DE\s+\d{4})/i,
]);
if (!folioMercantil && !registroPublico && !inscriptionDate) {
return null;
}
return {
folioMercantil,
registroPublico,
inscriptionDate,
};
}
function uniqueStrings(values: Array<string | null>) {
return [...new Set(values.map((value) => cleanValue(value)).filter((value): value is string => Boolean(value)))];
}
export function extractActaLookupDictionary(fullText: string): ActaLookupDictionary {
const normalizedText = fullText.replace(/\u0000/g, " ").trim();
const compactText = normalizedText.replace(/\s+/g, " ");
const lines = normalizedText
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const legalName = extractLegalName(compactText, lines);
const instrumentType = extractInstrumentType(compactText);
const instrumentNumber = extractInstrumentNumber(compactText);
const protocolVolumeBook = extractProtocolVolumeBook(compactText);
const instrumentDate = extractInstrumentDate(compactText, lines);
const placeOfGranting = extractPlaceOfGranting(compactText);
const companyType = detectCompanyType(compactText);
const hasNotaryReference = /NOTAR[IÍ]A|NOTARIO\s+P[UÚ]BLICO|ESCRITURA\s+P[UÚ]BLICA|INSTRUMENTO\s+NOTARIAL/i.test(compactText);
const notaryName = hasNotaryReference ? extractNotaryName(compactText, lines) : null;
const notaryNumber = hasNotaryReference ? extractNotaryNumber(compactText) : null;
const notaryState = hasNotaryReference ? extractNotaryState(compactText) : null;
const domicileFromClause = extractByPatterns(compactText, [
/TERCERA\.\s*DOMICILIO\.\s*EL\s+DOMICILIO\s+DE\s+LA\s+SOCIEDAD\s+SER[ÁA]\s+EL\s+UBICADO\s+EN\s+(.+?)(?=\s+CUARTA\.)/i,
]);
const domicileLabels =
companyType === "SAS"
? ["DOMICILIO SOCIAL", "DOMICILIO DE LA SOCIEDAD", "TENDRA SU DOMICILIO EN"]
: ["DOMICILIO FISCAL", "DOMICILIO SOCIAL", "DOMICILIO DE LA SOCIEDAD", "TENDRA SU DOMICILIO EN", "DOMICILIO"];
const domicileRaw =
domicileFromClause ??
findValueNearLabel(lines, domicileLabels, {
maxLinesForward: 4,
joinLines: true,
requireLineStart: companyType === "SAS",
maxLineLength: companyType === "SAS" ? 140 : 220,
});
const domicile = cleanValue(domicileRaw?.replace(/,\s*pudiendo\s+establecer[\s\S]*$/i, "") ?? null, 600);
const durationFromClause = extractByPatterns(compactText, [
/CUARTA\.\s*DURACI[OÓ]N\.\s*LA\s+DURACI[OÓ]N\s+DE\s+LA\s+SOCIEDAD\s+SER[ÁA]\s*(INDEFINIDA|[0-9]{1,3}\s+A[NÑ]OS?)/i,
]);
const duration = durationFromClause ?? extractByPatterns(compactText, [
/DURACI[OÓ]N\s*(?:SER[AÁ]|ES)?\s*[:\-]?\s*(INDEFINIDA|[0-9]{1,3}\s+A[NÑ]OS?)/i,
]);
const corporatePurpose =
extractByPatterns(compactText, [
/LA\s+SOCIEDAD\s+TIENE\s+COMO\s+ACTIVIDAD\s+PRINCIPAL\s+(.+?)(?=\s+CUARTA\.)/i,
/OBJETO\s+SOCIAL\s*[:\-]?\s*(.+?)(?=\s+(?:CUARTA|QUINTA|SEXTA|S[EÉ]PTIMA|OCTAVA|NOVENA|D[EÉ]CIMA)\.)/i,
/TIENE\s+COMO\s+ACTIVIDAD\s+PRINCIPAL\s+(.+?)(?=\s+(?:CUARTA|QUINTA|SEXTA|S[EÉ]PTIMA|OCTAVA|NOVENA|D[EÉ]CIMA)\.)/i,
]) ??
findClauseBlock(lines, ["OBJETO SOCIAL", "LA SOCIEDAD TENDRA POR OBJETO", "TENDRA POR OBJETO"], {
maxLines: 12,
}) ?? findValueNearLabel(lines, ["OBJETO SOCIAL", "OBJETO"], { maxLinesForward: 8, joinLines: true });
const stateFromDomicile = extractByPatterns(domicile ?? "", [
/ENTIDAD\s+FEDERATIVA\s+([A-ZÁÉÍÓÚÑ ]{3,80})/i,
]);
const stateOfIncorporation =
stateFromDomicile ??
extractByPatterns(compactText, [
/TERCERA\.\s*DOMICILIO\.[^]*?ENTIDAD\s+FEDERATIVA\s+([A-ZÁÉÍÓÚÑ ]{3,80})(?=\s+[A-ZÁÉÍÓÚÑ][a-záéíóúñ]+\.|\s+CUARTA\.)/i,
]) ??
findValueNearLabel(lines, ["ENTIDAD FEDERATIVA"], {
maxLinesForward: 1,
joinLines: false,
requireLineStart: true,
maxLineLength: 90,
});
const legalRepresentative = findValueNearLabel(
lines,
["REPRESENTANTE LEGAL", "APODERADO LEGAL"],
{
maxLinesForward: 2,
joinLines: true,
requireLineStart: true,
maxLineLength: 120,
},
) ??
extractByPatterns(compactText, [
/ADMINISTRADOR(?:A)?\s+UNICO\s*[:\-]?\s*([A-ZÁÉÍÓÚÑ][A-ZÁÉÍÓÚÑ\s]{4,120})/i,
/SE\s+DESIGNA\s+COMO\s+ADMINISTRADOR(?:A)?\s+UNICO\s+A\s+([A-ZÁÉÍÓÚÑ][A-ZÁÉÍÓÚÑ\s]{4,120})/i,
/CARGO\s+DE\s+ADMINISTRADOR(?:A)?\s+UNICO\s+RECAER[ÁA]\s+EN\s+([A-ZÁÉÍÓÚÑ][A-ZÁÉÍÓÚÑ\s]{4,120})/i,
/FUNCI[OÓ]N\s+QUE\s+DESEMPE[ÑN]AR[ÁA]\s+([A-ZÁÉÍÓÚÑ][A-ZÁÉÍÓÚÑ\s]{4,120}?)\s+Y\s+DE\s+CONFORMIDAD/i,
/ESTAR[ÁA]\s+A\s+CARGO\s+DE\s+UN\s+ADMINISTRADOR[^.]{0,220}?\s+([A-ZÁÉÍÓÚÑ][A-ZÁÉÍÓÚÑ\s]{4,120}?)\s+Y\s+DE\s+CONFORMIDAD/i,
]);
const industry = extractIndustry(compactText);
const countryOfOperation = extractCountryOfOperation(compactText);
// RFC must be captured manually by user during onboarding (acta often contains personal RFCs).
const rfcCompany = null;
const rpcReference = extractRpcReference(compactText);
const capitalTotalDetected = extractMoney(compactText, [
/CAPITAL\s+SOCIAL(?:\s+TOTAL)?\s*(?:ASCIENDE\s+A|SERA\s+DE|ES\s+DE|POR\s+LA\s+CANTIDAD\s+DE)\s*\$?\s*([0-9][0-9\.,]*(?:\s*[A-Z.]+)?)/i,
]);
const capitalFixed = extractMoney(compactText, [
/(?:CAPITAL\s+SOCIAL\s+FIJO|CAPITAL\s+FIJO|PARTE\s+FIJA)\s*(?:ES\s+LA\s+CANTIDAD\s+DE|:|\-)?\s*\$?\s*([0-9][0-9\.,]*(?:\s*[A-Z.]+)?)/i,
]);
const capitalVariable = extractMoney(compactText, [
/(?:PORCI[OÓ]N\s+VARIABLE\s+DEL\s+CAPITAL\s+SOCIAL|CAPITAL\s+VARIABLE|PARTE\s+VARIABLE)\s*(?:ES\s+LA\s+CANTIDAD\s+DE|:|\-)?\s*\$?\s*([0-9][0-9\.,]*(?:\s*[A-Z.]+)?|ILIMITAD[AO])/i,
]);
const shareNominalValue = extractMoney(compactText, [
/VALOR\s+NOMINAL(?:\s+DE)?\s*[:\-]?\s*\$?\s*([0-9][0-9\.,]*(?:\s*[A-Z.]+)?)/i,
/CADA\s+(?:ACCI[OÓ]N|PARTE\s+SOCIAL)\s+DE\s*\$?\s*([0-9\.,]+(?:\s*[A-Z.]+)?)/i,
]);
const derivedCapitalTotal = (() => {
const fixed = parseMoneyAmount(capitalFixed);
const variable = parseMoneyAmount(capitalVariable);
if (fixed === null || variable === null) {
return null;
}
return cleanValue(`${fixed + variable} pesos`);
})();
const capitalTotal = capitalTotalDetected ?? derivedCapitalTotal;
const administrationType = detectAdministrationType(compactText);
const foreignersClause = detectForeignersClause(compactText);
const sreNameAuthorization = findValueNearLabel(
lines,
["SECRETARIA DE RELACIONES EXTERIORES", "SRE", "USO DE DENOMINACION", "AUTORIZACION"],
{
maxLinesForward: 2,
joinLines: true,
},
);
const keySignersCandidates = uniqueStrings([legalRepresentative]);
return ActaLookupDictionarySchema.parse({
version: "mx_acta_constitutiva_reference_v1",
instrumentType,
instrumentNumber,
protocolVolumeBook,
instrumentDate,
placeOfGranting,
notaryName,
notaryNumber,
notaryState,
legalName,
normalizedName: normalizeCompanyName(legalName),
companyType,
domicile,
duration,
corporatePurpose,
industry,
countryOfOperation,
stateOfIncorporation,
legalRepresentative,
rpcReference,
rfcCompany,
capitalTotal,
capitalFixed,
capitalVariable,
shareNominalValue,
administrationType,
foreignersClause: foreignersClause ?? "NO_DETECTADO",
sreNameAuthorization,
derivedFields: {
isCompanyValidlyConstituted: legalName && companyType && instrumentNumber && notaryNumber ? true : null,
keySignersCandidates: keySignersCandidates.length ? keySignersCandidates : null,
},
});
}
export function mapLookupDictionaryToActaFields(dictionary: ActaLookupDictionary): ActaFields {
const notaryName = dictionary.notaryName
? dictionary.notaryNumber
? `${dictionary.notaryName} (Notario No. ${dictionary.notaryNumber})`
: dictionary.notaryName
: dictionary.notaryNumber
? `Notario Publico No. ${dictionary.notaryNumber}`
: null;
return ActaFieldsSchema.parse({
name: dictionary.legalName,
rfc: null,
legalRepresentative: dictionary.legalRepresentative,
incorporationDate: dictionary.instrumentDate,
deedNumber: dictionary.instrumentNumber,
notaryName,
fiscalAddress: dictionary.domicile,
businessPurpose: dictionary.corporatePurpose,
stateOfIncorporation: dictionary.stateOfIncorporation ?? dictionary.notaryState,
});
}
export function extractFields(fullText: string): ActaFields {
const dictionary = extractActaLookupDictionary(fullText);
return mapLookupDictionaryToActaFields(dictionary);
}

View File

@@ -0,0 +1,81 @@
import { z } from "zod";
export const ActaLookupCompanyTypeSchema = z.enum([
"SA_DE_CV",
"SA",
"S_DE_RL_DE_CV",
"S_DE_RL",
"SAS",
"SAPI_DE_CV",
"SC",
"OTHER",
]);
export const ActaAdministrationTypeSchema = z.enum([
"ADMIN_UNICO",
"CONSEJO_ADMIN",
"GERENTE_UNICO",
"GERENTES",
"OTHER",
]);
export const ActaForeignersClauseSchema = z.enum(["CLAUSULA_CALVO", "ADMITE_EXTRANJEROS", "NO_DETECTADO"]);
export const ActaFieldsSchema = z.object({
name: z.string().nullable(),
rfc: z.string().nullable(),
legalRepresentative: z.string().nullable(),
incorporationDate: z.string().nullable(),
deedNumber: z.string().nullable(),
notaryName: z.string().nullable(),
fiscalAddress: z.string().nullable(),
businessPurpose: z.string().nullable(),
stateOfIncorporation: z.string().nullable(),
});
export const ActaLookupDictionarySchema = z.object({
version: z.literal("mx_acta_constitutiva_reference_v1"),
instrumentType: z.string().nullable(),
instrumentNumber: z.string().nullable(),
protocolVolumeBook: z.string().nullable(),
instrumentDate: z.string().nullable(),
placeOfGranting: z.string().nullable(),
notaryName: z.string().nullable(),
notaryNumber: z.string().nullable(),
notaryState: z.string().nullable(),
legalName: z.string().nullable(),
normalizedName: z.string().nullable(),
companyType: ActaLookupCompanyTypeSchema.nullable(),
domicile: z.string().nullable(),
duration: z.string().nullable(),
corporatePurpose: z.string().nullable(),
industry: z.string().nullable(),
countryOfOperation: z.string().nullable(),
stateOfIncorporation: z.string().nullable(),
legalRepresentative: z.string().nullable(),
rpcReference: z
.object({
folioMercantil: z.string().nullable(),
registroPublico: z.string().nullable(),
inscriptionDate: z.string().nullable(),
})
.nullable(),
rfcCompany: z.string().nullable(),
capitalTotal: z.string().nullable(),
capitalFixed: z.string().nullable(),
capitalVariable: z.string().nullable(),
shareNominalValue: z.string().nullable(),
administrationType: ActaAdministrationTypeSchema.nullable(),
foreignersClause: ActaForeignersClauseSchema.nullable(),
sreNameAuthorization: z.string().nullable(),
derivedFields: z.object({
isCompanyValidlyConstituted: z.boolean().nullable(),
keySignersCandidates: z.array(z.string()).nullable(),
}),
});
export type ActaFields = z.infer<typeof ActaFieldsSchema>;
export type ActaLookupDictionary = z.infer<typeof ActaLookupDictionarySchema>;
export type ActaLookupCompanyType = z.infer<typeof ActaLookupCompanyTypeSchema>;
export type ActaAdministrationType = z.infer<typeof ActaAdministrationTypeSchema>;
export type ActaForeignersClause = z.infer<typeof ActaForeignersClauseSchema>;

75
src/lib/http/url.ts Normal file
View File

@@ -0,0 +1,75 @@
import "server-only";
type QueryParams = Record<string, string | undefined | null>;
function normalizeHost(host: string) {
if (host === "0.0.0.0") {
return "localhost";
}
if (host.startsWith("0.0.0.0:")) {
return host.replace("0.0.0.0", "localhost");
}
if (host === "[::]") {
return "localhost";
}
if (host.startsWith("[::]:")) {
return host.replace("[::]", "localhost");
}
return host;
}
function getConfiguredOrigin() {
const configured = process.env.APP_URL?.trim();
if (!configured) {
return null;
}
try {
return new URL(configured).origin;
} catch {
return null;
}
}
export function getRequestOrigin(request: Request) {
const configuredOrigin = getConfiguredOrigin();
if (configuredOrigin) {
return configuredOrigin;
}
const forwardedHost = request.headers.get("x-forwarded-host")?.split(",")[0]?.trim();
const rawHost = forwardedHost || request.headers.get("host")?.trim();
const host = rawHost ? normalizeHost(rawHost) : null;
const forwardedProto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
if (host) {
const protocol = forwardedProto || "http";
return `${protocol}://${host}`;
}
try {
const requestUrl = new URL(request.url);
const normalizedHost = normalizeHost(requestUrl.host);
return `${requestUrl.protocol}//${normalizedHost}`;
} catch {
return "http://localhost:3000";
}
}
export function buildAppUrl(request: Request, path: string, params: QueryParams = {}) {
const url = new URL(path, getRequestOrigin(request));
Object.entries(params).forEach(([key, value]) => {
if (value) {
url.searchParams.set(key, value);
}
});
return url;
}

View File

@@ -0,0 +1,38 @@
import "server-only";
function parseInteger(value: string | undefined, fallback: number) {
if (!value) {
return fallback;
}
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function parseBoolean(value: string | undefined, fallback: boolean) {
if (!value) {
return fallback;
}
const normalized = value.trim().toLowerCase();
if (["1", "true", "yes", "si"].includes(normalized)) {
return true;
}
if (["0", "false", "no"].includes(normalized)) {
return false;
}
return fallback;
}
export const licitationsConfig = {
staleDaysThreshold: parseInteger(process.env.LICITATIONS_STALE_DAYS, 45),
currentWindowDays: parseInteger(process.env.LICITATIONS_CURRENT_WINDOW_DAYS, 30),
requestDelayMs: parseInteger(process.env.LICITATIONS_REQUEST_DELAY_MS, 750),
maxRetries: parseInteger(process.env.LICITATIONS_MAX_RETRIES, 3),
maxMunicipalitiesPerRun: parseInteger(process.env.LICITATIONS_MAX_MUNICIPALITIES_PER_RUN, 100),
scrapingEnabledByDefault: parseBoolean(process.env.LICITATIONS_SCRAPING_ENABLED, true),
syncCronToken: process.env.LICITATIONS_SYNC_TOKEN ?? "",
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,468 @@
import "server-only";
import { chromium } from "playwright";
import { read, utils } from "xlsx";
import { LicitationCategory, LicitationProcedureType, LicitationSource } from "@prisma/client";
import { licitationsConfig } from "@/lib/licitations/config";
import {
mapCategory,
mapProcedureType,
parseAmountAndCurrency,
parseDateMaybe,
pickFirstText,
pickPublishDate,
sanitizeDocuments,
} from "@/lib/licitations/normalize";
import type { ConnectorResult, MunicipalityConnectorInput, NormalizedDocument, NormalizedLicitationInput } from "@/lib/licitations/types";
import { delay, normalizeUrl, withRetries } from "@/lib/licitations/utils";
const PNT_HOST = "https://consultapublicamx.plataformadetransparencia.org.mx";
const FRACCION_REGEX = /(fracci[oó]n\s*xxviii|procedimientos de adjudicaci[oó]n directa|invitaci[oó]n restringida|licitaci[oó]n)/i;
type ScrapedTableRow = {
tableIndex: number;
rowIndex: number;
rowText: string;
cells: string[];
values: Record<string, string>;
links: Array<{ name: string; url: string }>;
};
type ScrapedSnapshot = {
pageUrl: string;
pageTitle: string;
rows: ScrapedTableRow[];
downloadLinks: Array<{ name: string; url: string }>;
};
function maybeEncodeBase64(value: string | null) {
if (!value) {
return null;
}
if (/^[A-Za-z0-9+/=]+$/.test(value) && value.length % 4 === 0) {
return value;
}
return Buffer.from(value, "utf-8").toString("base64");
}
function addCandidate(set: Set<string>, value: string | null) {
if (!value) {
return;
}
try {
set.add(new URL(value, PNT_HOST).toString());
} catch {
// Ignore malformed URLs.
}
}
export function resolvePntEntryCandidates(municipality: MunicipalityConnectorInput) {
const candidates = new Set<string>();
if (municipality.pntEntryUrl) {
addCandidate(candidates, municipality.pntEntryUrl);
}
const encodedEntity = maybeEncodeBase64(municipality.pntEntityId);
const encodedSubject = maybeEncodeBase64(municipality.pntSubjectId);
if (encodedEntity && encodedSubject) {
addCandidate(
candidates,
`${PNT_HOST}/vut-web/faces/view/consultaPublica.xhtml?idEntidad=${encodeURIComponent(encodedEntity)}&idSujetoObligado=${encodeURIComponent(encodedSubject)}#inicio`,
);
}
if (municipality.pntEntityId && municipality.pntSubjectId) {
const sector = municipality.pntSectorId ?? "";
addCandidate(
candidates,
`${PNT_HOST}/vut-web/?idEntidadParametro=${encodeURIComponent(municipality.pntEntityId)}&idSectorParametro=${encodeURIComponent(sector)}&idSujetoObigadoParametro=${encodeURIComponent(municipality.pntSubjectId)}`,
);
addCandidate(
candidates,
`${PNT_HOST}/vut-web/?idEntidadParametro=${encodeURIComponent(municipality.pntEntityId)}&idSectorParametro=${encodeURIComponent(sector)}&idSujetoObligadoParametro=${encodeURIComponent(municipality.pntSubjectId)}`,
);
}
addCandidate(candidates, `${PNT_HOST}/vut-web/`);
return [...candidates];
}
function pickRowValue(row: ScrapedTableRow, patterns: RegExp[]) {
for (const [key, value] of Object.entries(row.values)) {
if (patterns.some((pattern) => pattern.test(key))) {
const normalized = value.trim();
if (normalized) {
return normalized;
}
}
}
return null;
}
function normalizeLinks(links: Array<{ name: string; url: string }>, pageUrl: string): NormalizedDocument[] {
return links
.map((link) => {
const url = normalizeUrl(link.url, pageUrl);
if (!url) {
return null;
}
const typeMatch = url.match(/\.([a-z0-9]+)(?:$|\?)/i);
const document: NormalizedDocument = {
name: link.name?.trim() || "Documento",
url,
};
if (typeMatch?.[1]) {
document.type = typeMatch[1].toUpperCase();
}
return document;
})
.filter((item): item is NormalizedDocument => item !== null);
}
function rowToLicitation(row: ScrapedTableRow, snapshot: ScrapedSnapshot): NormalizedLicitationInput | null {
const title = pickFirstText([
pickRowValue(row, [/objeto/i, /concepto/i, /descripcion/i, /convocatoria/i, /procedimiento/i]),
row.cells[0],
row.rowText,
]);
if (!title) {
return null;
}
const procedureText = pickFirstText([pickRowValue(row, [/tipo/i, /procedimiento/i]), row.rowText]);
const categoryText = pickFirstText([pickRowValue(row, [/categoria/i, /rubro/i, /giro/i, /objeto/i]), row.rowText]);
const publishDate = pickPublishDate([
pickRowValue(row, [/fecha de publicacion/i, /publicaci[oó]n/i, /fecha de emision/i]),
pickRowValue(row, [/fecha/i]),
]);
const amountText = pickFirstText([pickRowValue(row, [/monto/i, /importe/i, /presupuesto/i]), row.rowText]);
const amountData = parseAmountAndCurrency(amountText);
const sourceRecordId = pickFirstText([
pickRowValue(row, [/folio/i, /expediente/i, /id/i, /numero/i]),
`${row.tableIndex}-${row.rowIndex}`,
]);
const eventDates: Record<string, string> = {};
for (const [key, value] of Object.entries(row.values)) {
if (!/fecha/i.test(key)) {
continue;
}
const parsed = parseDateMaybe(value);
if (!parsed) {
continue;
}
eventDates[key] = parsed.toISOString();
}
const documents = sanitizeDocuments(normalizeLinks(row.links, snapshot.pageUrl));
return {
sourceRecordId,
procedureType: mapProcedureType(procedureText),
title,
description: pickRowValue(row, [/descripcion/i, /detalle/i, /observaciones/i]),
category: mapCategory(categoryText),
publishDate,
eventDates: Object.keys(eventDates).length ? eventDates : null,
amount: amountData.amount,
currency: amountData.currency,
status: pickRowValue(row, [/estatus/i, /estado/i, /situacion/i]),
supplierAwarded: pickRowValue(row, [/proveedor/i, /adjudicado/i, /ganador/i]),
documents,
rawSourceUrl: snapshot.pageUrl,
rawPayload: {
pageTitle: snapshot.pageTitle,
row,
},
};
}
function linkToStub(link: { name: string; url: string }, pageUrl: string, pageTitle: string): NormalizedLicitationInput | null {
const normalizedUrl = normalizeUrl(link.url, pageUrl);
if (!normalizedUrl) {
return null;
}
return {
sourceRecordId: normalizedUrl,
procedureType: LicitationProcedureType.UNKNOWN,
title: link.name?.trim() || "Documento de licitacion",
description: "Registro generado desde enlace documental en PNT.",
category: LicitationCategory.UNKNOWN,
publishDate: null,
documents: sanitizeDocuments([{ name: link.name || "Documento", url: normalizedUrl }]),
rawSourceUrl: pageUrl,
rawPayload: {
pageTitle,
link,
mode: "link-feed",
},
};
}
async function parseSpreadsheetLinks(snapshot: ScrapedSnapshot) {
const spreadsheetLinks = snapshot.downloadLinks.filter((link) => /\.(xlsx|xls|csv)(?:$|\?)/i.test(link.url));
if (!spreadsheetLinks.length) {
return [] as NormalizedLicitationInput[];
}
const records: NormalizedLicitationInput[] = [];
for (const link of spreadsheetLinks) {
try {
const spreadsheetUrl = normalizeUrl(link.url, snapshot.pageUrl);
if (!spreadsheetUrl) {
continue;
}
const response = await fetch(spreadsheetUrl);
if (!response.ok) {
continue;
}
const arrayBuffer = await response.arrayBuffer();
const workbook = read(arrayBuffer, { type: "array" });
for (const sheetName of workbook.SheetNames) {
const sheet = workbook.Sheets[sheetName];
if (!sheet) {
continue;
}
const rows = utils.sheet_to_json<Record<string, unknown>>(sheet, {
defval: "",
});
for (const row of rows) {
const text = Object.values(row)
.map((value) => String(value ?? ""))
.join(" ");
if (!text.trim()) {
continue;
}
const publishDate = pickPublishDate([
String(row["Fecha de publicación"] ?? ""),
String(row["Fecha"] ?? ""),
]);
const amountData = parseAmountAndCurrency(String(row["Monto"] ?? row["Importe"] ?? ""));
records.push({
sourceRecordId: String(row["Folio"] ?? row["ID"] ?? row["No."] ?? "").trim() || null,
procedureType: mapProcedureType(String(row["Procedimiento"] ?? row["Tipo"] ?? text)),
title: pickFirstText([
String(row["Objeto"] ?? ""),
String(row["Descripción"] ?? row["Descripcion"] ?? ""),
text,
]),
description: String(row["Descripción"] ?? row["Descripcion"] ?? "").trim() || null,
category: mapCategory(String(row["Categoría"] ?? row["Categoria"] ?? text)),
publishDate,
amount: amountData.amount,
currency: amountData.currency,
status: String(row["Estatus"] ?? row["Estado"] ?? "").trim() || null,
supplierAwarded: String(row["Proveedor"] ?? row["Ganador"] ?? "").trim() || null,
documents: [{ name: link.name || `Archivo ${sheetName}`, url: spreadsheetUrl, type: "XLSX" }],
rawSourceUrl: spreadsheetUrl,
rawPayload: {
source: "spreadsheet",
sheetName,
row,
},
});
}
}
} catch {
// Non-fatal.
}
}
return records;
}
async function openPntAndExtract(municipality: MunicipalityConnectorInput) {
const browser = await chromium.launch({ headless: true });
try {
const context = await browser.newContext({
userAgent:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
});
const page = await context.newPage();
const candidates = resolvePntEntryCandidates(municipality);
let opened = false;
for (const candidate of candidates) {
try {
await page.goto(candidate, { waitUntil: "domcontentloaded", timeout: 45_000 });
opened = true;
break;
} catch {
// Try next candidate.
}
}
if (!opened) {
throw new Error("PNT entry URL could not be opened.");
}
await page.waitForTimeout(1500);
const target = page.getByText(FRACCION_REGEX).first();
if ((await target.count()) > 0) {
await target.click({ timeout: 10_000 }).catch(() => undefined);
await page.waitForTimeout(1200);
}
const snapshot = (await page.evaluate(() => {
const normalize = (value: string | null | undefined) => (value ?? "").replace(/\s+/g, " ").trim();
const rows: Array<{
tableIndex: number;
rowIndex: number;
rowText: string;
cells: string[];
values: Record<string, string>;
links: Array<{ name: string; url: string }>;
}> = [];
const tableElements = Array.from(document.querySelectorAll("table"));
tableElements.forEach((table, tableIndex) => {
const headerCells = Array.from(table.querySelectorAll("thead th, tr th"));
const headers = headerCells.map((cell, index) => normalize(cell.textContent) || `col_${index + 1}`);
const bodyRows = Array.from(table.querySelectorAll("tbody tr, tr")).filter((row) => row.querySelectorAll("td").length > 0);
bodyRows.forEach((row, rowIndex) => {
const cells = Array.from(row.querySelectorAll("td")).map((cell) => normalize(cell.textContent));
const values: Record<string, string> = {};
cells.forEach((cellValue, index) => {
const key = headers[index] || `col_${index + 1}`;
values[key] = cellValue;
});
const links = Array.from(row.querySelectorAll("a[href]")).map((anchor) => ({
name: normalize(anchor.textContent) || normalize(anchor.getAttribute("title")) || "Documento",
url: String(anchor.getAttribute("href") || ""),
}));
rows.push({
tableIndex,
rowIndex,
rowText: cells.join(" | "),
cells,
values,
links,
});
});
});
const downloadLinks = Array.from(document.querySelectorAll("a[href]")).
map((anchor) => ({
name: normalize(anchor.textContent) || normalize(anchor.getAttribute("title")) || "Documento",
url: String(anchor.getAttribute("href") || ""),
}))
.filter((item) => /\.(pdf|xls|xlsx|csv|doc|docx)(?:$|\?)/i.test(item.url));
return {
pageUrl: window.location.href,
pageTitle: document.title || "PNT",
rows,
downloadLinks,
};
})) as ScrapedSnapshot;
await context.close();
return snapshot;
} finally {
await browser.close();
}
}
export async function fetchPntLicitations(
municipality: MunicipalityConnectorInput,
options?: {
targetYear?: number;
},
): Promise<ConnectorResult> {
const warnings: string[] = [];
if (!municipality.pntSubjectId && !municipality.pntEntryUrl) {
return {
source: LicitationSource.PNT,
items: [],
warnings: ["Municipio sin identificador PNT configurado."],
};
}
const snapshot = await withRetries(() => openPntAndExtract(municipality), {
retries: licitationsConfig.maxRetries,
initialDelayMs: 900,
});
await delay(licitationsConfig.requestDelayMs);
const items = snapshot.rows
.map((row) => rowToLicitation(row, snapshot))
.filter((row): row is NormalizedLicitationInput => Boolean(row))
.filter((row) => {
if (!options?.targetYear || !row.publishDate) {
return true;
}
return row.publishDate.getUTCFullYear() === options.targetYear;
});
const spreadsheetItems = await parseSpreadsheetLinks(snapshot);
const merged = [...items, ...spreadsheetItems];
if (!merged.length && snapshot.downloadLinks.length) {
for (const link of snapshot.downloadLinks) {
const stub = linkToStub(link, snapshot.pageUrl, snapshot.pageTitle);
if (stub) {
merged.push(stub);
}
}
}
if (!snapshot.rows.length) {
warnings.push("No se detectaron tablas estructuradas en PNT para Fraccion XXVIII.");
}
return {
source: LicitationSource.PNT,
items: merged,
rawSourceUrl: snapshot.pageUrl,
warnings,
};
}

View File

@@ -0,0 +1,49 @@
import { LicitationCategory, LicitationProcedureType, LicitationSource } from "@prisma/client";
export function getProcedureTypeLabel(value: LicitationProcedureType) {
if (value === LicitationProcedureType.LICITACION_PUBLICA) {
return "Licitacion publica";
}
if (value === LicitationProcedureType.INVITACION_RESTRINGIDA) {
return "Invitacion restringida";
}
if (value === LicitationProcedureType.ADJUDICACION_DIRECTA) {
return "Adjudicacion directa";
}
return "Sin clasificar";
}
export function getCategoryLabel(value: LicitationCategory | null) {
if (value === LicitationCategory.GOODS) {
return "Bienes";
}
if (value === LicitationCategory.SERVICES) {
return "Servicios";
}
if (value === LicitationCategory.WORKS) {
return "Obra";
}
if (value === LicitationCategory.MIXED) {
return "Mixto";
}
return "Sin categoria";
}
export function getSourceLabel(source: LicitationSource) {
if (source === LicitationSource.MUNICIPAL_OPEN_PORTAL) {
return "Portal municipal (oportunidades abiertas)";
}
if (source === LicitationSource.PNT) {
return "Plataforma Nacional de Transparencia";
}
return "Portal municipal (respaldo)";
}

View File

@@ -0,0 +1,190 @@
import { createHash } from "node:crypto";
import { LicitationCategory, LicitationProcedureType } from "@prisma/client";
import type { MunicipalityConnectorInput, NormalizedDocument, NormalizedLicitationInput } from "@/lib/licitations/types";
function normalizeText(value: string | null | undefined) {
return (value ?? "").trim().replace(/\s+/g, " ");
}
function normalizeSearchText(value: string | null | undefined) {
return normalizeText(value).toLowerCase();
}
export function mapProcedureType(value: string | null | undefined): LicitationProcedureType {
const text = normalizeSearchText(value);
if (text.includes("adjudicacion directa")) {
return LicitationProcedureType.ADJUDICACION_DIRECTA;
}
if (text.includes("invitacion restringida") || text.includes("invitacion a cuando menos")) {
return LicitationProcedureType.INVITACION_RESTRINGIDA;
}
if (text.includes("licitacion publica") || text.includes("licitacion")) {
return LicitationProcedureType.LICITACION_PUBLICA;
}
return LicitationProcedureType.UNKNOWN;
}
export function mapCategory(value: string | null | undefined): LicitationCategory {
const text = normalizeSearchText(value);
const hasGoods = text.includes("bien") || text.includes("insumo") || text.includes("material");
const hasServices = text.includes("servicio") || text.includes("consultoria");
const hasWorks = text.includes("obra") || text.includes("construccion") || text.includes("infraestructura");
const matched = [hasGoods, hasServices, hasWorks].filter(Boolean).length;
if (matched > 1) {
return LicitationCategory.MIXED;
}
if (hasGoods) {
return LicitationCategory.GOODS;
}
if (hasServices) {
return LicitationCategory.SERVICES;
}
if (hasWorks) {
return LicitationCategory.WORKS;
}
return LicitationCategory.UNKNOWN;
}
export function parseDateMaybe(value: string | null | undefined): Date | null {
const raw = normalizeText(value);
if (!raw) {
return null;
}
const isoParsed = new Date(raw);
if (!Number.isNaN(isoParsed.getTime())) {
return isoParsed;
}
const normalized = raw.replace(/\./g, "/").replace(/-/g, "/");
const match = normalized.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})$/);
if (!match) {
return null;
}
const day = Number.parseInt(match[1] ?? "", 10);
const month = Number.parseInt(match[2] ?? "", 10);
const yearInput = Number.parseInt(match[3] ?? "", 10);
if (!Number.isFinite(day) || !Number.isFinite(month) || !Number.isFinite(yearInput)) {
return null;
}
const year = yearInput < 100 ? yearInput + 2000 : yearInput;
const parsed = new Date(Date.UTC(year, month - 1, day));
if (Number.isNaN(parsed.getTime())) {
return null;
}
return parsed;
}
export function parseAmountAndCurrency(value: string | null | undefined): { amount: number | null; currency: string | null } {
const raw = normalizeText(value);
if (!raw) {
return { amount: null, currency: null };
}
const currency = raw.includes("USD") || raw.includes("US$") || raw.includes("dolar") ? "USD" : "MXN";
const cleaned = raw.replace(/[^0-9.,-]/g, "");
if (!cleaned) {
return { amount: null, currency: null };
}
const normalized = cleaned.includes(",") && cleaned.includes(".") ? cleaned.replace(/,/g, "") : cleaned.replace(/,/g, ".");
const amount = Number.parseFloat(normalized);
return {
amount: Number.isFinite(amount) ? amount : null,
currency,
};
}
export function sanitizeDocuments(documents: NormalizedDocument[] | undefined) {
if (!documents?.length) {
return [];
}
const seen = new Set<string>();
const result: NormalizedDocument[] = [];
for (const doc of documents) {
const name = normalizeText(doc.name);
const url = normalizeText(doc.url);
if (!url) {
continue;
}
const key = `${name.toLowerCase()}|${url}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
result.push({
name: name || "Documento",
url,
type: normalizeText(doc.type),
});
}
return result;
}
export function buildStableRecordId(municipality: MunicipalityConnectorInput, item: NormalizedLicitationInput) {
const text = [
municipality.id,
item.title,
item.publishDate?.toISOString().slice(0, 10) ?? "",
item.amount == null ? "" : String(item.amount),
item.documents?.map((document) => document.url).sort().join("|") ?? "",
item.rawSourceUrl ?? "",
]
.map((value) => normalizeText(value))
.join("||");
return createHash("sha256").update(text).digest("hex").slice(0, 40);
}
export function pickFirstText(values: Array<string | null | undefined>) {
for (const value of values) {
const normalized = normalizeText(value);
if (normalized) {
return normalized;
}
}
return "";
}
export function pickPublishDate(values: Array<string | null | undefined>) {
for (const value of values) {
const parsed = parseDateMaybe(value);
if (parsed) {
return parsed;
}
}
return null;
}

View File

@@ -0,0 +1,156 @@
import "server-only";
import { LicitationProcedureType, type Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
type LicitationQueryParams = {
state?: string | null;
municipality?: string | null;
procedureType?: string | null;
q?: string | null;
minAmount?: string | null;
maxAmount?: string | null;
dateFrom?: string | null;
dateTo?: string | null;
includeClosed?: boolean;
take?: number;
skip?: number;
};
function parseNumber(value: string | null | undefined) {
if (!value) {
return null;
}
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : null;
}
function parseDate(value: string | null | undefined) {
if (!value) {
return null;
}
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
function parseProcedureType(value: string | null | undefined) {
if (!value) {
return null;
}
const normalized = value.trim().toUpperCase();
if (normalized in LicitationProcedureType) {
return normalized as LicitationProcedureType;
}
return null;
}
export async function listMunicipalities(filters?: { state?: string | null }) {
return prisma.municipality.findMany({
where: {
isActive: true,
...(filters?.state ? { stateCode: filters.state } : {}),
},
orderBy: [{ stateName: "asc" }, { municipalityName: "asc" }],
select: {
id: true,
stateCode: true,
stateName: true,
municipalityCode: true,
municipalityName: true,
openPortalUrl: true,
openPortalType: true,
pntSubjectId: true,
backupUrl: true,
},
});
}
export async function searchLicitations(params: LicitationQueryParams) {
const minAmount = parseNumber(params.minAmount);
const maxAmount = parseNumber(params.maxAmount);
const dateFrom = parseDate(params.dateFrom);
const dateTo = parseDate(params.dateTo);
const procedureType = parseProcedureType(params.procedureType);
const q = params.q?.trim();
const includeClosed = params.includeClosed === true;
const now = new Date();
const andClauses: Prisma.LicitationWhereInput[] = [];
if (!includeClosed) {
andClauses.push({ OR: [{ isOpen: true }, { closingDate: { gte: now } }] });
}
if (procedureType) {
andClauses.push({ procedureType });
}
if (minAmount != null || maxAmount != null) {
andClauses.push({
amount: {
...(minAmount != null ? { gte: minAmount } : {}),
...(maxAmount != null ? { lte: maxAmount } : {}),
},
});
}
if (dateFrom || dateTo) {
andClauses.push({
publishDate: {
...(dateFrom ? { gte: dateFrom } : {}),
...(dateTo ? { lte: dateTo } : {}),
},
});
}
if (q) {
andClauses.push({
OR: [
{ title: { contains: q, mode: "insensitive" } },
{ description: { contains: q, mode: "insensitive" } },
{ supplierAwarded: { contains: q, mode: "insensitive" } },
{ tenderCode: { contains: q, mode: "insensitive" } },
],
});
}
const where: Prisma.LicitationWhereInput = {
municipality: {
isActive: true,
...(params.state ? { stateCode: params.state } : {}),
...(params.municipality ? { municipalityCode: params.municipality } : {}),
},
...(andClauses.length > 0 ? { AND: andClauses } : {}),
};
const [total, records] = await Promise.all([
prisma.licitation.count({ where }),
prisma.licitation.findMany({
where,
orderBy: [{ isOpen: "desc" }, { closingDate: "asc" }, { publishDate: "desc" }, { createdAt: "desc" }],
take: params.take ?? 50,
skip: params.skip ?? 0,
include: {
municipality: {
select: {
id: true,
stateCode: true,
stateName: true,
municipalityCode: true,
municipalityName: true,
},
},
},
}),
]);
return {
total,
records,
};
}

View File

@@ -0,0 +1,252 @@
import "server-only";
import { LicitationCategory } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import type { RecommendationResultItem } from "@/lib/licitations/types";
import { toStringArray } from "@/lib/licitations/utils";
type ParsedProfile = {
id: string;
locations: {
stateCodes: string[];
municipalityCodes: string[];
};
categoriesSupported: string[];
keywords: string[];
minAmount: number | null;
maxAmount: number | null;
};
function parseLocations(value: unknown) {
if (!value || typeof value !== "object") {
return {
stateCodes: [] as string[],
municipalityCodes: [] as string[],
};
}
const stateCodes = toStringArray((value as Record<string, unknown>).stateCodes);
const municipalityCodes = toStringArray((value as Record<string, unknown>).municipalityCodes);
return {
stateCodes,
municipalityCodes,
};
}
function parseProfile(profile: {
id: string;
locations: unknown;
categoriesSupported: unknown;
keywords: unknown;
minAmount: unknown;
maxAmount: unknown;
}): ParsedProfile {
return {
id: profile.id,
locations: parseLocations(profile.locations),
categoriesSupported: toStringArray(profile.categoriesSupported).map((item) => item.toUpperCase()),
keywords: toStringArray(profile.keywords).map((item) => item.toLowerCase()),
minAmount: profile.minAmount == null ? null : Number(profile.minAmount),
maxAmount: profile.maxAmount == null ? null : Number(profile.maxAmount),
};
}
function hasAmountMatch(profile: ParsedProfile, amount: number | null) {
if (amount == null) {
return false;
}
if (profile.minAmount != null && amount < profile.minAmount) {
return false;
}
if (profile.maxAmount != null && amount > profile.maxAmount) {
return false;
}
return true;
}
function keywordOverlap(profile: ParsedProfile, text: string) {
if (!profile.keywords.length) {
return [] as string[];
}
const normalized = text.toLowerCase();
return profile.keywords.filter((keyword) => normalized.includes(keyword));
}
function normalizeCategory(category: LicitationCategory | null) {
if (!category) {
return null;
}
return category.toUpperCase();
}
async function ensureCompanyProfileForUser(userId: string) {
const existing = await prisma.companyProfile.findUnique({
where: { userId },
select: {
id: true,
locations: true,
categoriesSupported: true,
keywords: true,
minAmount: true,
maxAmount: true,
},
});
if (existing) {
return existing;
}
const organization = await prisma.organization.findUnique({
where: { userId },
select: {
id: true,
operatingState: true,
municipality: true,
industry: true,
businessPurpose: true,
},
});
const keywords = [organization?.industry, organization?.businessPurpose]
.filter((value): value is string => Boolean(value && value.trim()))
.flatMap((value) => value.split(/[,;]/g).map((entry) => entry.trim().toLowerCase()))
.filter(Boolean)
.slice(0, 20);
return prisma.companyProfile.create({
data: {
userId,
organizationId: organization?.id,
locations: {
stateCodes: organization?.operatingState ? [organization.operatingState] : [],
municipalityCodes: organization?.municipality ? [organization.municipality] : [],
},
categoriesSupported: [LicitationCategory.UNKNOWN],
keywords,
minAmount: null,
maxAmount: null,
},
select: {
id: true,
locations: true,
categoriesSupported: true,
keywords: true,
minAmount: true,
maxAmount: true,
},
});
}
export async function getLicitationRecommendationsForUser(userId: string, profileId?: string | null) {
const rawProfile = profileId
? await prisma.companyProfile.findFirst({
where: {
id: profileId,
userId,
},
select: {
id: true,
locations: true,
categoriesSupported: true,
keywords: true,
minAmount: true,
maxAmount: true,
},
})
: await ensureCompanyProfileForUser(userId);
if (!rawProfile) {
return {
profileId: null,
results: [] as RecommendationResultItem[],
};
}
const profile = parseProfile(rawProfile);
const records = await prisma.licitation.findMany({
where: {
OR: [{ isOpen: true }, { closingDate: { gte: new Date() } }],
municipality: {
isActive: true,
},
},
orderBy: [{ publishDate: "desc" }, { createdAt: "desc" }],
take: 200,
include: {
municipality: {
select: {
stateCode: true,
stateName: true,
municipalityCode: true,
municipalityName: true,
},
},
},
});
const scored = records.map((record) => {
let score = 0;
const reasons: string[] = [];
if (
profile.locations.stateCodes.includes(record.municipality.stateCode) ||
profile.locations.municipalityCodes.includes(record.municipality.municipalityCode)
) {
score += 35;
reasons.push("Coincide con tu ubicacion objetivo.");
}
const category = normalizeCategory(record.category);
if (category && profile.categoriesSupported.includes(category)) {
score += 25;
reasons.push("Coincide con tus categorias de oferta.");
}
const amountValue = record.amount == null ? null : Number(record.amount);
if (hasAmountMatch(profile, amountValue)) {
score += 20;
reasons.push("Esta dentro de tu rango de monto preferido.");
}
const overlap = keywordOverlap(profile, `${record.title} ${record.description ?? ""}`);
if (overlap.length) {
score += Math.min(20, overlap.length * 5);
reasons.push(`Coincidencia de palabras clave: ${overlap.slice(0, 3).join(", ")}.`);
}
const amountLabel = amountValue == null ? null : `${record.currency ?? "MXN"} ${amountValue.toLocaleString("es-MX", { maximumFractionDigits: 2 })}`;
return {
id: record.id,
score,
reasons,
title: record.title,
description: record.description,
amount: amountLabel,
procedureType: record.procedureType,
category: record.category,
publishDate: record.publishDate?.toISOString() ?? null,
municipalityName: record.municipality.municipalityName,
stateName: record.municipality.stateName,
source: record.source,
} satisfies RecommendationResultItem;
});
const results = scored.sort((a, b) => b.score - a.score).slice(0, 50);
return {
profileId: profile.id,
results,
};
}

392
src/lib/licitations/sync.ts Normal file
View File

@@ -0,0 +1,392 @@
import "server-only";
import { LicitationSource, Prisma, SyncRunStatus } from "@prisma/client";
import { licitationsConfig } from "@/lib/licitations/config";
import { fetchMunicipalBackupLicitations, fetchMunicipalOpenLicitations } from "@/lib/licitations/connectors/municipal";
import { fetchPntLicitations } from "@/lib/licitations/connectors/pnt";
import { buildStableRecordId, sanitizeDocuments } from "@/lib/licitations/normalize";
import { prisma } from "@/lib/prisma";
import type { ConnectorResult, MunicipalityConnectorInput, SyncStats } from "@/lib/licitations/types";
import { delay } from "@/lib/licitations/utils";
const MUNICIPALITY_SELECT = {
id: true,
stateCode: true,
stateName: true,
municipalityCode: true,
municipalityName: true,
openPortalUrl: true,
openPortalType: true,
openSyncIntervalDays: true,
lastOpenSyncAt: true,
pntSubjectId: true,
pntEntityId: true,
pntSectorId: true,
pntEntryUrl: true,
backupUrl: true,
scrapingEnabled: true,
isActive: true,
} satisfies Prisma.MunicipalitySelect;
function createEmptyStats(): SyncStats {
return {
totalFetched: 0,
inserted: 0,
updated: 0,
skipped: 0,
errors: 0,
latestPublishDate: null,
};
}
function toNullableJson(value: Record<string, unknown> | null | undefined) {
return value ? (value as Prisma.InputJsonValue) : Prisma.DbNull;
}
function getLatestPublishDate(items: { publishDate?: Date | null }[]) {
let latest: Date | null = null;
for (const item of items) {
if (!item.publishDate) {
continue;
}
if (!latest || item.publishDate > latest) {
latest = item.publishDate;
}
}
return latest;
}
function inferOpenFlag(source: LicitationSource, item: { isOpen?: boolean; closingDate?: Date | null }) {
if (typeof item.isOpen === "boolean") {
return item.isOpen;
}
if (source !== LicitationSource.MUNICIPAL_OPEN_PORTAL) {
return false;
}
if (!item.closingDate) {
return true;
}
return item.closingDate >= new Date();
}
async function upsertConnectorItems(municipality: MunicipalityConnectorInput, connectorResult: ConnectorResult): Promise<SyncStats> {
const stats = createEmptyStats();
stats.totalFetched = connectorResult.items.length;
const latestDate = getLatestPublishDate(connectorResult.items);
stats.latestPublishDate = latestDate ? latestDate.toISOString() : null;
for (const item of connectorResult.items) {
try {
const title = item.title?.trim();
if (!title) {
stats.skipped += 1;
continue;
}
const sourceRecordId = (item.sourceRecordId?.trim() || buildStableRecordId(municipality, item)).slice(0, 255);
const existing = await prisma.licitation.findUnique({
where: {
municipalityId_source_sourceRecordId: {
municipalityId: municipality.id,
source: connectorResult.source,
sourceRecordId,
},
},
select: { id: true },
});
await prisma.licitation.upsert({
where: {
municipalityId_source_sourceRecordId: {
municipalityId: municipality.id,
source: connectorResult.source,
sourceRecordId,
},
},
update: {
tenderCode: item.tenderCode ?? null,
procedureType: item.procedureType,
title,
description: item.description ?? null,
category: item.category ?? undefined,
isOpen: inferOpenFlag(connectorResult.source, item),
openingDate: item.openingDate ?? null,
closingDate: item.closingDate ?? null,
publishDate: item.publishDate ?? null,
eventDates: toNullableJson(item.eventDates ?? null),
amount: item.amount ?? null,
currency: item.currency ?? null,
status: item.status ?? null,
supplierAwarded: item.supplierAwarded ?? null,
documents: sanitizeDocuments(item.documents) as Prisma.InputJsonValue,
rawSourceUrl: item.rawSourceUrl ?? connectorResult.rawSourceUrl ?? null,
rawPayload: item.rawPayload as Prisma.InputJsonValue,
lastSeenAt: new Date(),
},
create: {
municipalityId: municipality.id,
source: connectorResult.source,
sourceRecordId,
tenderCode: item.tenderCode ?? null,
procedureType: item.procedureType,
title,
description: item.description ?? null,
category: item.category ?? undefined,
isOpen: inferOpenFlag(connectorResult.source, item),
openingDate: item.openingDate ?? null,
closingDate: item.closingDate ?? null,
publishDate: item.publishDate ?? null,
eventDates: toNullableJson(item.eventDates ?? null),
amount: item.amount ?? null,
currency: item.currency ?? null,
status: item.status ?? null,
supplierAwarded: item.supplierAwarded ?? null,
documents: sanitizeDocuments(item.documents) as Prisma.InputJsonValue,
rawSourceUrl: item.rawSourceUrl ?? connectorResult.rawSourceUrl ?? null,
rawPayload: item.rawPayload as Prisma.InputJsonValue,
lastSeenAt: new Date(),
},
});
if (existing) {
stats.updated += 1;
} else {
stats.inserted += 1;
}
} catch {
stats.errors += 1;
}
}
return stats;
}
function resolveSyncStatus(stats: SyncStats): SyncRunStatus {
if (stats.errors === 0) {
return SyncRunStatus.SUCCESS;
}
if (stats.inserted > 0 || stats.updated > 0) {
return SyncRunStatus.PARTIAL;
}
return SyncRunStatus.FAILED;
}
async function runConnectorSync(municipality: MunicipalityConnectorInput, source: LicitationSource, connector: () => Promise<ConnectorResult>) {
const syncRun = await prisma.syncRun.create({
data: {
municipalityId: municipality.id,
source,
status: SyncRunStatus.SUCCESS,
},
});
try {
const connectorResult = await connector();
const stats = await upsertConnectorItems(municipality, connectorResult);
const status = resolveSyncStatus(stats);
const error = status === SyncRunStatus.FAILED ? "No se pudieron persistir registros de licitaciones." : null;
await prisma.syncRun.update({
where: { id: syncRun.id },
data: {
finishedAt: new Date(),
status,
stats: {
...stats,
warnings: connectorResult.warnings,
},
error,
},
});
if (source === LicitationSource.MUNICIPAL_OPEN_PORTAL && status !== SyncRunStatus.FAILED) {
await prisma.municipality.update({
where: { id: municipality.id },
data: {
lastOpenSyncAt: new Date(),
},
});
}
return {
source,
status,
stats,
warnings: connectorResult.warnings,
};
} catch (error) {
const message = error instanceof Error ? error.message : "Error desconocido en sync.";
await prisma.syncRun.update({
where: { id: syncRun.id },
data: {
finishedAt: new Date(),
status: SyncRunStatus.FAILED,
stats: createEmptyStats(),
error: message,
},
});
return {
source,
status: SyncRunStatus.FAILED,
stats: createEmptyStats(),
warnings: [message],
};
}
}
async function shouldRunOpenSourceThisCycle(municipality: MunicipalityConnectorInput, force = false) {
if (force) {
return true;
}
const intervalDays = municipality.openSyncIntervalDays > 0 ? municipality.openSyncIntervalDays : 7;
const now = new Date();
if (municipality.lastOpenSyncAt) {
const nextRunAt = new Date(municipality.lastOpenSyncAt);
nextRunAt.setUTCDate(nextRunAt.getUTCDate() + intervalDays);
if (nextRunAt > now) {
return false;
}
}
const latestRun = await prisma.syncRun.findFirst({
where: {
municipalityId: municipality.id,
source: LicitationSource.MUNICIPAL_OPEN_PORTAL,
status: {
in: [SyncRunStatus.SUCCESS, SyncRunStatus.PARTIAL],
},
finishedAt: {
not: null,
},
},
orderBy: {
finishedAt: "desc",
},
select: {
finishedAt: true,
},
});
if (!latestRun?.finishedAt) {
return true;
}
const nextRunAt = new Date(latestRun.finishedAt);
nextRunAt.setUTCDate(nextRunAt.getUTCDate() + intervalDays);
return nextRunAt <= now;
}
export async function runMunicipalityLicitationsSync(
municipality: MunicipalityConnectorInput,
options?: {
targetYear?: number;
includePnt?: boolean;
force?: boolean;
},
) {
const sourceResults: Array<{ source: LicitationSource; status: SyncRunStatus; stats: SyncStats; warnings: string[] }> = [];
if (!licitationSyncEnabledForMunicipality(municipality)) {
return {
municipality,
skipped: true,
reason: "Scraping deshabilitado para municipio o entorno.",
sourceResults,
};
}
let openItemsFetched = 0;
if (municipality.openPortalUrl && (await shouldRunOpenSourceThisCycle(municipality, options?.force))) {
const openResult = await runConnectorSync(municipality, LicitationSource.MUNICIPAL_OPEN_PORTAL, () =>
fetchMunicipalOpenLicitations(municipality, {
targetYear: options?.targetYear,
}),
);
sourceResults.push(openResult);
openItemsFetched = openResult.stats.totalFetched;
await delay(licitationsConfig.requestDelayMs);
}
if (openItemsFetched === 0 && municipality.backupUrl) {
const backup = await runConnectorSync(municipality, LicitationSource.MUNICIPAL_BACKUP, () => fetchMunicipalBackupLicitations(municipality));
sourceResults.push(backup);
await delay(licitationsConfig.requestDelayMs);
}
if (options?.includePnt && (municipality.pntSubjectId || municipality.pntEntryUrl)) {
const pnt = await runConnectorSync(municipality, LicitationSource.PNT, () =>
fetchPntLicitations(municipality, {
targetYear: options?.targetYear,
}),
);
sourceResults.push(pnt);
}
return {
municipality,
skipped: false,
reason: null,
sourceResults,
};
}
function licitationSyncEnabledForMunicipality(municipality: MunicipalityConnectorInput) {
return licitationsConfig.scrapingEnabledByDefault && municipality.scrapingEnabled;
}
export async function runDailyLicitationsSync(options?: {
municipalityId?: string;
limit?: number;
skip?: number;
targetYear?: number;
includePnt?: boolean;
force?: boolean;
}) {
const municipalities = await prisma.municipality.findMany({
where: {
isActive: true,
...(options?.municipalityId ? { id: options.municipalityId } : {}),
},
orderBy: [{ stateCode: "asc" }, { municipalityName: "asc" }],
take: options?.limit ?? licitationsConfig.maxMunicipalitiesPerRun,
skip: options?.skip ?? 0,
select: MUNICIPALITY_SELECT,
});
const results = [] as Awaited<ReturnType<typeof runMunicipalityLicitationsSync>>[];
for (const municipality of municipalities) {
const result = await runMunicipalityLicitationsSync(municipality, {
targetYear: options?.targetYear,
includePnt: options?.includePnt,
force: options?.force,
});
results.push(result);
await delay(licitationsConfig.requestDelayMs);
}
return {
processedMunicipalities: municipalities.length,
skippedMunicipalities: options?.skip ?? 0,
results,
};
}

View File

@@ -0,0 +1,78 @@
import type { LicitationCategory, LicitationProcedureType, LicitationSource, MunicipalOpenPortalType } from "@prisma/client";
export type MunicipalityConnectorInput = {
id: string;
stateCode: string;
stateName: string;
municipalityCode: string;
municipalityName: string;
openPortalUrl: string | null;
openPortalType: MunicipalOpenPortalType;
openSyncIntervalDays: number;
lastOpenSyncAt: Date | null;
pntSubjectId: string | null;
pntEntityId: string | null;
pntSectorId: string | null;
pntEntryUrl: string | null;
backupUrl: string | null;
scrapingEnabled: boolean;
isActive: boolean;
};
export type NormalizedDocument = {
name: string;
url: string;
type?: string;
};
export type NormalizedLicitationInput = {
sourceRecordId?: string | null;
tenderCode?: string | null;
procedureType: LicitationProcedureType;
title: string;
description?: string | null;
category?: LicitationCategory | null;
isOpen?: boolean;
openingDate?: Date | null;
closingDate?: Date | null;
publishDate?: Date | null;
eventDates?: Record<string, string> | null;
amount?: number | null;
currency?: string | null;
status?: string | null;
supplierAwarded?: string | null;
documents?: NormalizedDocument[];
rawSourceUrl?: string | null;
rawPayload: Record<string, unknown>;
};
export type ConnectorResult = {
source: LicitationSource;
items: NormalizedLicitationInput[];
rawSourceUrl?: string | null;
warnings: string[];
};
export type SyncStats = {
totalFetched: number;
inserted: number;
updated: number;
skipped: number;
errors: number;
latestPublishDate: string | null;
};
export type RecommendationResultItem = {
id: string;
score: number;
reasons: string[];
title: string;
description: string | null;
amount: string | null;
procedureType: string;
category: string | null;
publishDate: string | null;
municipalityName: string;
stateName: string;
source: string;
};

View File

@@ -0,0 +1,78 @@
import "server-only";
export async function delay(ms: number) {
if (ms <= 0) {
return;
}
await new Promise((resolve) => setTimeout(resolve, ms));
}
export async function withRetries<T>(
fn: (attempt: number) => Promise<T>,
options?: {
retries?: number;
initialDelayMs?: number;
maxDelayMs?: number;
},
) {
const retries = options?.retries ?? 3;
const initialDelayMs = options?.initialDelayMs ?? 500;
const maxDelayMs = options?.maxDelayMs ?? 4000;
let attempt = 0;
let lastError: unknown;
while (attempt < retries) {
attempt += 1;
try {
return await fn(attempt);
} catch (error) {
lastError = error;
if (attempt >= retries) {
break;
}
const waitMs = Math.min(maxDelayMs, initialDelayMs * 2 ** (attempt - 1));
await delay(waitMs);
}
}
throw lastError;
}
export function normalizeUrl(rawUrl: string | null | undefined, base?: string | null) {
if (!rawUrl) {
return null;
}
try {
if (base) {
return new URL(rawUrl, base).toString();
}
return new URL(rawUrl).toString();
} catch {
return null;
}
}
export function toStringArray(value: unknown) {
if (!Array.isArray(value)) {
return [];
}
return value
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
.filter((entry) => entry.length > 0);
}
export function toObjectArray(value: unknown) {
if (!Array.isArray(value)) {
return [];
}
return value.filter((entry) => entry && typeof entry === "object") as Record<string, unknown>[];
}

View File

@@ -0,0 +1,155 @@
import "server-only";
import { PDFParse } from "pdf-parse";
export type ActaExtractedData = {
legalName: string | null;
rfc: string | null;
incorporationDate: string | null;
deedNumber: string | null;
notaryName: string | null;
legalRepresentative: string | null;
fiscalAddress: string | null;
businessPurpose: string | null;
stateOfIncorporation: string | null;
extractedFields: string[];
confidence: "low" | "medium" | "high";
textSnippet: string;
};
function cleanValue(value: string | null | undefined) {
if (!value) {
return null;
}
const compact = value.replace(/\s+/g, " ").trim();
if (!compact) {
return null;
}
return compact.length > 320 ? compact.slice(0, 320) : compact;
}
function normalizeSearchText(value: string) {
return value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toUpperCase();
}
function extractByPatterns(text: string, patterns: RegExp[]) {
for (const pattern of patterns) {
const match = text.match(pattern);
if (match?.[1]) {
const value = cleanValue(match[1]);
if (value) {
return value;
}
}
}
return null;
}
function findLineWithLabel(lines: string[], labels: string[]) {
const normalizedLabels = labels.map((label) => normalizeSearchText(label));
for (let index = 0; index < lines.length; index += 1) {
const currentLine = lines[index];
const normalizedLine = normalizeSearchText(currentLine);
const hasLabel = normalizedLabels.some((label) => normalizedLine.includes(label));
if (!hasLabel) {
continue;
}
const inline = currentLine.split(/[:\-]/).slice(1).join("-").trim();
const inlineValue = cleanValue(inline);
if (inlineValue) {
return inlineValue;
}
const nextLine = cleanValue(lines[index + 1] ?? "");
if (nextLine) {
return nextLine;
}
}
return null;
}
export async function extractActaDataFromPdf(pdfBuffer: Buffer): Promise<ActaExtractedData> {
const parser = new PDFParse({ data: pdfBuffer });
const parsed = await parser.getText();
await parser.destroy();
const rawText = parsed.text?.replace(/\u0000/g, " ").trim() ?? "";
if (!rawText) {
throw new Error("No fue posible leer texto del PDF.");
}
const compactText = rawText.replace(/\s+/g, " ");
const lines = rawText
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const legalName = extractByPatterns(compactText, [
/(?:DENOMINACION|RAZON)\s+SOCIAL(?:\s+DE\s+LA\s+SOCIEDAD)?\s*[:\-]?\s*([A-Z0-9&.,'"\-() ]{5,140})/i,
/SOCIEDAD\s+DENOMINADA\s*[:\-]?\s*([A-Z0-9&.,'"\-() ]{5,140})/i,
]);
const rfcMatch = compactText.match(/\b[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}\b/i);
const rfc = cleanValue(rfcMatch?.[0] ?? null);
const incorporationDate = extractByPatterns(compactText, [
/(?:FECHA\s+DE\s+CONSTITUCION|SE\s+CONSTITUYO\s+EL|CONSTITUIDA\s+EL)\s*[:\-]?\s*([A-Z0-9,./\-\s]{8,80})/i,
/A\s+LOS\s+([A-Z0-9\s,./-]{10,80})\s+SE\s+CONSTITUYE/i,
]);
const deedNumber = extractByPatterns(compactText, [
/(?:ESCRITURA(?:\s+PUBLICA)?(?:\s+NUMERO|\s+NO\.?|\s+NÚMERO)?)\s*[:#\-]?\s*([A-Z0-9\-\/]{2,40})/i,
]);
const notaryName = findLineWithLabel(lines, ["NOTARIO PUBLICO", "NOTARIO", "CORREDOR PUBLICO"]);
const legalRepresentative = findLineWithLabel(lines, ["REPRESENTANTE LEGAL", "APODERADO LEGAL"]);
const fiscalAddress = findLineWithLabel(lines, ["DOMICILIO FISCAL", "DOMICILIO SOCIAL", "DOMICILIO"]);
const businessPurpose = findLineWithLabel(lines, ["OBJETO SOCIAL", "OBJETO"]);
const stateOfIncorporation = findLineWithLabel(lines, ["ESTADO", "ENTIDAD FEDERATIVA"]);
const extractedFields = Object.entries({
legalName,
rfc,
incorporationDate,
deedNumber,
notaryName,
legalRepresentative,
fiscalAddress,
businessPurpose,
stateOfIncorporation,
})
.filter(([, value]) => Boolean(value))
.map(([field]) => field);
const confidence: ActaExtractedData["confidence"] =
extractedFields.length >= 6 ? "high" : extractedFields.length >= 3 ? "medium" : "low";
return {
legalName,
rfc,
incorporationDate,
deedNumber,
notaryName,
legalRepresentative,
fiscalAddress,
businessPurpose,
stateOfIncorporation,
extractedFields,
confidence,
textSnippet: rawText.slice(0, 1600),
};
}

View File

@@ -0,0 +1,62 @@
import "server-only";
import { createHash, randomUUID } from "node:crypto";
import { mkdir, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
export const MAX_ACTA_PDF_BYTES = 15 * 1024 * 1024;
const ACTA_STORAGE_ROOT = path.join(process.cwd(), "storage", "actas");
export type StoredActaPdf = {
fileName: string;
storedFileName: string;
filePath: string;
mimeType: string;
sizeBytes: number;
checksumSha256: string;
};
function sanitizeFileName(input: string) {
const normalized = input.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-");
const fallback = normalized || "acta-constitutiva.pdf";
return fallback.endsWith(".pdf") ? fallback : `${fallback}.pdf`;
}
export async function storeActaPdf(userId: string, originalFileName: string, pdfBuffer: Buffer): Promise<StoredActaPdf> {
const safeOriginalName = sanitizeFileName(originalFileName);
const userDirectory = path.join(ACTA_STORAGE_ROOT, userId);
const storedFileName = `${Date.now()}-${randomUUID()}.pdf`;
const absoluteFilePath = path.join(userDirectory, storedFileName);
await mkdir(userDirectory, { recursive: true });
await writeFile(absoluteFilePath, pdfBuffer);
return {
fileName: safeOriginalName,
storedFileName,
filePath: path.join("storage", "actas", userId, storedFileName).replace(/\\/g, "/"),
mimeType: "application/pdf",
sizeBytes: pdfBuffer.byteLength,
checksumSha256: createHash("sha256").update(pdfBuffer).digest("hex"),
};
}
export async function removeStoredActaPdf(relativeFilePath: string | null | undefined) {
if (!relativeFilePath) {
return;
}
const storageRoot = path.resolve(process.cwd(), "storage");
const absolutePath = path.resolve(process.cwd(), relativeFilePath);
if (!absolutePath.startsWith(`${storageRoot}${path.sep}`)) {
return;
}
try {
await unlink(absolutePath);
} catch {
// Ignore delete errors (already deleted or inaccessible).
}
}

View File

@@ -0,0 +1,96 @@
import { describe, expect, it, vi } from "vitest";
import { analyzePdf } from "@/lib/pdf/analyzePdf";
import { PdfUnreadableError } from "@/lib/pdf/errors";
describe("analyzePdf", () => {
it("returns direct method for PDFs with enough extracted text", async () => {
const extractMock = vi.fn(async () => ({ text: "texto ".repeat(50), numPages: 2 }));
const ocrMock = vi.fn(async () => undefined);
const result = await analyzePdf(Buffer.from("pdf"), {
deps: {
extractTextFromPdfBuffer: extractMock,
ocrPdfToSearchablePdf: ocrMock,
},
});
expect(result.methodUsed).toBe("direct");
expect(result.text.length).toBeGreaterThan(0);
expect(ocrMock).not.toHaveBeenCalled();
});
it("uses OCR fallback when direct extraction is too short", async () => {
const extractMock = vi
.fn()
.mockResolvedValueOnce({ text: "", numPages: 1 })
.mockResolvedValueOnce({ text: "texto recuperado por ocr ".repeat(16), numPages: 1 });
const ocrMock = vi.fn(async () => undefined);
const readFileMock = vi.fn(async () => Buffer.from("pdf-ocr"));
const unlinkMock = vi.fn(async () => undefined);
const result = await analyzePdf(Buffer.from("pdf"), {
deps: {
extractTextFromPdfBuffer: extractMock,
ocrPdfToSearchablePdf: ocrMock,
writePdfBufferToTempFile: async () => "/tmp/input.pdf",
createTempPdfPath: () => "/tmp/output.ocr.pdf",
readFile: readFileMock,
unlink: unlinkMock,
},
});
expect(result.methodUsed).toBe("ocr");
expect(result.text.length).toBeGreaterThan(0);
expect(ocrMock).toHaveBeenCalledWith("/tmp/input.pdf", "/tmp/output.ocr.pdf", "spa+eng");
expect(readFileMock).toHaveBeenCalledWith("/tmp/output.ocr.pdf");
expect(unlinkMock).toHaveBeenCalledTimes(2);
});
it("uses OCR fallback when direct extraction throws", async () => {
const extractMock = vi
.fn()
.mockRejectedValueOnce(new Error("direct parse failed"))
.mockResolvedValueOnce({ text: "texto recuperado por ocr ".repeat(16), numPages: 1 });
const ocrMock = vi.fn(async () => undefined);
const result = await analyzePdf(Buffer.from("pdf"), {
deps: {
extractTextFromPdfBuffer: extractMock,
ocrPdfToSearchablePdf: ocrMock,
writePdfBufferToTempFile: async () => "/tmp/input.pdf",
createTempPdfPath: () => "/tmp/output.ocr.pdf",
readFile: async () => Buffer.from("pdf-ocr"),
unlink: async () => undefined,
},
});
expect(result.methodUsed).toBe("ocr");
expect(result.warnings.some((warning) => warning.includes("extraccion directa fallo"))).toBe(true);
expect(ocrMock).toHaveBeenCalledTimes(1);
});
it("uses pdftotext fallback when OCR output is unreadable for pdf parser", async () => {
const extractMock = vi
.fn()
.mockResolvedValueOnce({ text: "", numPages: 1 })
.mockRejectedValueOnce(new PdfUnreadableError("cannot parse ocr pdf"));
const result = await analyzePdf(Buffer.from("pdf"), {
deps: {
extractTextFromPdfBuffer: extractMock,
ocrPdfToSearchablePdf: async () => undefined,
writePdfBufferToTempFile: async () => "/tmp/input.pdf",
createTempPdfPath: () => "/tmp/output.ocr.pdf",
readFile: async () => Buffer.from("pdf-ocr"),
unlink: async () => undefined,
extractTextWithPdftotext: async () => "texto recuperado via pdftotext ".repeat(12),
getPdfPageCountWithPdfinfo: async () => 1,
},
});
expect(result.methodUsed).toBe("ocr");
expect(result.warnings.some((warning) => warning.includes("pdftotext"))).toBe(true);
});
});

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { extractTextFromPdfBuffer } from "@/lib/pdf/extractText";
function buildSimpleTextPdfBuffer() {
const pdf = `%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>
endobj
4 0 obj
<< /Length 65 >>
stream
BT
/F1 24 Tf
100 700 Td
(Hello PDF Text Extraction) Tj
ET
endstream
endobj
5 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
xref
0 6
0000000000 65535 f
0000000010 00000 n
0000000060 00000 n
0000000117 00000 n
0000000243 00000 n
0000000359 00000 n
trailer
<< /Root 1 0 R /Size 6 >>
startxref
429
%%EOF`;
return Buffer.from(pdf, "utf8");
}
describe("extractTextFromPdfBuffer", () => {
it("extracts text and page count from a text-based PDF", async () => {
const result = await extractTextFromPdfBuffer(buildSimpleTextPdfBuffer());
expect(result.numPages).toBe(1);
expect(result.text).toContain("Hello PDF Text Extraction");
});
});

159
src/lib/pdf/analyzePdf.ts Normal file
View File

@@ -0,0 +1,159 @@
import { readFile, unlink } from "node:fs/promises";
import { extractTextFromPdfBuffer, type PdfTextExtraction } from "@/lib/pdf/extractText";
import {
createTempPdfPath,
extractTextWithPdftotext,
getPdfPageCountWithPdfinfo,
ocrPdfToSearchablePdf,
writePdfBufferToTempFile,
} from "@/lib/pdf/ocr";
import { OcrFailedError, PdfEncryptedError, PdfNoTextDetectedError, PdfUnreadableError } from "@/lib/pdf/errors";
const DEFAULT_MIN_DOC_CHARS = 200;
const DEFAULT_MIN_CHARS_PER_PAGE = 50;
const MIN_TEXT_CHARS_TO_ALLOW_DIRECT_FALLBACK = 30;
export type AnalyzePdfResult = {
text: string;
methodUsed: "direct" | "ocr";
numPages: number;
warnings: string[];
};
type AnalyzePdfDeps = {
extractTextFromPdfBuffer: (buffer: Buffer) => Promise<PdfTextExtraction>;
ocrPdfToSearchablePdf: (inputPath: string, outputPath: string, lang: string) => Promise<void>;
writePdfBufferToTempFile: (buffer: Buffer, prefix?: string) => Promise<string>;
createTempPdfPath: (prefix?: string) => string;
readFile: (path: string) => Promise<Buffer>;
unlink: (path: string) => Promise<void>;
extractTextWithPdftotext: (inputPath: string) => Promise<string>;
getPdfPageCountWithPdfinfo: (inputPath: string) => Promise<number>;
};
export type AnalyzePdfOptions = {
ocrLanguage?: string;
minDocChars?: number;
minCharsPerPage?: number;
deps?: Partial<AnalyzePdfDeps>;
};
function getDefaults(): AnalyzePdfDeps {
return {
extractTextFromPdfBuffer,
ocrPdfToSearchablePdf,
writePdfBufferToTempFile,
createTempPdfPath,
readFile: (path) => readFile(path),
unlink: (path) => unlink(path),
extractTextWithPdftotext,
getPdfPageCountWithPdfinfo,
};
}
function getTextLength(text: string) {
return text.trim().length;
}
function shouldApplyOcr(text: string, numPages: number, minDocChars: number, minCharsPerPage: number) {
const totalChars = getTextLength(text);
const safePages = Math.max(numPages, 1);
const charsPerPage = totalChars / safePages;
return totalChars < minDocChars || charsPerPage < minCharsPerPage;
}
async function safeDelete(path?: string, deleteFile?: (path: string) => Promise<void>) {
if (!path || !deleteFile) {
return;
}
await deleteFile(path).catch(() => undefined);
}
export async function analyzePdf(buffer: Buffer, options: AnalyzePdfOptions = {}): Promise<AnalyzePdfResult> {
const deps = { ...getDefaults(), ...(options.deps ?? {}) };
const minDocChars = options.minDocChars ?? DEFAULT_MIN_DOC_CHARS;
const minCharsPerPage = options.minCharsPerPage ?? DEFAULT_MIN_CHARS_PER_PAGE;
const ocrLanguage = options.ocrLanguage ?? "spa+eng";
const warnings: string[] = [];
let direct: PdfTextExtraction | null = null;
try {
direct = await deps.extractTextFromPdfBuffer(buffer);
} catch (error) {
if (error instanceof PdfEncryptedError) {
throw error;
}
warnings.push("La extraccion directa fallo; se intento OCR.");
}
if (direct && !shouldApplyOcr(direct.text, direct.numPages, minDocChars, minCharsPerPage)) {
return {
text: direct.text,
methodUsed: "direct",
numPages: direct.numPages,
warnings,
};
}
if (direct) {
warnings.push("Se detecto poco texto en extraccion directa; se intento OCR.");
}
let inputPath: string | undefined;
let outputPath: string | undefined;
try {
inputPath = await deps.writePdfBufferToTempFile(buffer, "acta-input");
outputPath = deps.createTempPdfPath("acta-output");
await deps.ocrPdfToSearchablePdf(inputPath, outputPath, ocrLanguage);
const ocrBuffer = await deps.readFile(outputPath);
let ocrResult: PdfTextExtraction;
try {
ocrResult = await deps.extractTextFromPdfBuffer(ocrBuffer);
} catch (error) {
if (!(error instanceof PdfUnreadableError)) {
throw error;
}
const fallbackText = await deps.extractTextWithPdftotext(outputPath);
const fallbackPages = (await deps.getPdfPageCountWithPdfinfo(outputPath)) || direct?.numPages || 1;
warnings.push("Se uso pdftotext como respaldo tras OCR.");
ocrResult = {
text: fallbackText,
numPages: fallbackPages,
};
}
if (shouldApplyOcr(ocrResult.text, ocrResult.numPages, minDocChars, minCharsPerPage)) {
throw new PdfNoTextDetectedError();
}
return {
text: ocrResult.text,
methodUsed: "ocr",
numPages: ocrResult.numPages,
warnings,
};
} catch (error) {
const normalizedError = error instanceof PdfUnreadableError ? new OcrFailedError("OCR genero un resultado no legible.", { cause: error }) : error;
if (direct && getTextLength(direct.text) >= MIN_TEXT_CHARS_TO_ALLOW_DIRECT_FALLBACK) {
warnings.push("OCR no estuvo disponible; se devolvio texto directo del PDF.");
return {
text: direct.text,
methodUsed: "direct",
numPages: direct.numPages,
warnings,
};
}
throw normalizedError;
} finally {
await Promise.all([safeDelete(inputPath, deps.unlink), safeDelete(outputPath, deps.unlink)]);
}
}

Some files were not shown because too many files have changed in this diff Show More