initial push
This commit is contained in:
288
src/app/admin/page.tsx
Normal file
288
src/app/admin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
src/app/api/acta/analyze/route.ts
Normal file
3
src/app/api/acta/analyze/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export { POST } from "@/app/api/onboarding/acta/route";
|
||||
52
src/app/api/admin/content/create/route.ts
Normal file
52
src/app/api/admin/content/create/route.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
54
src/app/api/admin/content/update/route.ts
Normal file
54
src/app/api/admin/content/update/route.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
72
src/app/api/admin/scoring/route.ts
Normal file
72
src/app/api/admin/scoring/route.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
42
src/app/api/admin/sync/route.ts
Normal file
42
src/app/api/admin/sync/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
38
src/app/api/admin/users/role/route.ts
Normal file
38
src/app/api/admin/users/role/route.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
87
src/app/api/auth/login/route.ts
Normal file
87
src/app/api/auth/login/route.ts
Normal 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" });
|
||||
}
|
||||
}
|
||||
11
src/app/api/auth/logout/route.ts
Normal file
11
src/app/api/auth/logout/route.ts
Normal 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;
|
||||
}
|
||||
75
src/app/api/auth/register/route.ts
Normal file
75
src/app/api/auth/register/route.ts
Normal 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" });
|
||||
}
|
||||
}
|
||||
41
src/app/api/auth/resend/route.ts
Normal file
41
src/app/api/auth/resend/route.ts
Normal 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" });
|
||||
}
|
||||
}
|
||||
54
src/app/api/cron/licitations-sync/route.ts
Normal file
54
src/app/api/cron/licitations-sync/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
160
src/app/api/diagnostic/response/route.ts
Normal file
160
src/app/api/diagnostic/response/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
17
src/app/api/licitations/recommendations/route.ts
Normal file
17
src/app/api/licitations/recommendations/route.ts
Normal 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);
|
||||
}
|
||||
45
src/app/api/licitations/route.ts
Normal file
45
src/app/api/licitations/route.ts
Normal 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);
|
||||
}
|
||||
19
src/app/api/municipalities/route.ts
Normal file
19
src/app/api/municipalities/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
408
src/app/api/onboarding/acta/route.ts
Normal file
408
src/app/api/onboarding/acta/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
157
src/app/api/onboarding/route.ts
Normal file
157
src/app/api/onboarding/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
134
src/app/api/strategic-diagnostic/evidence/route.ts
Normal file
134
src/app/api/strategic-diagnostic/evidence/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
38
src/app/api/strategic-diagnostic/route.ts
Normal file
38
src/app/api/strategic-diagnostic/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
191
src/app/api/talleres/evidence/route.ts
Normal file
191
src/app/api/talleres/evidence/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
120
src/app/api/talleres/progress/route.ts
Normal file
120
src/app/api/talleres/progress/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
33
src/app/api/talleres/route.ts
Normal file
33
src/app/api/talleres/route.ts
Normal 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
355
src/app/dashboard/page.tsx
Normal 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
64
src/app/dev/db/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
src/app/diagnostic/[moduleId]/page.tsx
Normal file
62
src/app/diagnostic/[moduleId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
src/app/diagnostic/page.tsx
Normal file
79
src/app/diagnostic/page.tsx
Normal 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
33
src/app/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
204
src/app/licitations/[id]/page.tsx
Normal file
204
src/app/licitations/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
261
src/app/licitations/page.tsx
Normal file
261
src/app/licitations/page.tsx
Normal 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
18
src/app/loading.tsx
Normal 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
83
src/app/login/page.tsx
Normal 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
52
src/app/manual/page.tsx
Normal 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
152
src/app/onboarding/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
751
src/app/page.tsx
751
src/app/page.tsx
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
76
src/app/recommendations/page.tsx
Normal file
76
src/app/recommendations/page.tsx
Normal 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
90
src/app/register/page.tsx
Normal 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
87
src/app/results/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
src/app/strategic-diagnostic/page.tsx
Normal file
78
src/app/strategic-diagnostic/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
src/app/talleres-desarrollo/page.tsx
Normal file
73
src/app/talleres-desarrollo/page.tsx
Normal 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
147
src/app/verify/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
233
src/components/app/dashboard-maturity-section.tsx
Normal file
233
src/components/app/dashboard-maturity-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
src/components/app/dashboard-view.tsx
Normal file
86
src/components/app/dashboard-view.tsx
Normal 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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
40
src/components/app/kontia-mark.tsx
Normal file
40
src/components/app/kontia-mark.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
src/components/app/licitations-sync-button.tsx
Normal file
53
src/components/app/licitations-sync-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/components/app/module-bars-card.tsx
Normal file
36
src/components/app/module-bars-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
src/components/app/module-card.tsx
Normal file
41
src/components/app/module-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
359
src/components/app/module-questionnaire.tsx
Normal file
359
src/components/app/module-questionnaire.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
737
src/components/app/onboarding-wizard.tsx
Normal file
737
src/components/app/onboarding-wizard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
src/components/app/page-shell.tsx
Normal file
110
src/components/app/page-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/components/app/question-card.tsx
Normal file
30
src/components/app/question-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
src/components/app/radar-chart-card.tsx
Normal file
42
src/components/app/radar-chart-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/components/app/score-card.tsx
Normal file
26
src/components/app/score-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1300
src/components/app/strategic-diagnostic-wizard.tsx
Normal file
1300
src/components/app/strategic-diagnostic-wizard.tsx
Normal file
File diff suppressed because it is too large
Load Diff
438
src/components/app/talleres-desarrollo-view.tsx
Normal file
438
src/components/app/talleres-desarrollo-view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
src/components/ui/accordion.tsx
Normal file
40
src/components/ui/accordion.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
src/components/ui/badge.tsx
Normal file
27
src/components/ui/badge.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
42
src/components/ui/button.tsx
Normal file
42
src/components/ui/button.tsx
Normal 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";
|
||||
19
src/components/ui/card.tsx
Normal file
19
src/components/ui/card.tsx
Normal 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} />;
|
||||
}
|
||||
58
src/components/ui/dialog.tsx
Normal file
58
src/components/ui/dialog.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
19
src/components/ui/input.tsx
Normal file
19
src/components/ui/input.tsx
Normal 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";
|
||||
6
src/components/ui/label.tsx
Normal file
6
src/components/ui/label.tsx
Normal 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} />;
|
||||
}
|
||||
27
src/components/ui/progress.tsx
Normal file
27
src/components/ui/progress.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/components/ui/stepper.tsx
Normal file
35
src/components/ui/stepper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
src/components/ui/tabs.tsx
Normal file
46
src/components/ui/tabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
src/lib/__tests__/scoring-core.test.ts
Normal file
66
src/lib/__tests__/scoring-core.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
27
src/lib/__tests__/session-token.test.ts
Normal file
27
src/lib/__tests__/session-token.test.ts
Normal 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
61
src/lib/auth/admin.ts
Normal 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;
|
||||
}
|
||||
9
src/lib/auth/constants.ts
Normal file
9
src/lib/auth/constants.ts
Normal 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
31
src/lib/auth/password.ts
Normal 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);
|
||||
}
|
||||
66
src/lib/auth/session-token.ts
Normal file
66
src/lib/auth/session-token.ts
Normal 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
47
src/lib/auth/session.ts
Normal 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
101
src/lib/auth/user.ts
Normal 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;
|
||||
}
|
||||
113
src/lib/auth/verification.ts
Normal file
113
src/lib/auth/verification.ts
Normal 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
52
src/lib/content-pages.ts
Normal 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
263
src/lib/diagnostic.ts
Normal 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
106
src/lib/email/smtp.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
132
src/lib/extraction/__tests__/aiExtractFields.test.ts
Normal file
132
src/lib/extraction/__tests__/aiExtractFields.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
95
src/lib/extraction/__tests__/extractFields.test.ts
Normal file
95
src/lib/extraction/__tests__/extractFields.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
386
src/lib/extraction/aiExtractFields.ts
Normal file
386
src/lib/extraction/aiExtractFields.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
789
src/lib/extraction/extractFields.ts
Normal file
789
src/lib/extraction/extractFields.ts
Normal 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);
|
||||
}
|
||||
81
src/lib/extraction/schema.ts
Normal file
81
src/lib/extraction/schema.ts
Normal 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
75
src/lib/http/url.ts
Normal 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;
|
||||
}
|
||||
38
src/lib/licitations/config.ts
Normal file
38
src/lib/licitations/config.ts
Normal 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 ?? "",
|
||||
};
|
||||
1258
src/lib/licitations/connectors/municipal.ts
Normal file
1258
src/lib/licitations/connectors/municipal.ts
Normal file
File diff suppressed because it is too large
Load Diff
468
src/lib/licitations/connectors/pnt.ts
Normal file
468
src/lib/licitations/connectors/pnt.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
49
src/lib/licitations/labels.ts
Normal file
49
src/lib/licitations/labels.ts
Normal 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)";
|
||||
}
|
||||
190
src/lib/licitations/normalize.ts
Normal file
190
src/lib/licitations/normalize.ts
Normal 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;
|
||||
}
|
||||
156
src/lib/licitations/query.ts
Normal file
156
src/lib/licitations/query.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
252
src/lib/licitations/recommendations.ts
Normal file
252
src/lib/licitations/recommendations.ts
Normal 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
392
src/lib/licitations/sync.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
78
src/lib/licitations/types.ts
Normal file
78
src/lib/licitations/types.ts
Normal 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;
|
||||
};
|
||||
78
src/lib/licitations/utils.ts
Normal file
78
src/lib/licitations/utils.ts
Normal 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>[];
|
||||
}
|
||||
155
src/lib/onboarding/acta-extraction.ts
Normal file
155
src/lib/onboarding/acta-extraction.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
62
src/lib/onboarding/acta-storage.ts
Normal file
62
src/lib/onboarding/acta-storage.ts
Normal 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).
|
||||
}
|
||||
}
|
||||
96
src/lib/pdf/__tests__/analyzePdf.test.ts
Normal file
96
src/lib/pdf/__tests__/analyzePdf.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
52
src/lib/pdf/__tests__/extractText.test.ts
Normal file
52
src/lib/pdf/__tests__/extractText.test.ts
Normal 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
159
src/lib/pdf/analyzePdf.ts
Normal 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
Reference in New Issue
Block a user