initial push
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -19,6 +19,7 @@
|
||||
|
||||
# production
|
||||
/build
|
||||
/storage
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
BIN
Estructura_municipal_dic22.pdf
Normal file
BIN
Estructura_municipal_dic22.pdf
Normal file
Binary file not shown.
595
OCR_PDF_Lookup_instructions.md
Normal file
595
OCR_PDF_Lookup_instructions.md
Normal file
@@ -0,0 +1,595 @@
|
||||
We are going to be looking up certain information that is relevant to what we are loking for. Im going to provide you with a "dictionary" of some of the most common things we are going to be looking for and with that, we are going too be able to "fill in" that information for that company profile and fill in when applicable in onboarding/for later steps. Below is the dictionary, please understand this and modify our initial analysis during onboarding so that we can pre-fill as much of the information that we are asking for. If the information is not available, then we must leave blank and client fills in.
|
||||
|
||||
|
||||
version: "mx_acta_constitutiva_reference_v1"
|
||||
doc_types_supported:
|
||||
- "acta constitutiva / escritura constitutiva"
|
||||
- "escritura pública (constitución / protocolización)"
|
||||
- "instrumento notarial (sociedad mercantil)"
|
||||
normalization_recommended:
|
||||
- "lowercase + remove extra spaces"
|
||||
- "normalize accents (opcional) + normalize punctuation (.,;:)"
|
||||
- "normalize OCR confusions: 0/O, 1/I, rn/m, cv/c.v./c v"
|
||||
- "build a 'lines' array + also a 'full_text' string"
|
||||
- "detect clause blocks: regex on (cl[aá]usula|clausula|primera|segunda|tercera|cuarta|quinta|sexta|séptima|octava|novena|décima|undécima...)"
|
||||
- "keep page references if possible (for audit trail)"
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 0) METADATOS DEL DOCUMENTO (no del negocio, sino del instrumento)
|
||||
# ------------------------------------------------------------
|
||||
document_metadata:
|
||||
- key: "instrument_type"
|
||||
type: "string"
|
||||
anchors:
|
||||
- "escritura pública"
|
||||
- "escritura publica"
|
||||
- "instrumento número"
|
||||
- "instrumento numero"
|
||||
- "acta constitutiva"
|
||||
- "constitución de la sociedad"
|
||||
regex_hints:
|
||||
- "(escritura\\s+p[úu]blica|acta\\s+constitutiva|constituci[oó]n\\s+de\\s+la\\s+sociedad)"
|
||||
notes:
|
||||
- "Útil para clasificar si es constitución o modificación."
|
||||
|
||||
- key: "instrument_number"
|
||||
type: "string"
|
||||
anchors:
|
||||
- "escritura número"
|
||||
- "escritura no."
|
||||
- "instrumento número"
|
||||
- "instrumento no."
|
||||
- "número de escritura"
|
||||
regex_hints:
|
||||
- "(escritura|instrumento)\\s*(n[uú]mero|no\\.?|n\\.?)[\\s:]*([0-9]{1,10})"
|
||||
notes:
|
||||
- "OCR puede separar 'No.' o confundir ':'; usa fuzzy matching."
|
||||
|
||||
- key: "protocol_volume_book"
|
||||
type: "string"
|
||||
anchors:
|
||||
- "libro"
|
||||
- "tomo"
|
||||
- "volumen"
|
||||
- "protocolo"
|
||||
regex_hints:
|
||||
- "(tomo|libro|volumen)\\s*[:#]?\\s*([0-9]{1,10}|[ivxlcdm]+)"
|
||||
notes:
|
||||
- "A veces hay 'tomo' y 'libro'; guárdalo tal cual."
|
||||
|
||||
- key: "instrument_date"
|
||||
type: "date"
|
||||
anchors:
|
||||
- "a los"
|
||||
- "en la ciudad de"
|
||||
- "siendo las"
|
||||
- "del año"
|
||||
- "de dos mil"
|
||||
regex_hints:
|
||||
- "(a\\s+los?\\s+.*?\\s+del\\s+mes\\s+de\\s+.*?\\s+del\\s+a[nñ]o\\s+.*)"
|
||||
- "([0-3]?\\d)\\s+de\\s+(enero|febrero|marzo|abril|mayo|junio|julio|agosto|septiembre|setiembre|octubre|noviembre|diciembre)\\s+de\\s+(\\d{4})"
|
||||
notes:
|
||||
- "Mucho viene en letra ('dos mil veintidós'). Necesitas parser de números en español."
|
||||
- "Guarda también raw_text + parsed_date + confidence."
|
||||
|
||||
- key: "place_of_granting"
|
||||
type: "string"
|
||||
anchors:
|
||||
- "en la ciudad de"
|
||||
- "municipio de"
|
||||
- "estado de"
|
||||
- "comparecen"
|
||||
regex_hints:
|
||||
- "en\\s+la\\s+ciudad\\s+de\\s+([^,\\n]+)"
|
||||
notes:
|
||||
- "Puede venir como 'Monterrey, Nuevo León' o 'Municipio de ...'"
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 1) NOTARÍA / FEDATARIO
|
||||
# ------------------------------------------------------------
|
||||
notary:
|
||||
- key: "notary_name"
|
||||
type: "string"
|
||||
anchors:
|
||||
- "licenciado"
|
||||
- "lic."
|
||||
- "notario público"
|
||||
- "notario publico"
|
||||
- "titular de la notaría"
|
||||
regex_hints:
|
||||
- "(notario\\s+p[úu]blico\\s*(n[uú]mero|no\\.?|n\\.)\\s*[0-9]+\\s*[^\\n,]*,?\\s*(?:lic\\.?|licenciado)?\\s*([A-ZÁÉÍÓÚÑ ].{3,}))"
|
||||
notes:
|
||||
- "A veces el nombre está antes y el número después; parsea ambos por separado."
|
||||
|
||||
- key: "notary_number"
|
||||
type: "string"
|
||||
anchors:
|
||||
- "notaría número"
|
||||
- "notaría no."
|
||||
- "notario público número"
|
||||
regex_hints:
|
||||
- "(notar[ií]a|notario\\s+p[úu]blico)\\s*(n[uú]mero|no\\.?|n\\.)\\s*([0-9]{1,4})"
|
||||
notes:
|
||||
- "OCR puede leer '1O' por '10'. Normaliza."
|
||||
|
||||
- key: "notary_state"
|
||||
type: "string"
|
||||
anchors:
|
||||
- "del estado de"
|
||||
- "con residencia en"
|
||||
- "estado de"
|
||||
regex_hints:
|
||||
- "(del\\s+estado\\s+de|estado\\s+de)\\s+([^,\\n]+)"
|
||||
notes:
|
||||
- "No siempre viene explícito; a veces se infiere por el domicilio de la notaría (mejor no inferir)."
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 2) IDENTIDAD DE LA SOCIEDAD (lo más valioso para licitaciones)
|
||||
# ------------------------------------------------------------
|
||||
company_identity:
|
||||
- key: "legal_name"
|
||||
type: "string"
|
||||
anchors:
|
||||
- "denominación"
|
||||
- "denominacion"
|
||||
- "razón social"
|
||||
- "razon social"
|
||||
- "se denominará"
|
||||
- "se denomina"
|
||||
regex_hints:
|
||||
- "(denominaci[oó]n|raz[oó]n\\s+social)\\s*(?:social)?\\s*(?:ser[aá]|es|de)\\s*[:“\"]?\\s*([A-Z0-9ÁÉÍÓÚÑ \\-\\.&]+)"
|
||||
notes:
|
||||
- "A veces el nombre va entre comillas; a veces va en mayúsculas sin comillas."
|
||||
- "Guarda también una versión 'normalized_name' (sin S.A. de C.V. etc.)"
|
||||
|
||||
- key: "company_type"
|
||||
type: "enum"
|
||||
allowed_values:
|
||||
- "SA_DE_CV"
|
||||
- "SA"
|
||||
- "S_DE_RL_DE_CV"
|
||||
- "S_DE_RL"
|
||||
- "SAS"
|
||||
- "SAPI_DE_CV"
|
||||
- "SC"
|
||||
- "OTHER"
|
||||
anchors:
|
||||
- "sociedad anónima de capital variable"
|
||||
- "s.a. de c.v."
|
||||
- "sociedad de responsabilidad limitada de capital variable"
|
||||
- "s. de r.l. de c.v."
|
||||
- "sociedad por acciones simplificada"
|
||||
- "s.a.s."
|
||||
- "s.a.p.i. de c.v."
|
||||
regex_hints:
|
||||
- "(sociedad\\s+an[oó]nima\\s+de\\s+capital\\s+variable|s\\.?\\s*a\\.?\\s*de\\s*c\\.?\\s*v\\.?)"
|
||||
- "(sociedad\\s+de\\s+responsabilidad\\s+limitada\\s+de\\s+capital\\s+variable|s\\.?\\s*de\\s*r\\.?\\s*l\\.?\\s*de\\s*c\\.?\\s*v\\.?)"
|
||||
- "(sociedad\\s+por\\s+acciones\\s+simplificada|s\\.?\\s*a\\.?\\s*s\\.?)"
|
||||
notes:
|
||||
- "Detecta por 'tipo largo' y por abreviaturas. Usa whitelist + fuzzy."
|
||||
|
||||
- key: "domicile"
|
||||
type: "string"
|
||||
anchors:
|
||||
- "domicilio"
|
||||
- "domicilio social"
|
||||
- "tendrá su domicilio en"
|
||||
regex_hints:
|
||||
- "domicilio\\s+(?:social\\s+)?(?:ser[aá]|es|en)\\s*[:]?\\s*([^\\n\\.]+)"
|
||||
notes:
|
||||
- "Muchas veces sólo dice ciudad/estado, no calle."
|
||||
|
||||
- key: "duration"
|
||||
type: "string"
|
||||
anchors:
|
||||
- "duración"
|
||||
- "duracion"
|
||||
- "tiempo de duración"
|
||||
- "será de"
|
||||
- "indefinida"
|
||||
regex_hints:
|
||||
- "duraci[oó]n\\s*(?:ser[aá]|es)\\s*[:]?\\s*(indefinida|[0-9]{1,3}\\s+a[nñ]os)"
|
||||
notes:
|
||||
- "Indefinida es común."
|
||||
|
||||
- key: "corporate_purpose"
|
||||
type: "string"
|
||||
anchors:
|
||||
- "objeto"
|
||||
- "objeto social"
|
||||
- "la sociedad tendrá por objeto"
|
||||
- "tendrá por objeto"
|
||||
regex_hints:
|
||||
- "(objeto\\s+social|la\\s+sociedad\\s+tendr[aá]\\s+por\\s+objeto)\\s*[:]?\\s*(.+)"
|
||||
notes:
|
||||
- "Suele ser un párrafo largo; extrae bloque hasta la siguiente cláusula."
|
||||
- "Para licitaciones, también conviene crear 'purpose_keywords' (NLP) pero eso ya es capa 2."
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 3) REGISTROS / INSCRIPCIÓN (cuando existe en el mismo documento)
|
||||
# ------------------------------------------------------------
|
||||
registrations:
|
||||
- key: "rpc_reference"
|
||||
type: "object"
|
||||
subfields: ["folio_mercantil", "registro_publico", "inscription_date"]
|
||||
anchors:
|
||||
- "registro público de comercio"
|
||||
- "registro publico de comercio"
|
||||
- "folio mercantil"
|
||||
- "inscrita"
|
||||
- "inscripción"
|
||||
regex_hints:
|
||||
- "(folio\\s+mercantil)\\s*[:#]?\\s*([A-Z0-9\\-]+)"
|
||||
- "(registro\\s+p[úu]blico\\s+de\\s+comercio)"
|
||||
notes:
|
||||
- "En constitución pura, a veces aún no está inscrita; en testimonios/modificaciones sí."
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 4) SOCIOS / ACCIONISTAS / COMPARECIENTES (personas y participación)
|
||||
# ------------------------------------------------------------
|
||||
people_and_ownership:
|
||||
- key: "founders_list"
|
||||
type: "list<object>"
|
||||
subfields:
|
||||
- "full_name"
|
||||
- "nationality"
|
||||
- "marital_status"
|
||||
- "occupation"
|
||||
- "address"
|
||||
- "id_type"
|
||||
- "id_number"
|
||||
- "rfc"
|
||||
- "curp"
|
||||
anchors:
|
||||
- "comparecen"
|
||||
- "otorgan"
|
||||
- "los señores"
|
||||
- "por una parte"
|
||||
- "en su carácter"
|
||||
- "mexicano"
|
||||
- "mayor de edad"
|
||||
- "con domicilio"
|
||||
- "identificándose con"
|
||||
regex_hints:
|
||||
- "(comparecen|otorgan)\\s*:?\\s*(.+)"
|
||||
- "(mexicano|mexicana|de\\s+nacionalidad\\s+)[^\\n,\\.]+"
|
||||
notes:
|
||||
- "Esto es mejor por extracción estructurada (LLM) sobre el bloque 'COMPARECEN', porque son listas."
|
||||
- "En OCR, 'identificándose con credencial para votar' aparece con variaciones."
|
||||
|
||||
- key: "ownership_structure"
|
||||
type: "object"
|
||||
subfields:
|
||||
- "equity_units_type" # 'acciones' o 'partes sociales'
|
||||
- "equity_table" # lista por persona
|
||||
anchors:
|
||||
- "acciones"
|
||||
- "parte social"
|
||||
- "partes sociales"
|
||||
- "capital social"
|
||||
- "suscribe"
|
||||
- "suscriben"
|
||||
- "exhibe"
|
||||
- "aportación"
|
||||
- "aportacion"
|
||||
regex_hints:
|
||||
- "(acciones|partes\\s+sociales)"
|
||||
- "(suscribe|suscriben)\\s+([0-9,\\.]+)\\s*(acciones|partes\\s+sociales)"
|
||||
notes:
|
||||
- "SA/SAPI/SAS: acciones. SRL: partes sociales."
|
||||
- "Casi siempre hay una tablita o párrafos repetidos (persona + cantidad + valor)."
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 5) CAPITAL SOCIAL (fijo/variable, series, valor nominal)
|
||||
# ------------------------------------------------------------
|
||||
capital:
|
||||
- key: "capital_total"
|
||||
type: "money"
|
||||
anchors:
|
||||
- "capital social"
|
||||
- "importe"
|
||||
- "monto"
|
||||
- "asciende a"
|
||||
- "será de"
|
||||
regex_hints:
|
||||
- "capital\\s+social\\s*(?:asciende\\s+a|ser[aá]\\s+de)\\s*\\$?\\s*([0-9\\.,]+)"
|
||||
notes:
|
||||
- "Guarda moneda si aparece (MXN, pesos)."
|
||||
- "También existe en letra: 'la cantidad de cien mil pesos 00/100 M.N.'"
|
||||
|
||||
- key: "capital_fixed"
|
||||
type: "money"
|
||||
anchors:
|
||||
- "capital fijo"
|
||||
- "parte fija"
|
||||
regex_hints:
|
||||
- "(capital\\s+fijo|parte\\s+fija)\\s*[:]?\\s*\\$?\\s*([0-9\\.,]+)"
|
||||
notes:
|
||||
- "En S.A. de C.V. comúnmente separa fijo y variable."
|
||||
|
||||
- key: "capital_variable"
|
||||
type: "money"
|
||||
anchors:
|
||||
- "capital variable"
|
||||
- "parte variable"
|
||||
regex_hints:
|
||||
- "(capital\\s+variable|parte\\s+variable)\\s*[:]?\\s*\\$?\\s*([0-9\\.,]+)"
|
||||
notes:
|
||||
- "A veces dice 'ilimitado' para variable (reglas)."
|
||||
|
||||
- key: "share_nominal_value"
|
||||
type: "money"
|
||||
anchors:
|
||||
- "valor nominal"
|
||||
- "cada acción"
|
||||
- "cada parte social"
|
||||
regex_hints:
|
||||
- "(valor\\s+nominal)\\s*[:]?\\s*\\$?\\s*([0-9\\.,]+)"
|
||||
notes:
|
||||
- "Muy útil para validar consistencia: (#acciones * valor_nominal) ~= capital."
|
||||
|
||||
- key: "share_series"
|
||||
type: "list<object>"
|
||||
anchors:
|
||||
- "serie"
|
||||
- "acciones serie"
|
||||
regex_hints:
|
||||
- "serie\\s+([A-Z])\\s*(?:por|de)?\\s*([0-9\\.,]+)\\s*acciones"
|
||||
notes:
|
||||
- "No siempre aplica; pero en SAPI/SA puede haber series A/B."
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 6) ADMINISTRACIÓN / GOBIERNO CORPORATIVO
|
||||
# ------------------------------------------------------------
|
||||
governance:
|
||||
- key: "administration_type"
|
||||
type: "enum"
|
||||
allowed_values:
|
||||
- "ADMIN_UNICO"
|
||||
- "CONSEJO_ADMIN"
|
||||
- "GERENTE_UNICO"
|
||||
- "GERENTES"
|
||||
- "OTHER"
|
||||
anchors:
|
||||
- "administrador único"
|
||||
- "administrador unico"
|
||||
- "consejo de administración"
|
||||
- "consejo de administracion"
|
||||
- "gerente"
|
||||
- "gerentes"
|
||||
regex_hints:
|
||||
- "(administrador\\s+[uú]nico|consejo\\s+de\\s+administraci[oó]n|gerente[s]?)"
|
||||
notes:
|
||||
- "SA suele ser administrador único o consejo; SRL suele ser gerentes."
|
||||
|
||||
- key: "administrators"
|
||||
type: "list<object>"
|
||||
subfields: ["full_name", "role", "term", "is_board_member"]
|
||||
anchors:
|
||||
- "se designa"
|
||||
- "queda designado"
|
||||
- "nombrado"
|
||||
- "presidente"
|
||||
- "secretario"
|
||||
- "tesorero"
|
||||
- "vocal"
|
||||
regex_hints:
|
||||
- "(se\\s+designa|queda\\s+designado|nombrado)\\s+a\\s+([^\\n,\\.]+)"
|
||||
notes:
|
||||
- "Extrae dentro de la cláusula de administración."
|
||||
|
||||
- key: "comisario"
|
||||
type: "object"
|
||||
subfields: ["full_name", "substitute_full_name"]
|
||||
anchors:
|
||||
- "comisario"
|
||||
- "comisario propietario"
|
||||
- "comisario suplente"
|
||||
regex_hints:
|
||||
- "comisario\\s+(propietario|suplente)\\s*[:]?\\s*([^\\n,\\.]+)"
|
||||
notes:
|
||||
- "En SA es frecuente; en SRL puede no existir."
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 7) PODERES (súper relevante para licitaciones: quién firma)
|
||||
# ------------------------------------------------------------
|
||||
powers:
|
||||
- key: "powers_granted"
|
||||
type: "list<object>"
|
||||
subfields:
|
||||
- "grantee_name"
|
||||
- "power_type" # pleitos_y_cobranzas, actos_de_administracion, actos_de_dominio, titulos_de_credito, laboral, etc.
|
||||
- "limitations"
|
||||
anchors:
|
||||
- "otorga poder"
|
||||
- "poder general"
|
||||
- "pleitos y cobranzas"
|
||||
- "actos de administración"
|
||||
- "actos de administracion"
|
||||
- "actos de dominio"
|
||||
- "para suscribir títulos de crédito"
|
||||
- "titulos de credito"
|
||||
regex_hints:
|
||||
- "(poder\\s+general\\s+para\\s+(pleitos\\s+y\\s+cobranzas|actos\\s+de\\s+administraci[oó]n|actos\\s+de\\s+dominio))"
|
||||
notes:
|
||||
- "Muchas actas incluyen poderes al administrador/gerente."
|
||||
- "Para licitar, tu app puede derivar un campo 'authorized_signers' con base en powers_granted."
|
||||
|
||||
- key: "authorized_signers"
|
||||
type: "list<string>"
|
||||
anchors:
|
||||
- "podrá obligar a la sociedad"
|
||||
- "firma"
|
||||
- "representación legal"
|
||||
- "representacion legal"
|
||||
notes:
|
||||
- "No siempre textual; lo infieres desde 'poder' + 'administrador/gerente'. Guarda como 'derived' con confianza."
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 8) REGLAS DE ASAMBLEAS / RESOLUCIONES / QUÓRUM
|
||||
# ------------------------------------------------------------
|
||||
meetings:
|
||||
- key: "meeting_types"
|
||||
type: "list<string>"
|
||||
anchors:
|
||||
- "asamblea general"
|
||||
- "asamblea ordinaria"
|
||||
- "asamblea extraordinaria"
|
||||
- "resoluciones"
|
||||
notes:
|
||||
- "Extrae si menciona ordinaria/extraordinaria."
|
||||
|
||||
- key: "quorum_rules"
|
||||
type: "string"
|
||||
anchors:
|
||||
- "quórum"
|
||||
- "quorum"
|
||||
- "mayoría"
|
||||
- "mayoria"
|
||||
- "votación"
|
||||
- "votacion"
|
||||
notes:
|
||||
- "Suele ser texto largo; captura bloque por cláusula."
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 9) EJERCICIO SOCIAL / UTILIDADES
|
||||
# ------------------------------------------------------------
|
||||
finance_rules:
|
||||
- key: "fiscal_year"
|
||||
type: "string"
|
||||
anchors:
|
||||
- "ejercicio social"
|
||||
- "cerrará"
|
||||
- "cerrara"
|
||||
- "del primero de enero"
|
||||
- "al treinta y uno de diciembre"
|
||||
notes:
|
||||
- "Casi siempre es anual calendario, pero no asumas; extrae."
|
||||
|
||||
- key: "profit_distribution"
|
||||
type: "string"
|
||||
anchors:
|
||||
- "utilidades"
|
||||
- "reparto"
|
||||
- "dividendos"
|
||||
- "reserva legal"
|
||||
notes:
|
||||
- "Para licitaciones no siempre clave, pero sirve para perfil legal."
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 10) DISOLUCIÓN / LIQUIDACIÓN
|
||||
# ------------------------------------------------------------
|
||||
dissolution:
|
||||
- key: "dissolution_causes"
|
||||
type: "string"
|
||||
anchors:
|
||||
- "disolución"
|
||||
- "disolucion"
|
||||
- "liquidación"
|
||||
- "liquidacion"
|
||||
- "liquidador"
|
||||
notes:
|
||||
- "Guarda como bloque."
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 11) CLÁUSULA DE EXTRANJEROS / INVERSIÓN EXTRANJERA
|
||||
# ------------------------------------------------------------
|
||||
foreign_investment:
|
||||
- key: "foreigners_clause"
|
||||
type: "enum"
|
||||
allowed_values: ["CLAUSULA_CALVO", "ADMITE_EXTRANJEROS", "NO_DETECTADO"]
|
||||
anchors:
|
||||
- "cláusula de extranjeros"
|
||||
- "clausula de extranjeros"
|
||||
- "convención de extranjeros"
|
||||
- "calvo"
|
||||
- "se considerarán como nacionales"
|
||||
- "no invocar la protección de su gobierno"
|
||||
notes:
|
||||
- "Muy típica en actas mexicanas; útil para compliance en licitaciones."
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 12) DOMICILIOS / NOTIFICACIONES (a veces separado del domicilio social)
|
||||
# ------------------------------------------------------------
|
||||
addresses:
|
||||
- key: "notification_address"
|
||||
type: "string"
|
||||
anchors:
|
||||
- "para oír y recibir notificaciones"
|
||||
- "para oir y recibir notificaciones"
|
||||
notes:
|
||||
- "Aparece mucho en datos de comparecientes; no confundir con domicilio social."
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 13) RFC / DATOS FISCALES (no siempre están en acta)
|
||||
# ------------------------------------------------------------
|
||||
tax:
|
||||
- key: "rfc_company"
|
||||
type: "string"
|
||||
anchors:
|
||||
- "r.f.c."
|
||||
- "rfc"
|
||||
- "registro federal de contribuyentes"
|
||||
regex_hints:
|
||||
- "\\b([A-Z&Ñ]{3,4}\\d{6}[A-Z0-9]{3})\\b"
|
||||
notes:
|
||||
- "En acta constitutiva pura puede NO venir. Si no aparece, no inventes."
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 14) ANEXOS / CERTIFICACIONES / TESTIMONIOS
|
||||
# ------------------------------------------------------------
|
||||
attachments_and_certifications:
|
||||
- key: "sre_name_authorization"
|
||||
type: "string"
|
||||
anchors:
|
||||
- "secretaría de relaciones exteriores"
|
||||
- "sre"
|
||||
- "permiso"
|
||||
- "uso de denominación"
|
||||
- "autorización"
|
||||
notes:
|
||||
- "A veces menciona un oficio/folio de permiso de denominación."
|
||||
|
||||
- key: "certification_language"
|
||||
type: "string"
|
||||
anchors:
|
||||
- "doy fe"
|
||||
- "certifico"
|
||||
- "consta"
|
||||
- "testimonio"
|
||||
notes:
|
||||
- "Útil para identificar el cierre del documento y cortar extracción."
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 15) CAMPOS DERIVADOS (para tu 'perfil' de licitaciones)
|
||||
# ------------------------------------------------------------
|
||||
derived_fields:
|
||||
- key: "is_company_validly_constituted"
|
||||
type: "boolean"
|
||||
derivation:
|
||||
- "true si detectas legal_name + company_type + instrument_number + notary_number (con confianza alta)"
|
||||
notes:
|
||||
- "No es dictamen legal; sólo heurística de completitud."
|
||||
|
||||
- key: "key_signers_candidates"
|
||||
type: "list<object>"
|
||||
derivation:
|
||||
- "de powers_granted + administration_type + administrators"
|
||||
notes:
|
||||
- "Marca como derived + confidence y muestra evidencia (página/línea) en UI."
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 16) ESTRATEGIA DE EXTRACCIÓN RECOMENDADA (orden)
|
||||
# ------------------------------------------------------------
|
||||
extraction_strategy_order:
|
||||
- "1) detect sections: header, comparecientes, clauses, closing"
|
||||
- "2) extract high-confidence with anchors+regex (instrument, notary, legal_name, company_type)"
|
||||
- "3) clause-block extraction for domicilio/objeto/duración/capital/administración/poderes"
|
||||
- "4) list extraction (founders, equity_table, administrators) con parser estructurado (ideal LLM) sobre bloques detectados"
|
||||
- "5) validate consistency: capital vs acciones/partes, fechas parseables, RFC pattern"
|
||||
- "6) store raw_evidence: snippet + page + bounding box (si tienes) + confidence"
|
||||
|
||||
confidence_scoring_hint:
|
||||
- "high: value found near strong anchor + matches regex + inside expected clause"
|
||||
- "medium: matches regex but anchor weak or OCR noisy"
|
||||
- "low: inferred/derived or only found once without context"
|
||||
111
README.md
111
README.md
@@ -6,12 +6,6 @@ First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
@@ -34,3 +28,108 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
|
||||
## OCR Setup (Recommended)
|
||||
|
||||
PDF analysis uses direct text extraction first. If text is insufficient (common in scanned PDFs), the API falls back to OCR with `ocrmypdf`.
|
||||
|
||||
Install host dependencies (Ubuntu/Debian):
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y ocrmypdf poppler-utils tesseract-ocr tesseract-ocr-spa tesseract-ocr-eng
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
ocrmypdf --version
|
||||
```
|
||||
|
||||
If OCR is not available, the API returns a specific error (`OCR_UNAVAILABLE`) with install guidance.
|
||||
|
||||
## AI Extraction for Acta Constitutiva
|
||||
|
||||
Onboarding now uses AI as the default extraction engine after PDF text analysis:
|
||||
|
||||
1. Extract direct text from PDF.
|
||||
2. If text is insufficient, run OCR.
|
||||
3. Send extracted text to OpenAI to map fields and lookup dictionary.
|
||||
4. If AI fails, fallback extraction is used so onboarding is not blocked.
|
||||
|
||||
Environment variables:
|
||||
|
||||
```bash
|
||||
OPENAI_API_KEY=sk-...
|
||||
OPENAI_ACTA_MODEL=gpt-4.1-mini
|
||||
OPENAI_ACTA_TIMEOUT_MS=60000
|
||||
OPENAI_ACTA_MAX_CHARS=45000
|
||||
```
|
||||
|
||||
## Local CLI Script (PDF -> OCR/text -> AI)
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run acta:analyze:ai -- ./path/to/acta.pdf
|
||||
```
|
||||
|
||||
Optional output file:
|
||||
|
||||
```bash
|
||||
npm run acta:analyze:ai -- ./path/to/acta.pdf --out ./result.json
|
||||
```
|
||||
|
||||
## Licita Ya API Key Test
|
||||
|
||||
Add these vars to `.env`:
|
||||
|
||||
```bash
|
||||
LICITAYA_API_KEY=your-licitaya-api-key
|
||||
LICITAYA_BASE_URL=https://<licitaya-base-url>
|
||||
LICITAYA_TEST_ENDPOINT=/tender/search?items=10&page=1
|
||||
LICITAYA_ACCEPT=application/json
|
||||
LICITAYA_TIMEOUT_MS=20000
|
||||
```
|
||||
|
||||
Run the connection test:
|
||||
|
||||
```bash
|
||||
npm run licitaya:test
|
||||
```
|
||||
|
||||
Override values on demand:
|
||||
|
||||
```bash
|
||||
npm run licitaya:test -- --base-url https://www.licitaya.com.mx/api/v1 --endpoint /tender/search?items=10&page=1 --accept application/json
|
||||
```
|
||||
|
||||
You can also pass a full URL in `--endpoint`:
|
||||
|
||||
```bash
|
||||
npm run licitaya:test -- --endpoint https://<licitaya-base-url>/<country-endpoint>
|
||||
```
|
||||
|
||||
Common Licita Ya lookups:
|
||||
|
||||
```bash
|
||||
# Search tenders (keyword + filters)
|
||||
npm run licitaya:test -- --endpoint '/tender/search?keyword=computadora,monitor&state=NLE,XX&items=10&page=1&order=1'
|
||||
|
||||
# Search by date (YYYYmmdd)
|
||||
npm run licitaya:test -- --endpoint '/tender/search?date=20260313&items=10&page=1'
|
||||
|
||||
# Get one tender by ID
|
||||
npm run licitaya:test -- --endpoint '/tender/SCRZJ'
|
||||
```
|
||||
|
||||
Country base URL (pick one only):
|
||||
|
||||
- Mexico: `https://www.licitaya.com.mx/api/v1`
|
||||
- Argentina: `https://www.licitaya.com.ar/api/v1`
|
||||
|
||||
Notes:
|
||||
|
||||
- The script sends your key in header `X-API-KEY`.
|
||||
- It prints status code + response preview.
|
||||
- A non-2xx response exits with code `1` (useful for CI checks).
|
||||
|
||||
4967
municipalities.txt
Normal file
4967
municipalities.txt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,10 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
experimental: {
|
||||
// Keep this above onboarding's 15MB app-level limit so route validation can run.
|
||||
proxyClientMaxBodySize: "20mb",
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
2544
package-lock.json
generated
2544
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
@@ -6,21 +6,38 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"test": "vitest run",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:seed": "node prisma/seed.mjs",
|
||||
"municipalities:extract": "node scripts/extract-municipalities-from-pdf.mjs",
|
||||
"acta:analyze:ai": "node scripts/analyze-acta-ai.mjs",
|
||||
"licitaya:test": "node scripts/test-licitaya-api.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.16.3",
|
||||
"next": "16.1.6",
|
||||
"nodemailer": "^8.0.1",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"playwright": "^1.58.2",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
"react-dom": "19.2.3",
|
||||
"recharts": "^3.7.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^7.0.9",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"prisma": "^6.16.3",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
367
pdfExtractor.js
Normal file
367
pdfExtractor.js
Normal file
@@ -0,0 +1,367 @@
|
||||
import fs from 'fs';
|
||||
import zlib from 'zlib';
|
||||
import { createWorker } from 'tesseract.js';
|
||||
import { fromBuffer } from 'pdf2pic';
|
||||
import sharp from 'sharp';
|
||||
|
||||
export class PDFExtractor {
|
||||
static async extractText(buffer) {
|
||||
try {
|
||||
// Convert buffer to string to search for text patterns
|
||||
const pdfString = buffer.toString('latin1');
|
||||
|
||||
// Look for text streams in the PDF - multiple patterns
|
||||
const patterns = [
|
||||
/BT[\s\S]*?ET/g, // Text objects
|
||||
/\((.*?)\)/g, // Text in parentheses
|
||||
/\[(.*?)\]/g, // Text in brackets
|
||||
/<<(.*?)>>/g // Dictionary objects
|
||||
];
|
||||
|
||||
let extractedText = '';
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const matches = pdfString.match(pattern);
|
||||
if (matches) {
|
||||
for (const match of matches) {
|
||||
// Extract text content from matches
|
||||
const textContent = match.match(/\((.*?)\)/g);
|
||||
if (textContent) {
|
||||
for (const text of textContent) {
|
||||
let cleanText = text.slice(1, -1);
|
||||
// Basic text cleaning
|
||||
cleanText = cleanText.replace(/\\n/g, '\n');
|
||||
cleanText = cleanText.replace(/\\r/g, '\r');
|
||||
cleanText = cleanText.replace(/\\t/g, '\t');
|
||||
cleanText = cleanText.replace(/\\\(/g, '(');
|
||||
cleanText = cleanText.replace(/\\\)/g, ')');
|
||||
extractedText += cleanText + ' ';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return extractedText.trim();
|
||||
} catch (error) {
|
||||
console.error('Error extracting text from PDF:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
static async extractTextFromStream(buffer) {
|
||||
try {
|
||||
const pdfString = buffer.toString('latin1');
|
||||
|
||||
// Look for compressed streams
|
||||
const streamMatches = pdfString.match(/stream\s*\n([\s\S]*?)\nendstream/g);
|
||||
|
||||
if (!streamMatches) {
|
||||
console.log('No streams found, trying direct extraction');
|
||||
return this.extractText(buffer);
|
||||
}
|
||||
|
||||
console.log('Found', streamMatches.length, 'streams');
|
||||
let extractedText = '';
|
||||
|
||||
for (let i = 0; i < streamMatches.length; i++) {
|
||||
const match = streamMatches[i];
|
||||
try {
|
||||
// Extract the stream content
|
||||
const streamContent = match.match(/stream\s*\n([\s\S]*?)\nendstream/);
|
||||
if (streamContent) {
|
||||
const streamData = streamContent[1];
|
||||
console.log(`Processing stream ${i + 1}, length:`, streamData.length);
|
||||
|
||||
// Try to decompress with zlib (FlateDecode)
|
||||
try {
|
||||
// Convert to buffer properly
|
||||
const streamBuffer = Buffer.from(streamData, 'binary');
|
||||
const decompressed = zlib.inflateSync(streamBuffer);
|
||||
const decompressedText = decompressed.toString('utf8');
|
||||
|
||||
console.log('Decompressed text length:', decompressedText.length);
|
||||
|
||||
// Check if this looks like readable text
|
||||
const readableChars = decompressedText.match(/[a-zA-ZáéíóúñÁÉÍÓÚÑ]/g);
|
||||
const readabilityRatio = readableChars ? readableChars.length / decompressedText.length : 0;
|
||||
|
||||
console.log(`Stream ${i + 1} readability ratio:`, readabilityRatio.toFixed(3));
|
||||
|
||||
// Only process if it has reasonable readability
|
||||
if (readabilityRatio > 0.1) {
|
||||
console.log(`First 200 chars of decompressed:`, decompressedText.substring(0, 200));
|
||||
|
||||
// Extract text from decompressed content - multiple patterns
|
||||
const patterns = [
|
||||
/\((.*?)\)/g, // Text in parentheses
|
||||
/\[(.*?)\]/g, // Text in brackets
|
||||
/<([0-9A-Fa-f]+)>/g // Hexadecimal text
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const matches = decompressedText.match(pattern);
|
||||
if (matches) {
|
||||
console.log('Found', matches.length, 'matches for pattern:', pattern.source);
|
||||
for (const match of matches) {
|
||||
let cleanText = match.slice(1, -1);
|
||||
|
||||
// Handle hexadecimal text
|
||||
if (pattern.source.includes('[0-9A-Fa-f]')) {
|
||||
try {
|
||||
// Convert hex to text
|
||||
const hexText = cleanText.replace(/\s/g, '');
|
||||
if (hexText.length % 2 === 0) {
|
||||
const bytes = [];
|
||||
for (let j = 0; j < hexText.length; j += 2) {
|
||||
bytes.push(parseInt(hexText.substr(j, 2), 16));
|
||||
}
|
||||
cleanText = String.fromCharCode(...bytes);
|
||||
}
|
||||
} catch (hexError) {
|
||||
console.log('Hex conversion failed:', hexError.message);
|
||||
}
|
||||
} else {
|
||||
// Regular text cleaning
|
||||
cleanText = cleanText.replace(/\\n/g, '\n');
|
||||
cleanText = cleanText.replace(/\\r/g, '\r');
|
||||
cleanText = cleanText.replace(/\\t/g, '\t');
|
||||
cleanText = cleanText.replace(/\\\(/g, '(');
|
||||
cleanText = cleanText.replace(/\\\)/g, ')');
|
||||
}
|
||||
|
||||
extractedText += cleanText + ' ';
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`Skipping stream ${i + 1} - low readability (${readabilityRatio.toFixed(3)})`);
|
||||
}
|
||||
} catch (decompressError) {
|
||||
console.log('Decompression failed for stream', i + 1, ':', decompressError.message);
|
||||
// Try raw text extraction from stream
|
||||
const rawText = streamData.replace(/[^\x20-\x7E\n\r\t]/g, ' ');
|
||||
extractedText += rawText + ' ';
|
||||
}
|
||||
}
|
||||
} catch (streamError) {
|
||||
console.log('Stream processing failed for stream', i + 1, ':', streamError.message);
|
||||
}
|
||||
}
|
||||
|
||||
const result = extractedText.trim();
|
||||
console.log('Total extracted text length:', result.length);
|
||||
|
||||
// Check if we got meaningful text
|
||||
const meaningfulChars = result.match(/[a-zA-ZáéíóúñÁÉÍÓÚÑ]/g);
|
||||
const meaningfulRatio = meaningfulChars ? meaningfulChars.length / result.length : 0;
|
||||
|
||||
console.log('Meaningful text ratio:', meaningfulRatio.toFixed(3));
|
||||
|
||||
if (meaningfulRatio < 0.3) {
|
||||
console.log('⚠️ Low meaningful text ratio - trying OCR extraction...');
|
||||
return await this.extractTextWithOCR(buffer);
|
||||
}
|
||||
|
||||
return result || this.extractText(buffer);
|
||||
} catch (error) {
|
||||
console.error('Error extracting text from PDF stream:', error);
|
||||
return await this.extractTextWithOCR(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preprocess image to improve OCR accuracy for blurry/scanned documents
|
||||
* @param {Buffer} imageBuffer - Image buffer to preprocess
|
||||
* @returns {Promise<Buffer>} - Preprocessed image buffer
|
||||
*/
|
||||
static async preprocessImage(imageBuffer) {
|
||||
try {
|
||||
const processed = await sharp(imageBuffer)
|
||||
.greyscale() // Convert to grayscale for better OCR
|
||||
.normalize() // Enhance contrast
|
||||
.sharpen({ sigma: 1.5, m1: 1, m2: 3, x1: 3, y2: 15, y3: 15 }) // Sharpen blurry images
|
||||
.median(3) // Denoise - remove small artifacts
|
||||
.toBuffer();
|
||||
|
||||
return processed;
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Image preprocessing failed, using original:', error.message);
|
||||
return imageBuffer; // Return original if preprocessing fails
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine optimal PSM (Page Segmentation Mode) based on image characteristics
|
||||
* @param {Buffer} imageBuffer - Image buffer to analyze
|
||||
* @returns {Promise<number>} - PSM mode (3=auto, 6=single block, 11=sparse text)
|
||||
*/
|
||||
static async determinePSMMode(imageBuffer) {
|
||||
try {
|
||||
const metadata = await sharp(imageBuffer).metadata();
|
||||
const stats = await sharp(imageBuffer)
|
||||
.greyscale()
|
||||
.normalize()
|
||||
.stats();
|
||||
|
||||
// Calculate text density estimate (simplified heuristic)
|
||||
const avgBrightness = stats.channels[0].mean;
|
||||
const stdDev = stats.channels[0].stDev;
|
||||
|
||||
// Low stdDev suggests uniform background (single block)
|
||||
// High stdDev suggests varied content (auto mode)
|
||||
if (stdDev < 30) {
|
||||
return 6; // Single uniform block of text
|
||||
} else if (stdDev > 60) {
|
||||
return 11; // Sparse text (scanned documents with lots of whitespace)
|
||||
}
|
||||
|
||||
return 3; // Auto (default)
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Could not determine PSM mode, using auto:', error.message);
|
||||
return 3; // Default to auto
|
||||
}
|
||||
}
|
||||
|
||||
static async extractTextWithOCR(buffer) {
|
||||
console.log('🔍 Starting OCR extraction with enhanced preprocessing...');
|
||||
let worker = null;
|
||||
|
||||
try {
|
||||
// Convert PDF to images at higher DPI for better quality
|
||||
const convert = fromBuffer(buffer, {
|
||||
density: 400, // Increased from 300 to 400 for better OCR accuracy
|
||||
saveFilename: "page",
|
||||
savePath: "./temp",
|
||||
format: "png",
|
||||
width: 2000,
|
||||
height: 2000
|
||||
});
|
||||
|
||||
// Get the number of pages
|
||||
const result = await convert.bulk(-1, { responseType: "base64" });
|
||||
console.log(`📄 Converted PDF to ${result.length} images for OCR at 400 DPI`);
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new Error('No pages found in PDF');
|
||||
}
|
||||
|
||||
// Initialize Tesseract worker with optimized settings
|
||||
worker = await createWorker('spa+eng'); // Spanish + English
|
||||
|
||||
// Set OCR Engine Mode to 1 (LSTM neural nets - better accuracy)
|
||||
await worker.setParameters({
|
||||
tessedit_ocr_engine_mode: '1', // OEM 1: LSTM engine (best for accuracy)
|
||||
tessedit_pageseg_mode: '3', // PSM 3: Auto (will be adjusted per page)
|
||||
tessedit_char_whitelist: '', // No character restrictions
|
||||
});
|
||||
|
||||
let ocrText = '';
|
||||
let totalConfidence = 0;
|
||||
let pagesProcessed = 0;
|
||||
|
||||
// Process each page with OCR
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
console.log(`🔍 Processing page ${i + 1}/${result.length} with OCR...`);
|
||||
|
||||
try {
|
||||
// Decode base64 image
|
||||
const imageBuffer = Buffer.from(result[i].base64, 'base64');
|
||||
|
||||
// Preprocess image to improve OCR accuracy
|
||||
const processedImageBuffer = await this.preprocessImage(imageBuffer);
|
||||
|
||||
// Determine optimal PSM mode for this page (use original for analysis)
|
||||
const psmMode = await this.determinePSMMode(imageBuffer);
|
||||
await worker.setParameters({
|
||||
tessedit_pageseg_mode: String(psmMode)
|
||||
});
|
||||
|
||||
// Convert processed image to base64 data URL for tesseract.js
|
||||
const processedBase64 = processedImageBuffer.toString('base64');
|
||||
const imageDataUrl = `data:image/png;base64,${processedBase64}`;
|
||||
|
||||
// Perform OCR with confidence scores
|
||||
const { data } = await worker.recognize(imageDataUrl);
|
||||
|
||||
// Filter out low-confidence words (optional - can be adjusted)
|
||||
const minConfidence = 30; // Minimum confidence threshold (0-100)
|
||||
const words = data.words || [];
|
||||
const highConfidenceText = words
|
||||
.filter(word => word.confidence >= minConfidence)
|
||||
.map(word => word.text)
|
||||
.join(' ');
|
||||
|
||||
// Use high-confidence text if available, otherwise use all text
|
||||
const pageText = highConfidenceText.length > data.text.length * 0.5
|
||||
? highConfidenceText
|
||||
: data.text;
|
||||
|
||||
ocrText += pageText + '\n\n';
|
||||
|
||||
// Track confidence for reporting
|
||||
const avgConfidence = words.length > 0
|
||||
? words.reduce((sum, w) => sum + w.confidence, 0) / words.length
|
||||
: 0;
|
||||
totalConfidence += avgConfidence;
|
||||
pagesProcessed++;
|
||||
|
||||
console.log(`✅ Page ${i + 1}: Extracted ${pageText.length} chars, avg confidence: ${avgConfidence.toFixed(1)}%`);
|
||||
} catch (pageError) {
|
||||
console.error(`❌ Error processing page ${i + 1}:`, pageError.message);
|
||||
// Continue with next page instead of failing completely
|
||||
}
|
||||
}
|
||||
|
||||
// Terminate worker
|
||||
await worker.terminate();
|
||||
worker = null;
|
||||
|
||||
// Calculate average confidence
|
||||
const avgConfidence = pagesProcessed > 0 ? totalConfidence / pagesProcessed : 0;
|
||||
console.log(`📊 Average OCR confidence: ${avgConfidence.toFixed(1)}%`);
|
||||
|
||||
// Clean up temp files
|
||||
try {
|
||||
if (fs.existsSync('./temp')) {
|
||||
const tempFiles = fs.readdirSync('./temp').filter(file => file.startsWith('page'));
|
||||
tempFiles.forEach(file => {
|
||||
try {
|
||||
fs.unlinkSync(`./temp/${file}`);
|
||||
} catch (unlinkError) {
|
||||
// Ignore individual file deletion errors
|
||||
}
|
||||
});
|
||||
console.log('🧹 Cleaned up temp files');
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.log('⚠️ Could not clean up temp files:', cleanupError.message);
|
||||
}
|
||||
|
||||
const finalText = ocrText.trim();
|
||||
console.log(`✅ OCR extraction completed: ${finalText.length} total characters`);
|
||||
|
||||
if (finalText.length > 100) {
|
||||
console.log('📝 First 200 chars of OCR text:', finalText.substring(0, 200));
|
||||
return finalText;
|
||||
} else {
|
||||
console.log('⚠️ OCR extracted very little text, falling back to document name');
|
||||
return 'Documento procesado con OCR - información limitada extraída';
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ OCR extraction failed:', error);
|
||||
|
||||
// Ensure worker is terminated even on error
|
||||
if (worker) {
|
||||
try {
|
||||
await worker.terminate();
|
||||
} catch (terminateError) {
|
||||
// Ignore termination errors
|
||||
}
|
||||
}
|
||||
|
||||
return 'Error en procesamiento OCR - documento requiere revisión manual';
|
||||
}
|
||||
}
|
||||
}
|
||||
42094
prisma/data/municipalities.json
Normal file
42094
prisma/data/municipalities.json
Normal file
File diff suppressed because it is too large
Load Diff
250
prisma/migrations/20260213022602_init/migration.sql
Normal file
250
prisma/migrations/20260213022602_init/migration.sql
Normal file
@@ -0,0 +1,250 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PriorityLevel" AS ENUM ('LOW', 'MEDIUM', 'HIGH');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "UserRole" AS ENUM ('USER', 'ADMIN');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ContentPageType" AS ENUM ('FAQ', 'MANUAL');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "OverallScoreMethod" AS ENUM ('EQUAL_ALL_MODULES', 'EQUAL_ANSWERED_MODULES', 'WEIGHTED_ANSWERED_MODULES');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"role" "UserRole" NOT NULL DEFAULT 'USER',
|
||||
"emailVerifiedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Organization" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"industry" TEXT,
|
||||
"companySize" TEXT,
|
||||
"country" TEXT,
|
||||
"primaryObjective" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Organization_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "DiagnosticModule" (
|
||||
"id" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "DiagnosticModule_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Question" (
|
||||
"id" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"moduleId" TEXT NOT NULL,
|
||||
"prompt" TEXT NOT NULL,
|
||||
"helpText" TEXT,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Question_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AnswerOption" (
|
||||
"id" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"questionId" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"weight" INTEGER NOT NULL,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "AnswerOption_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Response" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"questionId" TEXT NOT NULL,
|
||||
"answerOptionId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Response_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AssessmentResult" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"moduleId" TEXT,
|
||||
"overallScore" DOUBLE PRECISION,
|
||||
"moduleScore" DOUBLE PRECISION,
|
||||
"metadata" JSONB,
|
||||
"calculatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "AssessmentResult_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Recommendation" (
|
||||
"id" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"moduleId" TEXT,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"priority" "PriorityLevel" NOT NULL DEFAULT 'MEDIUM',
|
||||
"isTemplate" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Recommendation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ContentPage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"type" "ContentPageType" NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"isPublished" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ContentPage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ScoringConfig" (
|
||||
"id" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"lowScoreThreshold" INTEGER NOT NULL DEFAULT 70,
|
||||
"overallScoreMethod" "OverallScoreMethod" NOT NULL DEFAULT 'EQUAL_ALL_MODULES',
|
||||
"moduleWeights" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ScoringConfig_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "EmailVerificationToken" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"consumedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "EmailVerificationToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Organization_userId_key" ON "Organization"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "DiagnosticModule_key_key" ON "DiagnosticModule"("key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Question_key_key" ON "Question"("key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Question_moduleId_sortOrder_idx" ON "Question"("moduleId", "sortOrder");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AnswerOption_key_key" ON "AnswerOption"("key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AnswerOption_questionId_sortOrder_idx" ON "AnswerOption"("questionId", "sortOrder");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Response_userId_idx" ON "Response"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Response_userId_questionId_key" ON "Response"("userId", "questionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AssessmentResult_userId_calculatedAt_idx" ON "AssessmentResult"("userId", "calculatedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AssessmentResult_moduleId_idx" ON "AssessmentResult"("moduleId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Recommendation_key_key" ON "Recommendation"("key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Recommendation_moduleId_idx" ON "Recommendation"("moduleId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ContentPage_slug_key" ON "ContentPage"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ContentPage_type_sortOrder_idx" ON "ContentPage"("type", "sortOrder");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ScoringConfig_key_key" ON "ScoringConfig"("key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "EmailVerificationToken_token_key" ON "EmailVerificationToken"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EmailVerificationToken_userId_idx" ON "EmailVerificationToken"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EmailVerificationToken_expiresAt_idx" ON "EmailVerificationToken"("expiresAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Organization" ADD CONSTRAINT "Organization_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Question" ADD CONSTRAINT "Question_moduleId_fkey" FOREIGN KEY ("moduleId") REFERENCES "DiagnosticModule"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AnswerOption" ADD CONSTRAINT "AnswerOption_questionId_fkey" FOREIGN KEY ("questionId") REFERENCES "Question"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Response" ADD CONSTRAINT "Response_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Response" ADD CONSTRAINT "Response_questionId_fkey" FOREIGN KEY ("questionId") REFERENCES "Question"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Response" ADD CONSTRAINT "Response_answerOptionId_fkey" FOREIGN KEY ("answerOptionId") REFERENCES "AnswerOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AssessmentResult" ADD CONSTRAINT "AssessmentResult_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AssessmentResult" ADD CONSTRAINT "AssessmentResult_moduleId_fkey" FOREIGN KEY ("moduleId") REFERENCES "DiagnosticModule"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Recommendation" ADD CONSTRAINT "Recommendation_moduleId_fkey" FOREIGN KEY ("moduleId") REFERENCES "DiagnosticModule"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EmailVerificationToken" ADD CONSTRAINT "EmailVerificationToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,50 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "OrganizationDocumentType" AS ENUM ('ACTA_CONSTITUTIVA');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Organization"
|
||||
ADD COLUMN "rfc" TEXT,
|
||||
ADD COLUMN "legalRepresentative" TEXT,
|
||||
ADD COLUMN "incorporationDate" TEXT,
|
||||
ADD COLUMN "deedNumber" TEXT,
|
||||
ADD COLUMN "notaryName" TEXT,
|
||||
ADD COLUMN "fiscalAddress" TEXT,
|
||||
ADD COLUMN "businessPurpose" TEXT,
|
||||
ADD COLUMN "actaExtractedData" JSONB,
|
||||
ADD COLUMN "actaUploadedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "onboardingCompletedAt" TIMESTAMP(3);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OrganizationDocument" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"type" "OrganizationDocumentType" NOT NULL,
|
||||
"fileName" TEXT NOT NULL,
|
||||
"storedFileName" TEXT NOT NULL,
|
||||
"filePath" TEXT NOT NULL,
|
||||
"mimeType" TEXT NOT NULL,
|
||||
"sizeBytes" INTEGER NOT NULL,
|
||||
"checksumSha256" TEXT,
|
||||
"extractedData" JSONB,
|
||||
"extractedTextSnippet" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "OrganizationDocument_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "OrganizationDocument_organizationId_type_key" ON "OrganizationDocument"("organizationId", "type");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "OrganizationDocument_userId_type_key" ON "OrganizationDocument"("userId", "type");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OrganizationDocument_type_idx" ON "OrganizationDocument"("type");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OrganizationDocument" ADD CONSTRAINT "OrganizationDocument_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OrganizationDocument" ADD CONSTRAINT "OrganizationDocument_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Organization"
|
||||
ADD COLUMN "stateOfIncorporation" TEXT,
|
||||
ADD COLUMN "companyType" TEXT,
|
||||
ADD COLUMN "actaLookupDictionary" JSONB;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Organization"
|
||||
ADD COLUMN "tradeName" TEXT,
|
||||
ADD COLUMN "operatingState" TEXT,
|
||||
ADD COLUMN "municipality" TEXT,
|
||||
ADD COLUMN "yearsOfOperation" TEXT,
|
||||
ADD COLUMN "annualRevenueRange" TEXT,
|
||||
ADD COLUMN "hasGovernmentContracts" BOOLEAN;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Response"
|
||||
ADD COLUMN "evidence" JSONB;
|
||||
@@ -0,0 +1,42 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "StrategicDiagnosticEvidenceSection" AS ENUM ('TECHNICAL', 'EXPERIENCE', 'ORGANIZATION', 'PUBLIC_PROCUREMENT');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Organization"
|
||||
ADD COLUMN "strategicDiagnosticData" JSONB,
|
||||
ADD COLUMN "strategicDiagnosticScores" JSONB,
|
||||
ADD COLUMN "strategicDiagnosticCompletedAt" TIMESTAMP(3);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "StrategicDiagnosticEvidenceDocument" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"section" "StrategicDiagnosticEvidenceSection" NOT NULL,
|
||||
"category" TEXT NOT NULL,
|
||||
"fileName" TEXT NOT NULL,
|
||||
"storedFileName" TEXT NOT NULL,
|
||||
"filePath" TEXT NOT NULL,
|
||||
"mimeType" TEXT NOT NULL,
|
||||
"sizeBytes" INTEGER NOT NULL,
|
||||
"checksumSha256" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "StrategicDiagnosticEvidenceDocument_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "StrategicDiagnosticEvidenceDocument_organizationId_section_idx" ON "StrategicDiagnosticEvidenceDocument"("organizationId", "section");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "StrategicDiagnosticEvidenceDocument_userId_section_idx" ON "StrategicDiagnosticEvidenceDocument"("userId", "section");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "StrategicDiagnosticEvidenceDocument_createdAt_idx" ON "StrategicDiagnosticEvidenceDocument"("createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "StrategicDiagnosticEvidenceDocument" ADD CONSTRAINT "StrategicDiagnosticEvidenceDocument_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "StrategicDiagnosticEvidenceDocument" ADD CONSTRAINT "StrategicDiagnosticEvidenceDocument_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,134 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LicitationSource" AS ENUM ('PNT', 'MUNICIPAL_BACKUP');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LicitationProcedureType" AS ENUM ('LICITACION_PUBLICA', 'INVITACION_RESTRINGIDA', 'ADJUDICACION_DIRECTA', 'UNKNOWN');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LicitationCategory" AS ENUM ('GOODS', 'SERVICES', 'WORKS', 'MIXED', 'UNKNOWN');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SyncRunStatus" AS ENUM ('SUCCESS', 'PARTIAL', 'FAILED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Municipality" (
|
||||
"id" TEXT NOT NULL,
|
||||
"stateCode" TEXT NOT NULL,
|
||||
"stateName" TEXT NOT NULL,
|
||||
"municipalityCode" TEXT NOT NULL,
|
||||
"municipalityName" TEXT NOT NULL,
|
||||
"pntSubjectId" TEXT,
|
||||
"pntEntityId" TEXT,
|
||||
"pntSectorId" TEXT,
|
||||
"pntEntryUrl" TEXT,
|
||||
"backupUrl" TEXT,
|
||||
"scrapingEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Municipality_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Licitation" (
|
||||
"id" TEXT NOT NULL,
|
||||
"municipalityId" TEXT NOT NULL,
|
||||
"source" "LicitationSource" NOT NULL,
|
||||
"sourceRecordId" TEXT NOT NULL,
|
||||
"procedureType" "LicitationProcedureType" NOT NULL DEFAULT 'UNKNOWN',
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"category" "LicitationCategory" DEFAULT 'UNKNOWN',
|
||||
"publishDate" TIMESTAMP(3),
|
||||
"eventDates" JSONB,
|
||||
"amount" DECIMAL(14,2),
|
||||
"currency" TEXT,
|
||||
"status" TEXT,
|
||||
"supplierAwarded" TEXT,
|
||||
"documents" JSONB,
|
||||
"rawSourceUrl" TEXT,
|
||||
"rawPayload" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Licitation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SyncRun" (
|
||||
"id" TEXT NOT NULL,
|
||||
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"finishedAt" TIMESTAMP(3),
|
||||
"municipalityId" TEXT,
|
||||
"source" "LicitationSource" NOT NULL,
|
||||
"status" "SyncRunStatus" NOT NULL DEFAULT 'SUCCESS',
|
||||
"stats" JSONB,
|
||||
"error" TEXT,
|
||||
|
||||
CONSTRAINT "SyncRun_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CompanyProfile" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"organizationId" TEXT,
|
||||
"locations" JSONB,
|
||||
"categoriesSupported" JSONB,
|
||||
"keywords" JSONB,
|
||||
"minAmount" DECIMAL(14,2),
|
||||
"maxAmount" DECIMAL(14,2),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "CompanyProfile_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Municipality_stateCode_municipalityCode_key" ON "Municipality"("stateCode", "municipalityCode");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Municipality_stateCode_municipalityName_idx" ON "Municipality"("stateCode", "municipalityName");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Municipality_isActive_scrapingEnabled_idx" ON "Municipality"("isActive", "scrapingEnabled");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Licitation_municipalityId_source_sourceRecordId_key" ON "Licitation"("municipalityId", "source", "sourceRecordId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Licitation_municipalityId_publishDate_idx" ON "Licitation"("municipalityId", "publishDate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Licitation_procedureType_category_idx" ON "Licitation"("procedureType", "category");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Licitation_amount_idx" ON "Licitation"("amount");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Licitation_createdAt_idx" ON "Licitation"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SyncRun_municipalityId_startedAt_idx" ON "SyncRun"("municipalityId", "startedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SyncRun_source_status_startedAt_idx" ON "SyncRun"("source", "status", "startedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CompanyProfile_userId_key" ON "CompanyProfile"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CompanyProfile_organizationId_key" ON "CompanyProfile"("organizationId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Licitation" ADD CONSTRAINT "Licitation_municipalityId_fkey" FOREIGN KEY ("municipalityId") REFERENCES "Municipality"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SyncRun" ADD CONSTRAINT "SyncRun_municipalityId_fkey" FOREIGN KEY ("municipalityId") REFERENCES "Municipality"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CompanyProfile" ADD CONSTRAINT "CompanyProfile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CompanyProfile" ADD CONSTRAINT "CompanyProfile_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "LicitationSource" ADD VALUE IF NOT EXISTS 'MUNICIPAL_OPEN_PORTAL';
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "MunicipalOpenPortalType" AS ENUM ('GENERIC', 'SAN_PEDRO_ASPX');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Municipality"
|
||||
ADD COLUMN "openPortalUrl" TEXT,
|
||||
ADD COLUMN "openPortalType" "MunicipalOpenPortalType" NOT NULL DEFAULT 'GENERIC',
|
||||
ADD COLUMN "openSyncIntervalDays" INTEGER NOT NULL DEFAULT 7,
|
||||
ADD COLUMN "lastOpenSyncAt" TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Licitation"
|
||||
ADD COLUMN "tenderCode" TEXT,
|
||||
ADD COLUMN "isOpen" BOOLEAN NOT NULL DEFAULT true,
|
||||
ADD COLUMN "openingDate" TIMESTAMP(3),
|
||||
ADD COLUMN "closingDate" TIMESTAMP(3),
|
||||
ADD COLUMN "lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Licitation_municipalityId_isOpen_closingDate_idx" ON "Licitation"("municipalityId", "isOpen", "closingDate");
|
||||
@@ -0,0 +1,99 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "WorkshopProgressStatus" AS ENUM ('NOT_STARTED', 'WATCHED', 'EVIDENCE_SUBMITTED', 'APPROVED', 'REJECTED', 'SKIPPED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "WorkshopEvidenceValidationStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED', 'ERROR');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "DevelopmentWorkshop" (
|
||||
"id" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"moduleId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"summary" TEXT NOT NULL,
|
||||
"videoUrl" TEXT NOT NULL,
|
||||
"durationMinutes" INTEGER NOT NULL DEFAULT 0,
|
||||
"evidenceRequired" TEXT NOT NULL,
|
||||
"learningObjectives" JSONB,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "DevelopmentWorkshop_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "DevelopmentWorkshopProgress" (
|
||||
"id" TEXT NOT NULL,
|
||||
"workshopId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"status" "WorkshopProgressStatus" NOT NULL DEFAULT 'NOT_STARTED',
|
||||
"watchedAt" TIMESTAMP(3),
|
||||
"skippedAt" TIMESTAMP(3),
|
||||
"completedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "DevelopmentWorkshopProgress_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "DevelopmentWorkshopEvidence" (
|
||||
"id" TEXT NOT NULL,
|
||||
"workshopId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"validationStatus" "WorkshopEvidenceValidationStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"validationReason" TEXT,
|
||||
"validationConfidence" DOUBLE PRECISION,
|
||||
"validatedAt" TIMESTAMP(3),
|
||||
"fileName" TEXT NOT NULL,
|
||||
"storedFileName" TEXT NOT NULL,
|
||||
"filePath" TEXT NOT NULL,
|
||||
"mimeType" TEXT NOT NULL,
|
||||
"sizeBytes" INTEGER NOT NULL,
|
||||
"checksumSha256" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "DevelopmentWorkshopEvidence_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "DevelopmentWorkshop_key_key" ON "DevelopmentWorkshop"("key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DevelopmentWorkshop_moduleId_sortOrder_idx" ON "DevelopmentWorkshop"("moduleId", "sortOrder");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DevelopmentWorkshop_isActive_sortOrder_idx" ON "DevelopmentWorkshop"("isActive", "sortOrder");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "DevelopmentWorkshopProgress_workshopId_userId_key" ON "DevelopmentWorkshopProgress"("workshopId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DevelopmentWorkshopProgress_userId_status_idx" ON "DevelopmentWorkshopProgress"("userId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DevelopmentWorkshopProgress_workshopId_status_idx" ON "DevelopmentWorkshopProgress"("workshopId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DevelopmentWorkshopEvidence_userId_workshopId_createdAt_idx" ON "DevelopmentWorkshopEvidence"("userId", "workshopId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DevelopmentWorkshopEvidence_workshopId_validationStatus_idx" ON "DevelopmentWorkshopEvidence"("workshopId", "validationStatus");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DevelopmentWorkshop" ADD CONSTRAINT "DevelopmentWorkshop_moduleId_fkey" FOREIGN KEY ("moduleId") REFERENCES "DiagnosticModule"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DevelopmentWorkshopProgress" ADD CONSTRAINT "DevelopmentWorkshopProgress_workshopId_fkey" FOREIGN KEY ("workshopId") REFERENCES "DevelopmentWorkshop"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DevelopmentWorkshopProgress" ADD CONSTRAINT "DevelopmentWorkshopProgress_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DevelopmentWorkshopEvidence" ADD CONSTRAINT "DevelopmentWorkshopEvidence_workshopId_fkey" FOREIGN KEY ("workshopId") REFERENCES "DevelopmentWorkshop"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DevelopmentWorkshopEvidence" ADD CONSTRAINT "DevelopmentWorkshopEvidence_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
477
prisma/schema.prisma
Normal file
477
prisma/schema.prisma
Normal file
@@ -0,0 +1,477 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum PriorityLevel {
|
||||
LOW
|
||||
MEDIUM
|
||||
HIGH
|
||||
}
|
||||
|
||||
enum UserRole {
|
||||
USER
|
||||
ADMIN
|
||||
}
|
||||
|
||||
enum ContentPageType {
|
||||
FAQ
|
||||
MANUAL
|
||||
}
|
||||
|
||||
enum OverallScoreMethod {
|
||||
EQUAL_ALL_MODULES
|
||||
EQUAL_ANSWERED_MODULES
|
||||
WEIGHTED_ANSWERED_MODULES
|
||||
}
|
||||
|
||||
enum OrganizationDocumentType {
|
||||
ACTA_CONSTITUTIVA
|
||||
}
|
||||
|
||||
enum StrategicDiagnosticEvidenceSection {
|
||||
TECHNICAL
|
||||
EXPERIENCE
|
||||
ORGANIZATION
|
||||
PUBLIC_PROCUREMENT
|
||||
}
|
||||
|
||||
enum WorkshopProgressStatus {
|
||||
NOT_STARTED
|
||||
WATCHED
|
||||
EVIDENCE_SUBMITTED
|
||||
APPROVED
|
||||
REJECTED
|
||||
SKIPPED
|
||||
}
|
||||
|
||||
enum WorkshopEvidenceValidationStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
ERROR
|
||||
}
|
||||
|
||||
enum LicitationSource {
|
||||
MUNICIPAL_OPEN_PORTAL
|
||||
PNT
|
||||
MUNICIPAL_BACKUP
|
||||
}
|
||||
|
||||
enum LicitationProcedureType {
|
||||
LICITACION_PUBLICA
|
||||
INVITACION_RESTRINGIDA
|
||||
ADJUDICACION_DIRECTA
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
enum LicitationCategory {
|
||||
GOODS
|
||||
SERVICES
|
||||
WORKS
|
||||
MIXED
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
enum SyncRunStatus {
|
||||
SUCCESS
|
||||
PARTIAL
|
||||
FAILED
|
||||
}
|
||||
|
||||
enum MunicipalOpenPortalType {
|
||||
GENERIC
|
||||
SAN_PEDRO_ASPX
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
passwordHash String
|
||||
name String?
|
||||
role UserRole @default(USER)
|
||||
emailVerifiedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
organization Organization?
|
||||
companyProfile CompanyProfile?
|
||||
organizationDocs OrganizationDocument[]
|
||||
strategicDiagnosticEvidenceDocs StrategicDiagnosticEvidenceDocument[]
|
||||
workshopProgresses DevelopmentWorkshopProgress[]
|
||||
workshopEvidenceDocs DevelopmentWorkshopEvidence[]
|
||||
verificationTokens EmailVerificationToken[]
|
||||
responses Response[]
|
||||
results AssessmentResult[]
|
||||
}
|
||||
|
||||
model Organization {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique
|
||||
name String
|
||||
tradeName String?
|
||||
rfc String?
|
||||
legalRepresentative String?
|
||||
incorporationDate String?
|
||||
deedNumber String?
|
||||
notaryName String?
|
||||
stateOfIncorporation String?
|
||||
companyType String?
|
||||
fiscalAddress String?
|
||||
businessPurpose String?
|
||||
industry String?
|
||||
operatingState String?
|
||||
municipality String?
|
||||
companySize String?
|
||||
yearsOfOperation String?
|
||||
annualRevenueRange String?
|
||||
hasGovernmentContracts Boolean?
|
||||
country String?
|
||||
primaryObjective String?
|
||||
actaExtractedData Json?
|
||||
actaLookupDictionary Json?
|
||||
actaUploadedAt DateTime?
|
||||
onboardingCompletedAt DateTime?
|
||||
strategicDiagnosticData Json?
|
||||
strategicDiagnosticScores Json?
|
||||
strategicDiagnosticCompletedAt DateTime?
|
||||
companyProfile CompanyProfile?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
documents OrganizationDocument[]
|
||||
strategicDiagnosticEvidenceDocs StrategicDiagnosticEvidenceDocument[]
|
||||
}
|
||||
|
||||
model OrganizationDocument {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
userId String
|
||||
type OrganizationDocumentType
|
||||
fileName String
|
||||
storedFileName String
|
||||
filePath String
|
||||
mimeType String
|
||||
sizeBytes Int
|
||||
checksumSha256 String?
|
||||
extractedData Json?
|
||||
extractedTextSnippet String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([organizationId, type])
|
||||
@@unique([userId, type])
|
||||
@@index([type])
|
||||
}
|
||||
|
||||
model StrategicDiagnosticEvidenceDocument {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
userId String
|
||||
section StrategicDiagnosticEvidenceSection
|
||||
category String
|
||||
fileName String
|
||||
storedFileName String
|
||||
filePath String
|
||||
mimeType String
|
||||
sizeBytes Int
|
||||
checksumSha256 String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([organizationId, section])
|
||||
@@index([userId, section])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model DiagnosticModule {
|
||||
id String @id @default(cuid())
|
||||
key String @unique
|
||||
name String
|
||||
description String?
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
questions Question[]
|
||||
results AssessmentResult[]
|
||||
recommendations Recommendation[]
|
||||
workshops DevelopmentWorkshop[]
|
||||
}
|
||||
|
||||
model DevelopmentWorkshop {
|
||||
id String @id @default(cuid())
|
||||
key String @unique
|
||||
moduleId String
|
||||
title String
|
||||
summary String
|
||||
videoUrl String
|
||||
durationMinutes Int @default(0)
|
||||
evidenceRequired String
|
||||
learningObjectives Json?
|
||||
sortOrder Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
module DiagnosticModule @relation(fields: [moduleId], references: [id], onDelete: Cascade)
|
||||
progresses DevelopmentWorkshopProgress[]
|
||||
evidences DevelopmentWorkshopEvidence[]
|
||||
|
||||
@@index([moduleId, sortOrder])
|
||||
@@index([isActive, sortOrder])
|
||||
}
|
||||
|
||||
model DevelopmentWorkshopProgress {
|
||||
id String @id @default(cuid())
|
||||
workshopId String
|
||||
userId String
|
||||
status WorkshopProgressStatus @default(NOT_STARTED)
|
||||
watchedAt DateTime?
|
||||
skippedAt DateTime?
|
||||
completedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
workshop DevelopmentWorkshop @relation(fields: [workshopId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([workshopId, userId])
|
||||
@@index([userId, status])
|
||||
@@index([workshopId, status])
|
||||
}
|
||||
|
||||
model DevelopmentWorkshopEvidence {
|
||||
id String @id @default(cuid())
|
||||
workshopId String
|
||||
userId String
|
||||
validationStatus WorkshopEvidenceValidationStatus @default(PENDING)
|
||||
validationReason String?
|
||||
validationConfidence Float?
|
||||
validatedAt DateTime?
|
||||
fileName String
|
||||
storedFileName String
|
||||
filePath String
|
||||
mimeType String
|
||||
sizeBytes Int
|
||||
checksumSha256 String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
workshop DevelopmentWorkshop @relation(fields: [workshopId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, workshopId, createdAt])
|
||||
@@index([workshopId, validationStatus])
|
||||
}
|
||||
|
||||
model Question {
|
||||
id String @id @default(cuid())
|
||||
key String @unique
|
||||
moduleId String
|
||||
prompt String
|
||||
helpText String?
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
module DiagnosticModule @relation(fields: [moduleId], references: [id], onDelete: Cascade)
|
||||
answerOptions AnswerOption[]
|
||||
responses Response[]
|
||||
|
||||
@@index([moduleId, sortOrder])
|
||||
}
|
||||
|
||||
model AnswerOption {
|
||||
id String @id @default(cuid())
|
||||
key String @unique
|
||||
questionId String
|
||||
label String
|
||||
weight Int
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
question Question @relation(fields: [questionId], references: [id], onDelete: Cascade)
|
||||
responses Response[]
|
||||
|
||||
@@index([questionId, sortOrder])
|
||||
}
|
||||
|
||||
model Response {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
questionId String
|
||||
answerOptionId String
|
||||
evidence Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
question Question @relation(fields: [questionId], references: [id], onDelete: Cascade)
|
||||
answerOption AnswerOption @relation(fields: [answerOptionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, questionId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model AssessmentResult {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
moduleId String?
|
||||
overallScore Float?
|
||||
moduleScore Float?
|
||||
metadata Json?
|
||||
calculatedAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
module DiagnosticModule? @relation(fields: [moduleId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([userId, calculatedAt])
|
||||
@@index([moduleId])
|
||||
}
|
||||
|
||||
model Recommendation {
|
||||
id String @id @default(cuid())
|
||||
key String @unique
|
||||
moduleId String?
|
||||
title String
|
||||
description String
|
||||
priority PriorityLevel @default(MEDIUM)
|
||||
isTemplate Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
module DiagnosticModule? @relation(fields: [moduleId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([moduleId])
|
||||
}
|
||||
|
||||
model ContentPage {
|
||||
id String @id @default(cuid())
|
||||
type ContentPageType
|
||||
slug String @unique
|
||||
title String
|
||||
content String
|
||||
sortOrder Int @default(0)
|
||||
isPublished Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([type, sortOrder])
|
||||
}
|
||||
|
||||
model ScoringConfig {
|
||||
id String @id @default(cuid())
|
||||
key String @unique
|
||||
lowScoreThreshold Int @default(70)
|
||||
overallScoreMethod OverallScoreMethod @default(EQUAL_ALL_MODULES)
|
||||
moduleWeights Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model EmailVerificationToken {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
token String @unique
|
||||
expiresAt DateTime
|
||||
consumedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
model Municipality {
|
||||
id String @id @default(cuid())
|
||||
stateCode String
|
||||
stateName String
|
||||
municipalityCode String
|
||||
municipalityName String
|
||||
openPortalUrl String?
|
||||
openPortalType MunicipalOpenPortalType @default(GENERIC)
|
||||
openSyncIntervalDays Int @default(7)
|
||||
lastOpenSyncAt DateTime?
|
||||
pntSubjectId String?
|
||||
pntEntityId String?
|
||||
pntSectorId String?
|
||||
pntEntryUrl String?
|
||||
backupUrl String?
|
||||
scrapingEnabled Boolean @default(true)
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
licitations Licitation[]
|
||||
syncRuns SyncRun[]
|
||||
|
||||
@@unique([stateCode, municipalityCode])
|
||||
@@index([stateCode, municipalityName])
|
||||
@@index([isActive, scrapingEnabled])
|
||||
}
|
||||
|
||||
model Licitation {
|
||||
id String @id @default(cuid())
|
||||
municipalityId String
|
||||
source LicitationSource
|
||||
sourceRecordId String
|
||||
tenderCode String?
|
||||
procedureType LicitationProcedureType @default(UNKNOWN)
|
||||
title String
|
||||
description String?
|
||||
category LicitationCategory? @default(UNKNOWN)
|
||||
isOpen Boolean @default(true)
|
||||
openingDate DateTime?
|
||||
closingDate DateTime?
|
||||
publishDate DateTime?
|
||||
eventDates Json?
|
||||
amount Decimal? @db.Decimal(14, 2)
|
||||
currency String?
|
||||
status String?
|
||||
supplierAwarded String?
|
||||
documents Json?
|
||||
rawSourceUrl String?
|
||||
rawPayload Json
|
||||
lastSeenAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
municipality Municipality @relation(fields: [municipalityId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([municipalityId, source, sourceRecordId])
|
||||
@@index([municipalityId, isOpen, closingDate])
|
||||
@@index([municipalityId, publishDate])
|
||||
@@index([procedureType, category])
|
||||
@@index([amount])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model SyncRun {
|
||||
id String @id @default(cuid())
|
||||
startedAt DateTime @default(now())
|
||||
finishedAt DateTime?
|
||||
municipalityId String?
|
||||
source LicitationSource
|
||||
status SyncRunStatus @default(SUCCESS)
|
||||
stats Json?
|
||||
error String?
|
||||
municipality Municipality? @relation(fields: [municipalityId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([municipalityId, startedAt])
|
||||
@@index([source, status, startedAt])
|
||||
}
|
||||
|
||||
model CompanyProfile {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique
|
||||
organizationId String? @unique
|
||||
locations Json?
|
||||
categoriesSupported Json?
|
||||
keywords Json?
|
||||
minAmount Decimal? @db.Decimal(14, 2)
|
||||
maxAmount Decimal? @db.Decimal(14, 2)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
|
||||
}
|
||||
729
prisma/seed.mjs
Normal file
729
prisma/seed.mjs
Normal file
@@ -0,0 +1,729 @@
|
||||
import { PrismaClient, ContentPageType, OverallScoreMethod, PriorityLevel } from "@prisma/client";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function yesNoOptions(questionKey) {
|
||||
return [
|
||||
{ key: `${questionKey}-opt-yes`, label: "Si", weight: 5, sortOrder: 1 },
|
||||
{ key: `${questionKey}-opt-no`, label: "No", weight: 0, sortOrder: 2 },
|
||||
];
|
||||
}
|
||||
|
||||
const moduleSeeds = [
|
||||
{
|
||||
key: "liderazgo-vision-estrategica",
|
||||
name: "Liderazgo y Vision Estrategica",
|
||||
description: "Capacidad de la direccion para definir y comunicar una vision clara orientada al valor publico.",
|
||||
sortOrder: 1,
|
||||
questions: [
|
||||
{
|
||||
key: "liderazgo-vision-estrategica-q1",
|
||||
prompt: "La direccion tiene una vision clara del proposito de la empresa mas alla de las ganancias?",
|
||||
helpText: null,
|
||||
sortOrder: 1,
|
||||
options: yesNoOptions("liderazgo-vision-estrategica-q1"),
|
||||
},
|
||||
{
|
||||
key: "liderazgo-vision-estrategica-q2",
|
||||
prompt: "Se comunica regularmente la estrategia empresarial a todo el equipo?",
|
||||
helpText: null,
|
||||
sortOrder: 2,
|
||||
options: yesNoOptions("liderazgo-vision-estrategica-q2"),
|
||||
},
|
||||
{
|
||||
key: "liderazgo-vision-estrategica-q3",
|
||||
prompt: "Existen objetivos medibles alineados con la generacion de valor publico?",
|
||||
helpText: null,
|
||||
sortOrder: 3,
|
||||
options: yesNoOptions("liderazgo-vision-estrategica-q3"),
|
||||
},
|
||||
{
|
||||
key: "liderazgo-vision-estrategica-q4",
|
||||
prompt: "La direccion participa activamente en la toma de decisiones estrategicas?",
|
||||
helpText: null,
|
||||
sortOrder: 4,
|
||||
options: yesNoOptions("liderazgo-vision-estrategica-q4"),
|
||||
},
|
||||
{
|
||||
key: "liderazgo-vision-estrategica-q5",
|
||||
prompt: "Se revisan y ajustan los planes estrategicos al menos anualmente?",
|
||||
helpText: null,
|
||||
sortOrder: 5,
|
||||
options: yesNoOptions("liderazgo-vision-estrategica-q5"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "cultura-organizacional",
|
||||
name: "Cultura Organizacional",
|
||||
description: "Valores, comportamientos y mentalidad orientados a la excelencia y el impacto social.",
|
||||
sortOrder: 2,
|
||||
questions: [
|
||||
{
|
||||
key: "cultura-organizacional-q1",
|
||||
prompt: "La empresa promueve valores de integridad y etica en sus operaciones?",
|
||||
helpText: null,
|
||||
sortOrder: 1,
|
||||
options: yesNoOptions("cultura-organizacional-q1"),
|
||||
},
|
||||
{
|
||||
key: "cultura-organizacional-q2",
|
||||
prompt: "Existe un ambiente de trabajo colaborativo y respetuoso?",
|
||||
helpText: null,
|
||||
sortOrder: 2,
|
||||
options: yesNoOptions("cultura-organizacional-q2"),
|
||||
},
|
||||
{
|
||||
key: "cultura-organizacional-q3",
|
||||
prompt: "Se fomenta la mejora continua y el aprendizaje organizacional?",
|
||||
helpText: null,
|
||||
sortOrder: 3,
|
||||
options: yesNoOptions("cultura-organizacional-q3"),
|
||||
},
|
||||
{
|
||||
key: "cultura-organizacional-q4",
|
||||
prompt: "Los empleados conocen y practican los valores de la empresa?",
|
||||
helpText: null,
|
||||
sortOrder: 4,
|
||||
options: yesNoOptions("cultura-organizacional-q4"),
|
||||
},
|
||||
{
|
||||
key: "cultura-organizacional-q5",
|
||||
prompt: "Se reconoce y celebra el buen desempeno del equipo?",
|
||||
helpText: null,
|
||||
sortOrder: 5,
|
||||
options: yesNoOptions("cultura-organizacional-q5"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "estructura-procesos",
|
||||
name: "Estructura y Procesos",
|
||||
description: "Organizacion interna, procedimientos y sistemas de gestion eficientes.",
|
||||
sortOrder: 3,
|
||||
questions: [
|
||||
{
|
||||
key: "estructura-procesos-q1",
|
||||
prompt: "Existen procesos documentados para las operaciones principales?",
|
||||
helpText: null,
|
||||
sortOrder: 1,
|
||||
options: yesNoOptions("estructura-procesos-q1"),
|
||||
},
|
||||
{
|
||||
key: "estructura-procesos-q2",
|
||||
prompt: "La empresa cuenta con un organigrama claro y funcional?",
|
||||
helpText: null,
|
||||
sortOrder: 2,
|
||||
options: yesNoOptions("estructura-procesos-q2"),
|
||||
},
|
||||
{
|
||||
key: "estructura-procesos-q3",
|
||||
prompt: "Se utilizan herramientas digitales para la gestion del negocio?",
|
||||
helpText: null,
|
||||
sortOrder: 3,
|
||||
options: yesNoOptions("estructura-procesos-q3"),
|
||||
},
|
||||
{
|
||||
key: "estructura-procesos-q4",
|
||||
prompt: "Existe un sistema de control de calidad en productos o servicios?",
|
||||
helpText: null,
|
||||
sortOrder: 4,
|
||||
options: yesNoOptions("estructura-procesos-q4"),
|
||||
},
|
||||
{
|
||||
key: "estructura-procesos-q5",
|
||||
prompt: "Se llevan registros financieros y contables actualizados?",
|
||||
helpText: null,
|
||||
sortOrder: 5,
|
||||
options: yesNoOptions("estructura-procesos-q5"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "innovacion-sostenibilidad",
|
||||
name: "Innovacion y Sostenibilidad",
|
||||
description: "Capacidad de adaptacion, mejora continua y practicas sostenibles.",
|
||||
sortOrder: 4,
|
||||
questions: [
|
||||
{
|
||||
key: "innovacion-sostenibilidad-q1",
|
||||
prompt: "La empresa invierte en innovacion de productos, servicios o procesos?",
|
||||
helpText: null,
|
||||
sortOrder: 1,
|
||||
options: yesNoOptions("innovacion-sostenibilidad-q1"),
|
||||
},
|
||||
{
|
||||
key: "innovacion-sostenibilidad-q2",
|
||||
prompt: "Se implementan practicas de sostenibilidad ambiental?",
|
||||
helpText: null,
|
||||
sortOrder: 2,
|
||||
options: yesNoOptions("innovacion-sostenibilidad-q2"),
|
||||
},
|
||||
{
|
||||
key: "innovacion-sostenibilidad-q3",
|
||||
prompt: "Existe disposicion para adoptar nuevas tecnologias?",
|
||||
helpText: null,
|
||||
sortOrder: 3,
|
||||
options: yesNoOptions("innovacion-sostenibilidad-q3"),
|
||||
},
|
||||
{
|
||||
key: "innovacion-sostenibilidad-q4",
|
||||
prompt: "Se monitorean tendencias del mercado y competencia?",
|
||||
helpText: null,
|
||||
sortOrder: 4,
|
||||
options: yesNoOptions("innovacion-sostenibilidad-q4"),
|
||||
},
|
||||
{
|
||||
key: "innovacion-sostenibilidad-q5",
|
||||
prompt: "Se busca activamente la eficiencia en el uso de recursos?",
|
||||
helpText: null,
|
||||
sortOrder: 5,
|
||||
options: yesNoOptions("innovacion-sostenibilidad-q5"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "impacto-social-equidad",
|
||||
name: "Impacto Social y Equidad",
|
||||
description: "Contribucion a la comunidad, inclusion y responsabilidad social.",
|
||||
sortOrder: 5,
|
||||
questions: [
|
||||
{
|
||||
key: "impacto-social-equidad-q1",
|
||||
prompt: "La empresa contribuye positivamente a la comunidad local?",
|
||||
helpText: null,
|
||||
sortOrder: 1,
|
||||
options: yesNoOptions("impacto-social-equidad-q1"),
|
||||
},
|
||||
{
|
||||
key: "impacto-social-equidad-q2",
|
||||
prompt: "Se promueve la equidad de genero en la organizacion?",
|
||||
helpText: null,
|
||||
sortOrder: 2,
|
||||
options: yesNoOptions("impacto-social-equidad-q2"),
|
||||
},
|
||||
{
|
||||
key: "impacto-social-equidad-q3",
|
||||
prompt: "Existen politicas de inclusion para grupos vulnerables?",
|
||||
helpText: null,
|
||||
sortOrder: 3,
|
||||
options: yesNoOptions("impacto-social-equidad-q3"),
|
||||
},
|
||||
{
|
||||
key: "impacto-social-equidad-q4",
|
||||
prompt: "Se considera el impacto social en las decisiones de negocio?",
|
||||
helpText: null,
|
||||
sortOrder: 4,
|
||||
options: yesNoOptions("impacto-social-equidad-q4"),
|
||||
},
|
||||
{
|
||||
key: "impacto-social-equidad-q5",
|
||||
prompt: "La empresa participa en iniciativas de responsabilidad social?",
|
||||
helpText: null,
|
||||
sortOrder: 5,
|
||||
options: yesNoOptions("impacto-social-equidad-q5"),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const recommendationSeeds = [
|
||||
{
|
||||
key: "rec-liderazgo-vision",
|
||||
moduleKey: "liderazgo-vision-estrategica",
|
||||
title: "Formalizar vision estrategica anual",
|
||||
description: "Define metas anuales medibles y comunica el avance con revisiones trimestrales para todo el equipo.",
|
||||
priority: PriorityLevel.HIGH,
|
||||
},
|
||||
{
|
||||
key: "rec-cultura-integridad",
|
||||
moduleKey: "cultura-organizacional",
|
||||
title: "Reforzar cultura de integridad y colaboracion",
|
||||
description: "Establece rutinas de reconocimiento y acciones concretas para fortalecer valores compartidos.",
|
||||
priority: PriorityLevel.MEDIUM,
|
||||
},
|
||||
{
|
||||
key: "rec-estructura-procesos",
|
||||
moduleKey: "estructura-procesos",
|
||||
title: "Estandarizar procesos y controles de calidad",
|
||||
description: "Documenta procesos clave y asigna responsables para mantener registros operativos y financieros al dia.",
|
||||
priority: PriorityLevel.HIGH,
|
||||
},
|
||||
{
|
||||
key: "rec-innovacion-sostenibilidad",
|
||||
moduleKey: "innovacion-sostenibilidad",
|
||||
title: "Activar plan de innovacion sostenible",
|
||||
description: "Prioriza proyectos de innovacion con impacto en eficiencia de recursos y seguimiento de tendencias del mercado.",
|
||||
priority: PriorityLevel.MEDIUM,
|
||||
},
|
||||
{
|
||||
key: "rec-impacto-social-equidad",
|
||||
moduleKey: "impacto-social-equidad",
|
||||
title: "Fortalecer estrategia de impacto social",
|
||||
description: "Define iniciativas de inclusion y equidad con indicadores concretos y resultados verificables.",
|
||||
priority: PriorityLevel.MEDIUM,
|
||||
},
|
||||
{
|
||||
key: "rec-gobernanza-global",
|
||||
moduleKey: null,
|
||||
title: "Instalar comite mensual de madurez empresarial",
|
||||
description: "Consolida resultados de los cinco modulos para priorizar inversiones y remover bloqueos.",
|
||||
priority: PriorityLevel.LOW,
|
||||
},
|
||||
];
|
||||
|
||||
const workshopSeeds = [
|
||||
{
|
||||
key: "taller-liderazgo-hoja-ruta",
|
||||
moduleKey: "liderazgo-vision-estrategica",
|
||||
title: "Hoja de Ruta de Liderazgo Estrategico",
|
||||
summary: "Alinea vision, objetivos y ritmos de seguimiento para mejorar la direccion estrategica del equipo.",
|
||||
videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
durationMinutes: 22,
|
||||
evidenceRequired: "Acta de sesion estrategica con objetivos trimestrales y responsables asignados.",
|
||||
learningObjectives: [
|
||||
"Definir prioridades estrategicas medibles",
|
||||
"Comunicar metas de forma transversal",
|
||||
"Establecer seguimiento mensual de resultados",
|
||||
],
|
||||
sortOrder: 1,
|
||||
},
|
||||
{
|
||||
key: "taller-cultura-colaborativa",
|
||||
moduleKey: "cultura-organizacional",
|
||||
title: "Ambiente de Trabajo Colaborativo",
|
||||
summary: "Fortalece dinamicas de comunicacion y colaboracion para elevar desempeno y compromiso del equipo.",
|
||||
videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
durationMinutes: 18,
|
||||
evidenceRequired: "Fotografia o acta de una dinamica de integracion aplicada con tu equipo.",
|
||||
learningObjectives: [
|
||||
"Implementar dinamicas de integracion",
|
||||
"Crear canales de comunicacion efectivos",
|
||||
"Manejar conflictos constructivamente",
|
||||
],
|
||||
sortOrder: 1,
|
||||
},
|
||||
{
|
||||
key: "taller-procesos-auditables",
|
||||
moduleKey: "estructura-procesos",
|
||||
title: "Procesos Auditables y Control Operativo",
|
||||
summary: "Documenta procesos clave y define controles para mejorar trazabilidad y cumplimiento.",
|
||||
videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
durationMinutes: 25,
|
||||
evidenceRequired: "Procedimiento documentado con roles, pasos, entradas y salidas del proceso.",
|
||||
learningObjectives: [
|
||||
"Mapear procesos criticos",
|
||||
"Definir controles de calidad",
|
||||
"Estandarizar evidencias operativas",
|
||||
],
|
||||
sortOrder: 1,
|
||||
},
|
||||
{
|
||||
key: "taller-innovacion-sostenible",
|
||||
moduleKey: "innovacion-sostenibilidad",
|
||||
title: "Innovacion Aplicada con Enfoque Sostenible",
|
||||
summary: "Disena mejoras de alto impacto para reducir costos y aumentar diferenciacion competitiva.",
|
||||
videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
durationMinutes: 20,
|
||||
evidenceRequired: "Ficha de iniciativa de innovacion con costo estimado, impacto y cronograma.",
|
||||
learningObjectives: [
|
||||
"Identificar oportunidades de mejora continua",
|
||||
"Evaluar impacto de iniciativas sostenibles",
|
||||
"Priorizar proyectos de innovacion factibles",
|
||||
],
|
||||
sortOrder: 1,
|
||||
},
|
||||
{
|
||||
key: "taller-impacto-social-evidencia",
|
||||
moduleKey: "impacto-social-equidad",
|
||||
title: "Impacto Social y Equidad con Evidencia",
|
||||
summary: "Define acciones de impacto social medibles para fortalecer tu posicion en licitaciones.",
|
||||
videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
durationMinutes: 24,
|
||||
evidenceRequired: "Plan de impacto social con objetivos, indicadores y responsables.",
|
||||
learningObjectives: [
|
||||
"Disenar iniciativas inclusivas",
|
||||
"Definir indicadores de impacto verificables",
|
||||
"Alinear acciones sociales con estrategia comercial",
|
||||
],
|
||||
sortOrder: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const contentPageSeeds = [
|
||||
{
|
||||
slug: "faq-calculo-puntaje",
|
||||
type: ContentPageType.FAQ,
|
||||
title: "Como se calcula el puntaje global?",
|
||||
content:
|
||||
"El puntaje global se obtiene de la normalizacion de respuestas por modulo y un promedio ponderado entre modulos.",
|
||||
sortOrder: 1,
|
||||
},
|
||||
{
|
||||
slug: "faq-pausa-diagnostico",
|
||||
type: ContentPageType.FAQ,
|
||||
title: "Puedo pausar y continuar luego?",
|
||||
content:
|
||||
"Si. Las respuestas quedan guardadas por usuario para retomar en el ultimo punto completado del cuestionario.",
|
||||
sortOrder: 2,
|
||||
},
|
||||
{
|
||||
slug: "manual-ruta-completa",
|
||||
type: ContentPageType.MANUAL,
|
||||
title: "Ruta completa de uso de la plataforma",
|
||||
content:
|
||||
"Registro, verificacion de correo, onboarding, diagnostico por modulos, resultados y recomendaciones accionables.",
|
||||
sortOrder: 1,
|
||||
},
|
||||
{
|
||||
slug: "manual-interpretacion-dashboard",
|
||||
type: ContentPageType.MANUAL,
|
||||
title: "Interpretacion de dashboard",
|
||||
content:
|
||||
"Use barras para avance relativo por modulo y radar para comparacion de madurez entre capacidades clave.",
|
||||
sortOrder: 2,
|
||||
},
|
||||
];
|
||||
|
||||
async function upsertDiagnosticStructure() {
|
||||
const moduleKeys = moduleSeeds.map((moduleSeed) => moduleSeed.key);
|
||||
|
||||
await prisma.diagnosticModule.deleteMany({
|
||||
where: {
|
||||
key: {
|
||||
notIn: moduleKeys,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const moduleSeed of moduleSeeds) {
|
||||
const moduleRecord = await prisma.diagnosticModule.upsert({
|
||||
where: { key: moduleSeed.key },
|
||||
update: {
|
||||
name: moduleSeed.name,
|
||||
description: moduleSeed.description,
|
||||
sortOrder: moduleSeed.sortOrder,
|
||||
},
|
||||
create: {
|
||||
key: moduleSeed.key,
|
||||
name: moduleSeed.name,
|
||||
description: moduleSeed.description,
|
||||
sortOrder: moduleSeed.sortOrder,
|
||||
},
|
||||
});
|
||||
|
||||
const questionKeys = moduleSeed.questions.map((questionSeed) => questionSeed.key);
|
||||
await prisma.question.deleteMany({
|
||||
where: {
|
||||
moduleId: moduleRecord.id,
|
||||
key: {
|
||||
notIn: questionKeys,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const questionSeed of moduleSeed.questions) {
|
||||
const questionRecord = await prisma.question.upsert({
|
||||
where: { key: questionSeed.key },
|
||||
update: {
|
||||
moduleId: moduleRecord.id,
|
||||
prompt: questionSeed.prompt,
|
||||
helpText: questionSeed.helpText,
|
||||
sortOrder: questionSeed.sortOrder,
|
||||
},
|
||||
create: {
|
||||
key: questionSeed.key,
|
||||
moduleId: moduleRecord.id,
|
||||
prompt: questionSeed.prompt,
|
||||
helpText: questionSeed.helpText,
|
||||
sortOrder: questionSeed.sortOrder,
|
||||
},
|
||||
});
|
||||
|
||||
const optionKeys = questionSeed.options.map((optionSeed) => optionSeed.key);
|
||||
await prisma.answerOption.deleteMany({
|
||||
where: {
|
||||
questionId: questionRecord.id,
|
||||
key: {
|
||||
notIn: optionKeys,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const optionSeed of questionSeed.options) {
|
||||
await prisma.answerOption.upsert({
|
||||
where: { key: optionSeed.key },
|
||||
update: {
|
||||
questionId: questionRecord.id,
|
||||
label: optionSeed.label,
|
||||
weight: optionSeed.weight,
|
||||
sortOrder: optionSeed.sortOrder,
|
||||
},
|
||||
create: {
|
||||
key: optionSeed.key,
|
||||
questionId: questionRecord.id,
|
||||
label: optionSeed.label,
|
||||
weight: optionSeed.weight,
|
||||
sortOrder: optionSeed.sortOrder,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertRecommendations() {
|
||||
const recommendationKeys = recommendationSeeds.map((recommendationSeed) => recommendationSeed.key);
|
||||
const moduleLookup = new Map(
|
||||
(await prisma.diagnosticModule.findMany({ select: { id: true, key: true } })).map((moduleRecord) => [moduleRecord.key, moduleRecord.id]),
|
||||
);
|
||||
|
||||
await prisma.recommendation.deleteMany({
|
||||
where: {
|
||||
key: {
|
||||
notIn: recommendationKeys,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const recommendationSeed of recommendationSeeds) {
|
||||
const moduleId = recommendationSeed.moduleKey ? moduleLookup.get(recommendationSeed.moduleKey) ?? null : null;
|
||||
|
||||
await prisma.recommendation.upsert({
|
||||
where: { key: recommendationSeed.key },
|
||||
update: {
|
||||
moduleId,
|
||||
title: recommendationSeed.title,
|
||||
description: recommendationSeed.description,
|
||||
priority: recommendationSeed.priority,
|
||||
isTemplate: true,
|
||||
},
|
||||
create: {
|
||||
key: recommendationSeed.key,
|
||||
moduleId,
|
||||
title: recommendationSeed.title,
|
||||
description: recommendationSeed.description,
|
||||
priority: recommendationSeed.priority,
|
||||
isTemplate: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertDevelopmentWorkshops() {
|
||||
const workshopKeys = workshopSeeds.map((workshopSeed) => workshopSeed.key);
|
||||
const moduleLookup = new Map(
|
||||
(await prisma.diagnosticModule.findMany({ select: { id: true, key: true } })).map((moduleRecord) => [moduleRecord.key, moduleRecord.id]),
|
||||
);
|
||||
|
||||
await prisma.developmentWorkshop.deleteMany({
|
||||
where: {
|
||||
key: {
|
||||
notIn: workshopKeys,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const workshopSeed of workshopSeeds) {
|
||||
const moduleId = moduleLookup.get(workshopSeed.moduleKey);
|
||||
|
||||
if (!moduleId) {
|
||||
// Skip orphan workshop seeds when module is unavailable.
|
||||
continue;
|
||||
}
|
||||
|
||||
await prisma.developmentWorkshop.upsert({
|
||||
where: { key: workshopSeed.key },
|
||||
update: {
|
||||
moduleId,
|
||||
title: workshopSeed.title,
|
||||
summary: workshopSeed.summary,
|
||||
videoUrl: workshopSeed.videoUrl,
|
||||
durationMinutes: workshopSeed.durationMinutes,
|
||||
evidenceRequired: workshopSeed.evidenceRequired,
|
||||
learningObjectives: workshopSeed.learningObjectives,
|
||||
sortOrder: workshopSeed.sortOrder,
|
||||
isActive: true,
|
||||
},
|
||||
create: {
|
||||
key: workshopSeed.key,
|
||||
moduleId,
|
||||
title: workshopSeed.title,
|
||||
summary: workshopSeed.summary,
|
||||
videoUrl: workshopSeed.videoUrl,
|
||||
durationMinutes: workshopSeed.durationMinutes,
|
||||
evidenceRequired: workshopSeed.evidenceRequired,
|
||||
learningObjectives: workshopSeed.learningObjectives,
|
||||
sortOrder: workshopSeed.sortOrder,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertContentPages() {
|
||||
for (const pageSeed of contentPageSeeds) {
|
||||
await prisma.contentPage.upsert({
|
||||
where: { slug: pageSeed.slug },
|
||||
update: {
|
||||
type: pageSeed.type,
|
||||
title: pageSeed.title,
|
||||
content: pageSeed.content,
|
||||
sortOrder: pageSeed.sortOrder,
|
||||
isPublished: true,
|
||||
},
|
||||
create: {
|
||||
slug: pageSeed.slug,
|
||||
type: pageSeed.type,
|
||||
title: pageSeed.title,
|
||||
content: pageSeed.content,
|
||||
sortOrder: pageSeed.sortOrder,
|
||||
isPublished: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertDefaultScoringConfig() {
|
||||
await prisma.scoringConfig.upsert({
|
||||
where: { key: "default" },
|
||||
update: {
|
||||
lowScoreThreshold: 70,
|
||||
overallScoreMethod: OverallScoreMethod.EQUAL_ALL_MODULES,
|
||||
moduleWeights: {},
|
||||
},
|
||||
create: {
|
||||
key: "default",
|
||||
lowScoreThreshold: 70,
|
||||
overallScoreMethod: OverallScoreMethod.EQUAL_ALL_MODULES,
|
||||
moduleWeights: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function loadMunicipalitySeeds() {
|
||||
const filePath = path.join(__dirname, "data", "municipalities.json");
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error("Invalid municipalities seed file format.");
|
||||
}
|
||||
|
||||
return parsed.filter((item) => {
|
||||
return (
|
||||
item &&
|
||||
typeof item.stateCode === "string" &&
|
||||
typeof item.stateName === "string" &&
|
||||
typeof item.municipalityCode === "string" &&
|
||||
typeof item.municipalityName === "string"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function upsertMunicipalities() {
|
||||
const municipalities = await loadMunicipalitySeeds();
|
||||
const activeKeys = municipalities.map((item) => `${item.stateCode}-${item.municipalityCode}`);
|
||||
|
||||
await prisma.municipality.updateMany({
|
||||
where: {
|
||||
NOT: municipalities.map((item) => ({
|
||||
stateCode: item.stateCode,
|
||||
municipalityCode: item.municipalityCode,
|
||||
})),
|
||||
},
|
||||
data: {
|
||||
isActive: false,
|
||||
},
|
||||
});
|
||||
|
||||
for (const municipality of municipalities) {
|
||||
await prisma.municipality.upsert({
|
||||
where: {
|
||||
stateCode_municipalityCode: {
|
||||
stateCode: municipality.stateCode,
|
||||
municipalityCode: municipality.municipalityCode,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
stateName: municipality.stateName,
|
||||
municipalityName: municipality.municipalityName,
|
||||
openPortalUrl: municipality.openPortalUrl ?? null,
|
||||
openPortalType: municipality.openPortalType ?? "GENERIC",
|
||||
openSyncIntervalDays:
|
||||
typeof municipality.openSyncIntervalDays === "number" && municipality.openSyncIntervalDays > 0
|
||||
? municipality.openSyncIntervalDays
|
||||
: 7,
|
||||
pntSubjectId: municipality.pntSubjectId ?? null,
|
||||
pntEntityId: municipality.pntEntityId ?? null,
|
||||
pntSectorId: municipality.pntSectorId ?? null,
|
||||
pntEntryUrl: municipality.pntEntryUrl ?? null,
|
||||
backupUrl: municipality.backupUrl ?? null,
|
||||
scrapingEnabled: municipality.scrapingEnabled !== false,
|
||||
isActive: municipality.isActive !== false,
|
||||
},
|
||||
create: {
|
||||
stateCode: municipality.stateCode,
|
||||
stateName: municipality.stateName,
|
||||
municipalityCode: municipality.municipalityCode,
|
||||
municipalityName: municipality.municipalityName,
|
||||
openPortalUrl: municipality.openPortalUrl ?? null,
|
||||
openPortalType: municipality.openPortalType ?? "GENERIC",
|
||||
openSyncIntervalDays:
|
||||
typeof municipality.openSyncIntervalDays === "number" && municipality.openSyncIntervalDays > 0
|
||||
? municipality.openSyncIntervalDays
|
||||
: 7,
|
||||
pntSubjectId: municipality.pntSubjectId ?? null,
|
||||
pntEntityId: municipality.pntEntityId ?? null,
|
||||
pntSectorId: municipality.pntSectorId ?? null,
|
||||
pntEntryUrl: municipality.pntEntryUrl ?? null,
|
||||
backupUrl: municipality.backupUrl ?? null,
|
||||
scrapingEnabled: municipality.scrapingEnabled !== false,
|
||||
isActive: municipality.isActive !== false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return activeKeys.length;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await upsertDiagnosticStructure();
|
||||
await upsertDevelopmentWorkshops();
|
||||
await upsertRecommendations();
|
||||
await upsertContentPages();
|
||||
await upsertDefaultScoringConfig();
|
||||
const municipalitySeedCount = await upsertMunicipalities();
|
||||
|
||||
const moduleCount = await prisma.diagnosticModule.count();
|
||||
const questionCount = await prisma.question.count();
|
||||
const optionCount = await prisma.answerOption.count();
|
||||
const workshopCount = await prisma.developmentWorkshop.count();
|
||||
const recommendationCount = await prisma.recommendation.count();
|
||||
const contentPageCount = await prisma.contentPage.count();
|
||||
const municipalityCount = await prisma.municipality.count({ where: { isActive: true } });
|
||||
|
||||
console.log("Seed completed", {
|
||||
modules: moduleCount,
|
||||
questions: questionCount,
|
||||
answerOptions: optionCount,
|
||||
workshops: workshopCount,
|
||||
recommendations: recommendationCount,
|
||||
contentPages: contentPageCount,
|
||||
municipalities: municipalityCount,
|
||||
municipalitySeedsProcessed: municipalitySeedCount,
|
||||
});
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error("Seed failed", error);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
311
prog.md
Normal file
311
prog.md
Normal file
@@ -0,0 +1,311 @@
|
||||
# PROG.md
|
||||
|
||||
## 1) Current Project Status
|
||||
|
||||
This project now implements **Phases 1 through 7** fully, and includes key **Phase 8 QA/polish updates**:
|
||||
|
||||
- Next.js App Router + TypeScript + Tailwind + npm
|
||||
- Prisma + Postgres schema + seed
|
||||
- Real email/password auth + verification token + session cookie + logout
|
||||
- Onboarding wizard persisted to `Organization`
|
||||
- Diagnostic engine with DB questions, autosave, and resume
|
||||
- Scoring engine persisted to `AssessmentResult`
|
||||
- Results and Dashboard fed from computed DB scores (not mock data)
|
||||
- Recommendations generated from low-scoring modules
|
||||
- Manual/FAQ loaded from `ContentPage` in DB
|
||||
- Added global loading and error UI states
|
||||
- Added baseline tests for scoring and session-token logic
|
||||
|
||||
Validation currently passes:
|
||||
|
||||
- `npm test`
|
||||
- `npm run lint`
|
||||
- `npx prisma validate`
|
||||
- `npm run build`
|
||||
|
||||
---
|
||||
|
||||
## 2) What Was Implemented (Phase-by-Phase)
|
||||
|
||||
### Phase 1: Scaffold + UI shell
|
||||
|
||||
Implemented in:
|
||||
- `src/components/ui/*`
|
||||
- `src/components/app/page-shell.tsx`
|
||||
- route pages under `src/app/*`
|
||||
|
||||
Delivered:
|
||||
- Custom UI kit (`Button`, `Card`, `Input`, `Label`, `Badge`, `Progress`, `Tabs`, `Dialog`, `Accordion`, `Stepper`)
|
||||
- Shell styling (light gray background, card layout, navy buttons, success greens)
|
||||
- Static versions of all required routes
|
||||
|
||||
### Phase 2: Prisma + schema + seed
|
||||
|
||||
Implemented in:
|
||||
- `prisma/schema.prisma`
|
||||
- `prisma/seed.mjs`
|
||||
- `src/lib/prisma.ts`
|
||||
- `src/app/dev/db/page.tsx`
|
||||
|
||||
Delivered:
|
||||
- Full data model (`User`, `Organization`, `DiagnosticModule`, `Question`, `AnswerOption`, `Response`, `AssessmentResult`, `Recommendation`, `ContentPage`, `EmailVerificationToken`)
|
||||
- Seed script for modules/questions/options/recommendations/manual+faq
|
||||
- Dev DB viewer route
|
||||
|
||||
### Phase 3: Auth + email verification + session gating
|
||||
|
||||
Implemented in:
|
||||
- `src/lib/auth/*`
|
||||
- `src/app/api/auth/*`
|
||||
- `src/app/register/page.tsx`
|
||||
- `src/app/login/page.tsx`
|
||||
- `src/app/verify/page.tsx`
|
||||
- `src/components/app/page-shell.tsx`
|
||||
|
||||
Delivered:
|
||||
- Signup/login with password hashing
|
||||
- Verification token issue/consume flow
|
||||
- Dev verification link logging in server console
|
||||
- Signed HttpOnly session cookie
|
||||
- Route protection and logout
|
||||
|
||||
### Phase 4: Onboarding persistence
|
||||
|
||||
Implemented in:
|
||||
- `src/components/app/onboarding-wizard.tsx`
|
||||
- `src/app/onboarding/page.tsx`
|
||||
- `src/app/api/onboarding/route.ts`
|
||||
- `src/lib/auth/user.ts`
|
||||
|
||||
Delivered:
|
||||
- Multi-step onboarding wizard
|
||||
- Persist/upsert to `Organization` linked to authenticated user
|
||||
- Redirect to `/diagnostic` after successful onboarding
|
||||
- Gating so non-onboarded users are redirected to `/onboarding`
|
||||
|
||||
### Phase 5: Diagnostic engine
|
||||
|
||||
Implemented in:
|
||||
- `src/lib/diagnostic.ts`
|
||||
- `src/app/diagnostic/page.tsx`
|
||||
- `src/app/diagnostic/[moduleId]/page.tsx`
|
||||
- `src/components/app/module-questionnaire.tsx`
|
||||
- `src/app/api/diagnostic/response/route.ts`
|
||||
|
||||
Delivered:
|
||||
- DB-driven module list and progress metrics
|
||||
- Resume links by module/question index
|
||||
- Per-question autosave into `Response`
|
||||
- Next/back navigation + save-and-exit + resume
|
||||
|
||||
### Phase 6: Scoring + Results + Dashboard
|
||||
|
||||
Implemented in:
|
||||
- `src/lib/scoring.ts`
|
||||
- `src/lib/scoring-core.ts`
|
||||
- `src/app/results/page.tsx`
|
||||
- `src/app/dashboard/page.tsx`
|
||||
- `src/components/app/dashboard-view.tsx`
|
||||
|
||||
Delivered:
|
||||
- Recompute module and overall scores from response weights
|
||||
- Persist module and overall snapshots in `AssessmentResult`
|
||||
- Results page with global score and strengths/weaknesses
|
||||
- Dashboard page with progress/bars/radar + module status detail
|
||||
|
||||
### Phase 7: Recommendations + Manual/FAQ
|
||||
|
||||
Implemented in:
|
||||
- `src/lib/recommendations.ts`
|
||||
- `src/lib/content-pages.ts`
|
||||
- `src/app/recommendations/page.tsx`
|
||||
- `src/app/manual/page.tsx`
|
||||
|
||||
Delivered:
|
||||
- Recommendations generated from low-scoring modules
|
||||
- Manual and FAQ loaded from DB `ContentPage`
|
||||
- Download report placeholder button on recommendations
|
||||
|
||||
### Phase 8 (partial but substantive): QA/polish
|
||||
|
||||
Implemented in:
|
||||
- `src/app/loading.tsx`
|
||||
- `src/app/error.tsx`
|
||||
- tests under `src/lib/__tests__/*`
|
||||
- `vitest.config.ts`
|
||||
|
||||
Delivered:
|
||||
- Global loading state
|
||||
- Global error boundary UI
|
||||
- Unit tests for scoring core and session-token logic
|
||||
|
||||
---
|
||||
|
||||
## 3) Assumptions Made
|
||||
|
||||
1. **Email delivery in development**: verification is valid if token links are logged to server console (real SMTP not required yet).
|
||||
2. **Scoring model**:
|
||||
- Module score normalized by answered questions only (selected weight / max possible for answered questions).
|
||||
- Overall score computed as equal-weight average across modules.
|
||||
3. **Recommendation model**:
|
||||
- Primary recommendations come from low-scoring modules (`score < 70`) with answered questions.
|
||||
- Fallback to global (`moduleId = null`) recommendations if no module-targeted suggestions apply.
|
||||
4. **Onboarding gating**:
|
||||
- Users must be authenticated and onboarded to access diagnostic/results/dashboard/recommendations/manual.
|
||||
5. **Data availability**:
|
||||
- Seed data provides required baseline modules/questions/options/manual/recommendations.
|
||||
|
||||
---
|
||||
|
||||
## 4) How to Modify Logic / Implement New Specs
|
||||
|
||||
### A) Change scoring formula
|
||||
|
||||
Primary files:
|
||||
- `src/lib/scoring-core.ts`
|
||||
- `src/lib/scoring.ts`
|
||||
|
||||
What to change:
|
||||
- Update `computeAssessmentSnapshot(...)` for new formulas (weighted modules, penalties, thresholds, etc.)
|
||||
- Keep DB persistence in `recomputeAssessmentResults(...)` synchronized with new output fields
|
||||
|
||||
### B) Change low-score threshold or recommendation strategy
|
||||
|
||||
Primary file:
|
||||
- `src/lib/recommendations.ts`
|
||||
|
||||
What to change:
|
||||
- Modify filter condition (`moduleScore.score < 70`)
|
||||
- Adjust ranking/prioritization and number of recommendations per module
|
||||
- Add role/industry/context-aware filtering if needed
|
||||
|
||||
### C) Add onboarding fields
|
||||
|
||||
Primary files:
|
||||
- `prisma/schema.prisma` (`Organization` model)
|
||||
- `src/components/app/onboarding-wizard.tsx`
|
||||
- `src/app/api/onboarding/route.ts`
|
||||
|
||||
What to change:
|
||||
- Add DB columns + migrate
|
||||
- Add step inputs/state
|
||||
- Persist and validate in API route
|
||||
|
||||
### D) Add or modify diagnostic modules/questions/options
|
||||
|
||||
Primary file:
|
||||
- `prisma/seed.mjs`
|
||||
|
||||
What to change:
|
||||
- Add new module/question/option seed definitions (`key` values must be unique)
|
||||
- Rerun migrate/seed
|
||||
|
||||
### E) Swap verification transport to real email provider
|
||||
|
||||
Primary files:
|
||||
- `src/lib/auth/verification.ts`
|
||||
- `src/app/api/auth/register/route.ts`
|
||||
- `src/app/api/auth/login/route.ts`
|
||||
- `src/app/api/auth/resend/route.ts`
|
||||
|
||||
What to change:
|
||||
- Keep token generation/consumption as-is
|
||||
- Replace console logging with provider API call (SES/SendGrid/Postmark)
|
||||
|
||||
### F) Customize dashboard charts and result blocks
|
||||
|
||||
Primary files:
|
||||
- `src/components/app/dashboard-view.tsx`
|
||||
- `src/components/app/module-bars-card.tsx`
|
||||
- `src/components/app/radar-chart-card.tsx`
|
||||
- `src/app/results/page.tsx`
|
||||
|
||||
What to change:
|
||||
- Adjust chart labels, colors, ranges, and data mapping
|
||||
- Add additional cards/insights from `AssessmentResult` or response-level analytics
|
||||
|
||||
### G) Session hardening
|
||||
|
||||
Primary files:
|
||||
- `src/lib/auth/session-token.ts`
|
||||
- `src/lib/auth/session.ts`
|
||||
- `src/lib/auth/constants.ts`
|
||||
|
||||
What to change:
|
||||
- Rotate `SESSION_SECRET`
|
||||
- Add token versioning/invalidations
|
||||
- Add shorter TTL + refresh logic
|
||||
|
||||
---
|
||||
|
||||
## 5) Full Diagnosis vs Original Plan (and Fixes Applied)
|
||||
|
||||
### Summary
|
||||
|
||||
The implementation now satisfies all required Phase 1-7 deliverables. The original gap found during this diagnosis was Phase 8 hardening depth (loading/error/test baseline), which has now been addressed.
|
||||
|
||||
### Detailed checklist
|
||||
|
||||
- Phase 1 scaffold + UI kit + static route shell: **Done**
|
||||
- Phase 2 schema + seed + DB viewer: **Done**
|
||||
- Phase 3 real auth + verify + session + logout: **Done**
|
||||
- Phase 4 onboarding wizard + persistence + redirect: **Done**
|
||||
- Phase 5 diagnostic autosave + resume + progress: **Done**
|
||||
- Phase 6 scoring persistence + results + dashboard charts: **Done**
|
||||
- Phase 7 recommendations from low scores + manual/faq from DB + report placeholder: **Done**
|
||||
- Phase 8 required polish subset:
|
||||
- consistency baseline: **Done**
|
||||
- loading/empty/error states: **Done**
|
||||
- validation/friendly errors baseline: **Done**
|
||||
- optional tests: **Done (added baseline unit tests)**
|
||||
|
||||
### Differences discovered and fixed during this pass
|
||||
|
||||
1. **No baseline automated tests existed** -> Added Vitest + 5 unit tests:
|
||||
- `src/lib/__tests__/scoring-core.test.ts`
|
||||
- `src/lib/__tests__/session-token.test.ts`
|
||||
2. **No global loading/error route states** -> Added:
|
||||
- `src/app/loading.tsx`
|
||||
- `src/app/error.tsx`
|
||||
3. **Residual mock file not used** -> Removed:
|
||||
- `src/lib/mock-data.ts`
|
||||
|
||||
---
|
||||
|
||||
## 6) Runbook
|
||||
|
||||
From ` /var/opt/assessment-app `:
|
||||
|
||||
1. Install deps:
|
||||
- `npm install`
|
||||
2. Generate Prisma client:
|
||||
- `npm run prisma:generate`
|
||||
3. Run migration(s):
|
||||
- `npm run prisma:migrate -- --name init`
|
||||
4. Seed data:
|
||||
- `npm run prisma:seed`
|
||||
5. Start app:
|
||||
- `npm run dev`
|
||||
6. Quality checks:
|
||||
- `npm test`
|
||||
- `npm run lint`
|
||||
- `npm run build`
|
||||
|
||||
---
|
||||
|
||||
## 7) External Dependencies / Pending Inputs
|
||||
|
||||
- A working Postgres connection string is required in `.env`:
|
||||
- `DATABASE_URL=...`
|
||||
- Session secret should be set to a secure value in non-dev:
|
||||
- `SESSION_SECRET=...`
|
||||
- Email verification currently logs links in server console; production email delivery is not wired yet.
|
||||
|
||||
---
|
||||
|
||||
## 8) Suggested Next Implementation Targets
|
||||
|
||||
1. Add integration tests for register/login/verify and diagnostic autosave flow.
|
||||
2. Add role-based access controls (admin/viewer) for organization scope.
|
||||
3. Add report generation endpoint (PDF) behind the current placeholder button.
|
||||
4. Add analytics/versioned scoring snapshots to compare improvements over time.
|
||||
344
scripts/analyze-acta-ai.mjs
Normal file
344
scripts/analyze-acta-ai.mjs
Normal file
@@ -0,0 +1,344 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { PDFParse } from "pdf-parse";
|
||||
|
||||
const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
|
||||
const DEFAULT_OPENAI_MODEL = process.env.OPENAI_ACTA_MODEL?.trim() || "gpt-4.1-mini";
|
||||
const DEFAULT_OCR_LANGUAGE = "spa+eng";
|
||||
const MIN_DOC_CHARS = 200;
|
||||
const MIN_CHARS_PER_PAGE = 50;
|
||||
|
||||
function loadDotEnv() {
|
||||
const envPath = path.resolve(process.cwd(), ".env");
|
||||
if (!existsSync(envPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = readFileSync(envPath, "utf8");
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const separator = trimmed.indexOf("=");
|
||||
if (separator <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = trimmed.slice(0, separator).trim();
|
||||
let value = trimmed.slice(separator + 1).trim();
|
||||
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
if (key && !process.env[key]) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadDotEnv();
|
||||
|
||||
function usage() {
|
||||
console.log("Usage: node scripts/analyze-acta-ai.mjs <path-to-pdf> [--out output.json]");
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = [...argv];
|
||||
const filePath = args[0];
|
||||
let outPath = null;
|
||||
|
||||
for (let index = 1; index < args.length; index += 1) {
|
||||
if (args[index] === "--out") {
|
||||
outPath = args[index + 1] ?? null;
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { filePath, outPath };
|
||||
}
|
||||
|
||||
function normalizeText(rawText) {
|
||||
return rawText
|
||||
.replace(/\u0000/g, " ")
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.replace(/\n?\s*--\s*\d+\s*of\s*\d+\s*--\s*\n?/gi, "\n")
|
||||
.replace(/\n?\s*p[áa]gina\s+\d+\s+de\s+\d+\s*\n?/gi, "\n")
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/\s+/g, " ").trim())
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
async function extractTextFromPdfBuffer(buffer) {
|
||||
const parser = new PDFParse({ data: buffer });
|
||||
try {
|
||||
const parsed = await parser.getText();
|
||||
const rawText = typeof parsed.text === "string" ? parsed.text : "";
|
||||
const text = normalizeText(rawText);
|
||||
const numPages = typeof parsed.total === "number" && Number.isFinite(parsed.total) ? parsed.total : 0;
|
||||
return { text, numPages };
|
||||
} finally {
|
||||
await parser.destroy().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function shouldApplyOcr(text, numPages) {
|
||||
const totalChars = text.trim().length;
|
||||
const pages = Math.max(numPages, 1);
|
||||
const charsPerPage = totalChars / pages;
|
||||
return totalChars < MIN_DOC_CHARS || charsPerPage < MIN_CHARS_PER_PAGE;
|
||||
}
|
||||
|
||||
function runCommand(command, args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
reject(error);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ stdout, stderr });
|
||||
return;
|
||||
}
|
||||
reject(new Error(`${command} failed with code ${code}: ${stderr}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createTempPdfPath(prefix = "acta-ai") {
|
||||
return path.join(tmpdir(), `${prefix}-${Date.now()}-${randomUUID()}.pdf`);
|
||||
}
|
||||
|
||||
async function runOcrAndExtractText(originalBuffer, lang = DEFAULT_OCR_LANGUAGE) {
|
||||
const inputPath = createTempPdfPath("acta-input");
|
||||
const outputPath = createTempPdfPath("acta-output");
|
||||
|
||||
try {
|
||||
await writeFile(inputPath, originalBuffer);
|
||||
await runCommand("ocrmypdf", ["--skip-text", "--force-ocr", "-l", lang, inputPath, outputPath]);
|
||||
|
||||
const ocrBuffer = await readFile(outputPath);
|
||||
const parsed = await extractTextFromPdfBuffer(ocrBuffer);
|
||||
return parsed;
|
||||
} finally {
|
||||
await Promise.all([unlink(inputPath).catch(() => undefined), unlink(outputPath).catch(() => undefined)]);
|
||||
}
|
||||
}
|
||||
|
||||
function parseIntegerEnv(name, fallback) {
|
||||
const parsed = Number.parseInt((process.env[name] ?? "").trim(), 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function getOpenAiApiKey() {
|
||||
return process.env.OPENAI_API_KEY?.trim() || process.env.API_KEY?.trim() || process.env.api_key?.trim() || "";
|
||||
}
|
||||
|
||||
function clampActaText(text) {
|
||||
const maxChars = parseIntegerEnv("OPENAI_ACTA_MAX_CHARS", 45_000);
|
||||
if (text.length <= maxChars) {
|
||||
return text;
|
||||
}
|
||||
return `${text.slice(0, maxChars)}\n\n[TEXT_TRUNCATED_TO_${maxChars}_CHARS]`;
|
||||
}
|
||||
|
||||
function extractJsonObject(rawContent) {
|
||||
const trimmed = rawContent.trim();
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
|
||||
const withoutFence = trimmed
|
||||
.replace(/^```json\s*/i, "")
|
||||
.replace(/^```\s*/i, "")
|
||||
.replace(/\s*```$/i, "")
|
||||
.trim();
|
||||
|
||||
try {
|
||||
return JSON.parse(withoutFence);
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
|
||||
const firstBrace = withoutFence.indexOf("{");
|
||||
const lastBrace = withoutFence.lastIndexOf("}");
|
||||
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
||||
return JSON.parse(withoutFence.slice(firstBrace, lastBrace + 1));
|
||||
}
|
||||
|
||||
throw new Error("AI response did not contain valid JSON.");
|
||||
}
|
||||
|
||||
async function extractWithAi(fullText) {
|
||||
const apiKey = getOpenAiApiKey();
|
||||
if (!apiKey) {
|
||||
throw new Error("OpenAI API key is missing (OPENAI_API_KEY or api_key).");
|
||||
}
|
||||
|
||||
const baseUrl = (process.env.OPENAI_API_BASE_URL?.trim() || DEFAULT_OPENAI_BASE_URL).replace(/\/+$/, "");
|
||||
const timeoutMs = parseIntegerEnv("OPENAI_ACTA_TIMEOUT_MS", 60_000);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
const systemPrompt = [
|
||||
"Eres analista legal experto en actas constitutivas mexicanas.",
|
||||
"Devuelve exclusivamente JSON valido, sin markdown.",
|
||||
"Si un campo no aparece, usa null.",
|
||||
"No inventes datos.",
|
||||
"rfc en fields debe ser null.",
|
||||
"lookupDictionary.version debe ser exactamente 'mx_acta_constitutiva_reference_v1'.",
|
||||
].join(" ");
|
||||
|
||||
const userPrompt = [
|
||||
"Extrae un objeto JSON con dos claves: fields y lookupDictionary.",
|
||||
"fields: name, rfc, legalRepresentative, incorporationDate, deedNumber, notaryName, fiscalAddress, businessPurpose, stateOfIncorporation.",
|
||||
"lookupDictionary: estructura completa del diccionario de acta.",
|
||||
"Texto:",
|
||||
clampActaText(fullText),
|
||||
].join("\n\n");
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: DEFAULT_OPENAI_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(() => ({}));
|
||||
if (!response.ok) {
|
||||
const message = payload?.error?.message ? ` ${payload.error.message}` : "";
|
||||
throw new Error(`OpenAI request failed with ${response.status}.${message}`);
|
||||
}
|
||||
|
||||
const content = payload?.choices?.[0]?.message?.content;
|
||||
if (typeof content !== "string" || !content.trim()) {
|
||||
throw new Error("OpenAI did not return text content.");
|
||||
}
|
||||
|
||||
const parsed = extractJsonObject(content);
|
||||
if (!parsed || typeof parsed !== "object" || !parsed.fields || !parsed.lookupDictionary) {
|
||||
throw new Error("OpenAI JSON missing required keys: fields/lookupDictionary.");
|
||||
}
|
||||
|
||||
return {
|
||||
model: payload?.model ?? DEFAULT_OPENAI_MODEL,
|
||||
usage: {
|
||||
promptTokens: payload?.usage?.prompt_tokens ?? null,
|
||||
completionTokens: payload?.usage?.completion_tokens ?? null,
|
||||
totalTokens: payload?.usage?.total_tokens ?? null,
|
||||
},
|
||||
...parsed,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function analyzePdfWithOcrFallback(filePath) {
|
||||
const buffer = await readFile(filePath);
|
||||
const warnings = [];
|
||||
|
||||
let direct;
|
||||
try {
|
||||
direct = await extractTextFromPdfBuffer(buffer);
|
||||
} catch (error) {
|
||||
warnings.push(`Direct extraction failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
if (direct && !shouldApplyOcr(direct.text, direct.numPages)) {
|
||||
return {
|
||||
text: direct.text,
|
||||
methodUsed: "direct",
|
||||
numPages: direct.numPages,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
warnings.push("Direct extraction was short; OCR attempted.");
|
||||
|
||||
const ocr = await runOcrAndExtractText(buffer);
|
||||
if (shouldApplyOcr(ocr.text, ocr.numPages)) {
|
||||
throw new Error("OCR completed but usable text was still not detected.");
|
||||
}
|
||||
|
||||
return {
|
||||
text: ocr.text,
|
||||
methodUsed: "ocr",
|
||||
numPages: ocr.numPages,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { filePath, outPath } = parseArgs(process.argv.slice(2));
|
||||
if (!filePath) {
|
||||
usage();
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const absolute = path.resolve(filePath);
|
||||
const analysis = await analyzePdfWithOcrFallback(absolute);
|
||||
const ai = await extractWithAi(analysis.text);
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
file: absolute,
|
||||
methodUsed: analysis.methodUsed,
|
||||
numPages: analysis.numPages,
|
||||
warnings: analysis.warnings,
|
||||
extractionEngine: "ai",
|
||||
aiModel: ai.model,
|
||||
aiUsage: ai.usage,
|
||||
fields: ai.fields,
|
||||
lookupDictionary: ai.lookupDictionary,
|
||||
};
|
||||
|
||||
if (outPath) {
|
||||
await writeFile(path.resolve(outPath), `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(`Result written to ${path.resolve(outPath)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
173
scripts/extract-municipalities-from-pdf.mjs
Normal file
173
scripts/extract-municipalities-from-pdf.mjs
Normal file
@@ -0,0 +1,173 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
function normalizeSpaces(value) {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
const knownOpenPortalMappings = {
|
||||
"19-019": {
|
||||
openPortalUrl: "https://licitaciones.sanpedro.gob.mx/Default.aspx?Year=2026&T=1&Pro=1",
|
||||
openPortalType: "SAN_PEDRO_ASPX",
|
||||
openSyncIntervalDays: 7,
|
||||
},
|
||||
};
|
||||
|
||||
function parseMunicipalityRows(text) {
|
||||
const lines = text.split(/\r?\n/);
|
||||
const areaRegex = "(Resto del País|Zona Libre de la Frontera Norte)";
|
||||
const rowRegex = new RegExp(`^\\s*(\\d{2})\\s+(.+?)\\s+(\\d{3})\\*?\\s+(.+?)\\s+${areaRegex}\\s*$`);
|
||||
const pendingRegex = /^\s*(\d{2})\s+(.+?)\s+(\d{3})\*?\s+(.+?)\s*$/;
|
||||
const continuationRegex = new RegExp(`^\\s+(.+?)\\s+${areaRegex}\\s*$`);
|
||||
const rows = [];
|
||||
let pendingRow = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (pendingRow) {
|
||||
const continuation = line.match(continuationRegex);
|
||||
|
||||
if (continuation) {
|
||||
rows.push({
|
||||
stateCode: pendingRow.stateCode,
|
||||
stateName: pendingRow.stateName,
|
||||
municipalityCode: pendingRow.municipalityCode,
|
||||
municipalityName: normalizeSpaces(continuation[1] ?? ""),
|
||||
areaGeografica: normalizeSpaces(continuation[2] ?? ""),
|
||||
openPortalUrl: null,
|
||||
openPortalType: "GENERIC",
|
||||
openSyncIntervalDays: 7,
|
||||
pntSubjectId: null,
|
||||
pntEntityId: null,
|
||||
pntSectorId: null,
|
||||
pntEntryUrl: null,
|
||||
backupUrl: null,
|
||||
scrapingEnabled: true,
|
||||
isActive: true,
|
||||
});
|
||||
pendingRow = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
pendingRow = null;
|
||||
}
|
||||
|
||||
if (!/^\s*\d{2}\s+/.test(line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = line.match(rowRegex);
|
||||
|
||||
if (!match) {
|
||||
const pendingMatch = line.match(pendingRegex);
|
||||
|
||||
if (pendingMatch) {
|
||||
pendingRow = {
|
||||
stateCode: pendingMatch[1],
|
||||
stateName: normalizeSpaces(pendingMatch[2] ?? ""),
|
||||
municipalityCode: (pendingMatch[3] ?? "").padStart(3, "0"),
|
||||
};
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const stateCode = match[1];
|
||||
const stateName = normalizeSpaces(match[2] ?? "");
|
||||
const municipalityCode = (match[3] ?? "").padStart(3, "0");
|
||||
const municipalityName = normalizeSpaces(match[4] ?? "");
|
||||
const areaGeografica = normalizeSpaces(match[5] ?? "");
|
||||
|
||||
rows.push({
|
||||
stateCode,
|
||||
stateName,
|
||||
municipalityCode,
|
||||
municipalityName,
|
||||
areaGeografica,
|
||||
openPortalUrl: null,
|
||||
openPortalType: "GENERIC",
|
||||
openSyncIntervalDays: 7,
|
||||
pntSubjectId: null,
|
||||
pntEntityId: null,
|
||||
pntSectorId: null,
|
||||
pntEntryUrl: null,
|
||||
backupUrl: null,
|
||||
scrapingEnabled: true,
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
|
||||
const deduped = new Map();
|
||||
|
||||
for (const row of rows) {
|
||||
deduped.set(`${row.stateCode}-${row.municipalityCode}`, row);
|
||||
}
|
||||
|
||||
const merged = [...deduped.values()].map((row) => {
|
||||
const key = `${row.stateCode}-${row.municipalityCode}`;
|
||||
const known = knownOpenPortalMappings[key];
|
||||
|
||||
if (!known) {
|
||||
return row;
|
||||
}
|
||||
|
||||
return {
|
||||
...row,
|
||||
...known,
|
||||
};
|
||||
});
|
||||
|
||||
return merged.sort((a, b) => {
|
||||
if (a.stateCode !== b.stateCode) {
|
||||
return a.stateCode.localeCompare(b.stateCode, "es");
|
||||
}
|
||||
|
||||
return a.municipalityCode.localeCompare(b.municipalityCode, "es");
|
||||
});
|
||||
}
|
||||
|
||||
async function readSourceText(inputPath) {
|
||||
if (inputPath.toLowerCase().endsWith(".txt")) {
|
||||
return readFile(inputPath, "utf8");
|
||||
}
|
||||
|
||||
const result = spawnSync("pdftotext", ["-layout", inputPath, "-"], {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 1024 * 1024 * 20,
|
||||
});
|
||||
|
||||
if (result.status === 0 && typeof result.stdout === "string" && result.stdout.trim()) {
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
throw new Error(result.stderr || "Failed to extract text from PDF.");
|
||||
}
|
||||
|
||||
function run() {
|
||||
const inputPdf = process.argv[2] || path.join(process.cwd(), "Estructura_municipal_dic22.pdf");
|
||||
const outputJson = process.argv[3] || path.join(process.cwd(), "prisma", "data", "municipalities.json");
|
||||
|
||||
return readSourceText(inputPdf).then((rawText) => {
|
||||
const rows = parseMunicipalityRows(rawText);
|
||||
|
||||
if (!rows.length) {
|
||||
throw new Error("No municipality rows parsed from source text.");
|
||||
}
|
||||
|
||||
return mkdir(path.dirname(outputJson), { recursive: true })
|
||||
.then(() => writeFile(outputJson, `${JSON.stringify(rows, null, 2)}\n`, "utf8"))
|
||||
.then(() => {
|
||||
console.log(`Parsed municipalities: ${rows.length}`);
|
||||
console.log(`Output: ${outputJson}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
195
scripts/test-licitaya-api.mjs
Normal file
195
scripts/test-licitaya-api.mjs
Normal file
@@ -0,0 +1,195 @@
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 20000;
|
||||
|
||||
function loadDotenv(filePath) {
|
||||
if (!existsSync(filePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = readFileSync(filePath, "utf8");
|
||||
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (!trimmed || trimmed.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const eqIndex = trimmed.indexOf("=");
|
||||
if (eqIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = trimmed.slice(0, eqIndex).trim();
|
||||
if (!key || process.env[key] !== undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let value = trimmed.slice(eqIndex + 1).trim();
|
||||
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
baseUrl: process.env.LICITAYA_BASE_URL,
|
||||
endpoint: process.env.LICITAYA_TEST_ENDPOINT,
|
||||
accept: process.env.LICITAYA_ACCEPT || "application/json",
|
||||
method: "GET",
|
||||
timeoutMs: Number.parseInt(process.env.LICITAYA_TIMEOUT_MS || "", 10) || DEFAULT_TIMEOUT_MS,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const current = argv[i];
|
||||
const next = argv[i + 1];
|
||||
|
||||
if (current === "--base-url" && next) {
|
||||
args.baseUrl = next;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current === "--endpoint" && next) {
|
||||
args.endpoint = next;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current === "--accept" && next) {
|
||||
args.accept = next;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current === "--method" && next) {
|
||||
args.method = next.toUpperCase();
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current === "--timeout" && next) {
|
||||
const parsed = Number.parseInt(next, 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
args.timeoutMs = parsed;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
function buildUrl(baseUrl, endpoint) {
|
||||
if (!endpoint) {
|
||||
throw new Error("Missing LICITAYA_TEST_ENDPOINT (or --endpoint).");
|
||||
}
|
||||
|
||||
if (endpoint.includes("<") || endpoint.includes(">")) {
|
||||
throw new Error(
|
||||
"LICITAYA_TEST_ENDPOINT still contains placeholders. Use a real path such as /tender/search or /tender/<tenderId>.",
|
||||
);
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(endpoint)) {
|
||||
return new URL(endpoint);
|
||||
}
|
||||
|
||||
if (!baseUrl) {
|
||||
throw new Error("Missing LICITAYA_BASE_URL (or --base-url).");
|
||||
}
|
||||
|
||||
const normalizedBase = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
||||
const cleanEndpoint = endpoint.startsWith("/") ? endpoint.slice(1) : endpoint;
|
||||
|
||||
return new URL(cleanEndpoint, normalizedBase);
|
||||
}
|
||||
|
||||
function previewBody(rawBody, contentType) {
|
||||
const trimmed = rawBody.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return "(empty body)";
|
||||
}
|
||||
|
||||
const isJson = contentType.includes("application/json") || trimmed.startsWith("{") || trimmed.startsWith("[");
|
||||
|
||||
if (isJson) {
|
||||
try {
|
||||
const json = JSON.parse(trimmed);
|
||||
return JSON.stringify(json, null, 2);
|
||||
} catch {
|
||||
return trimmed.slice(0, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed.slice(0, 3000);
|
||||
}
|
||||
|
||||
loadDotenv(resolve(process.cwd(), ".env"));
|
||||
|
||||
const apiKey = process.env.LICITAYA_API_KEY || process.env.X_API_KEY || process.env.X_API_KEY_LICITAYA;
|
||||
if (!apiKey) {
|
||||
console.error("Missing API key. Set LICITAYA_API_KEY in .env or shell env.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
let url;
|
||||
|
||||
try {
|
||||
url = buildUrl(args.baseUrl, args.endpoint);
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), args.timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: args.method,
|
||||
headers: {
|
||||
"X-API-KEY": apiKey,
|
||||
Accept: args.accept,
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
const rawBody = await response.text();
|
||||
const bodyPreview = previewBody(rawBody, contentType);
|
||||
|
||||
console.log(`URL: ${url.toString()}`);
|
||||
console.log(`Method: ${args.method}`);
|
||||
console.log(`Status: ${response.status} ${response.statusText}`);
|
||||
console.log(`Content-Type: ${contentType || "(none)"}`);
|
||||
console.log("--- Response Preview ---");
|
||||
console.log(bodyPreview);
|
||||
|
||||
if (response.status === 404 && url.pathname.endsWith("/tender/search")) {
|
||||
console.error("No tenders matched the current filters. Try a broader keyword or fewer filters.");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
console.error(`Request timed out after ${args.timeoutMs} ms.`);
|
||||
} else {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
process.exit(1);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user