116 Commits

Author SHA1 Message Date
jalvarado 2b16d9b2f7 Merge pull request 'Develop' (#37) from develop into master
Despliegue Automatizado Universal / get-context (push) Successful in 1s
Despliegue Automatizado Universal / build (push) Failing after 32s
Despliegue Automatizado Universal / deploy (push) Has been skipped
Reviewed-on: #37
2026-07-28 21:17:31 +00:00
jalvarado-contentoh 9c86f5d223 refactor: remove unnecessary reverse on cuestionarios display
Despliegue Automatizado Universal / get-context (push) Successful in 0s
Despliegue Automatizado Universal / build (push) Failing after 36s
Despliegue Automatizado Universal / deploy (push) Has been skipped
2026-07-28 15:15:58 -06:00
stern eaa955a995 Add deploy.yaml
Despliegue Automatizado Universal / get-context (push) Successful in 1s
Despliegue Automatizado Universal / build (push) Failing after 38s
Despliegue Automatizado Universal / deploy (push) Has been skipped
2026-07-02 03:38:28 -06:00
jalvarado f0be2bd7fe Merge pull request 'develop' (#36) from develop into master
Reviewed-on: #36
2026-06-02 17:49:03 -06:00
miguel ad983c6ae8 eliminate log 2026-06-02 17:48:17 -06:00
miguel ac0671bb1a feat: reverse order of cuestionarios display and add console log for data 2026-06-02 17:47:25 -06:00
jalvarado 066b150df3 Merge pull request 'feat: add modal to display user contador and update attendance confirmation flow' (#35) from develop into master
Reviewed-on: #35
2026-05-14 19:07:27 -06:00
miguel 8c2a6ecb6f feat: add modal to display user contador and update attendance confirmation flow
Co-authored-by: Copilot <copilot@github.com>
2026-05-13 20:52:05 -06:00
jalvarado b31a2f36c9 Merge pull request 'feat: tecto horas agregado a carta' (#34) from develop into master
Reviewed-on: #34
2026-03-26 10:20:23 -06:00
miguel 2f50d4f2fb feat: tecto horas agregado a carta 2026-03-26 10:19:22 -06:00
jalvarado 56ce4c3942 Merge pull request 'feature: horas, added at time and acento in estaras' (#33) from develop into master
Reviewed-on: #33
2026-03-24 20:19:12 -06:00
miguel dcc474a065 feature: horas, added at time and acento in estaras 2026-03-24 20:18:18 -06:00
jalvarado 36b52ca898 Merge pull request 'develop' (#32) from develop into master
Reviewed-on: #32
2026-03-10 15:42:15 -06:00
miguel fe8ec5f591 feat: add email message field to cuestionario and update edit form 2026-03-10 12:59:01 -06:00
miguel d3e1441f87 feat: add bulk user upload functionality and form success page
- Implemented bulk upload for students and workers with file validation and error handling.
- Added Excel template download feature for user uploads.
- Created success page for form submissions with QR code display.
- Established context and reducer for form state management, including user search and response handling.
- Added mermaid diagrams for event registration documentation.
2026-03-08 19:18:23 -06:00
jalvarado d846ea6c01 Merge pull request 'develop' (#31) from develop into master
Reviewed-on: #31
2026-02-11 15:22:57 -06:00
miguel f7d7bcfae6 fix: version nextjs 2026-02-11 15:22:08 -06:00
santiago c0f4f31e18 Se quitaron algunas vulnerabilidades 2026-01-08 18:24:00 -06:00
jalvarado a4b8746cd8 Merge pull request 'develop' (#30) from develop into master
Reviewed-on: #30
2025-11-10 17:35:56 -06:00
miguel 3eba0c1348 Refactor navbar component for improved readability and consistency 2025-11-10 17:33:31 -06:00
santiago 5a88e93e04 Se agrego la validacion para que el staff no pueda crear formularios 2025-10-30 20:04:04 +01:00
santiago 479f9fae46 Merge branch 'develop' 2025-10-28 22:25:42 +01:00
santiago 6f89b993e1 change cupos to registro 2025-10-28 22:20:22 +01:00
santiago cac17284d8 Cambios sin modificar 2025-10-07 18:48:06 +02:00
miguel 9068cef62d Implementar gestión de usuarios con modales para agregar, editar y eliminar administradores 2025-10-05 13:15:55 -06:00
santiago b0f744ef14 Modificaciones 2025-10-03 14:45:35 -06:00
santiago 4ff66e81c7 Modificaciones 2025-10-02 16:11:31 -06:00
santiago 3123c9911a Modificaciones y correcion de errores 2025-10-02 16:08:57 -06:00
santiago fee9d92ed7 Modificaciones en las vistas de las cargas masivas 2025-10-02 16:03:28 -06:00
santiago 8f2ee27711 Se agregaron las funcionalidades de subir eventos e imagenes en carga masiva 2025-10-01 15:21:02 -06:00
santiago 509ff4255a Modificaciones en error al escanear codigo qr 2025-10-01 14:14:36 -06:00
santiago 4c300a8e20 Validacion en la asistencia del usuario 2025-09-29 12:13:38 -06:00
jalvarado be30c5a438 Merge pull request 'feat: update plantilla titles and options for improved clarity and inclusivity' (#29) from develop into master
Reviewed-on: #29
2025-09-10 10:51:12 -06:00
miguel 534ba64e0c feat: update plantilla titles and options for improved clarity and inclusivity 2025-09-10 10:34:11 -06:00
jalvarado c2b435d033 Merge pull request 'develop' (#28) from develop into master
Reviewed-on: #28
2025-09-10 10:18:56 -06:00
jorge miguel dcb66ce2c1 feat: implement formulario de registro with community and account validation, including dynamic response handling and submission logic 2025-09-09 19:24:35 -06:00
jalvarado dc161cf269 Merge pull request 'Refactor code structure for improved readability and maintainability' (#26) from events-division into develop
Reviewed-on: #26
2025-09-09 15:17:44 -06:00
miguel b399821439 Refactor code structure for improved readability and maintainability 2025-09-09 15:16:40 -06:00
jalvarado 978f6c8a69 Merge pull request 'events-division' (#25) from events-division into develop
Reviewed-on: #25
2025-08-19 10:29:17 -06:00
miguel 99bfb46b8b feat: add admin management page and types
- Implemented the admin management page with a table displaying admin details.
- Created the Administrador type definition for better type safety.
- Added SubmitResponse type for handling submission responses.
- Included placeholder components for form card previews.
2025-08-19 10:26:02 -06:00
miguel 24642d1c3b feat: Enhance event and form management with previews and improved UI
- Added EventoCardPreview component to display event details with existing and new banners.
- Updated FormularioCardAdmin and FormularioCardUser components to handle event images and display questionnaire counts.
- Improved FormularioEditor with better layout and added preview functionality for forms.
- Introduced EmptyEventsState component for better user experience when no events are available.
- Enhanced FormularioCardPreview to support both existing and new forms with dynamic banner handling.
- Refactored code for better readability and maintainability across components.
2025-08-14 21:59:38 -06:00
miguel 7f2ab5b359 feat: Implement event creation and management features for administrators
- Added page for creating forms associated with events, including template selection and validation of form dates against event dates.
- Created layout component to provide context for event-related pages.
- Developed event detail page with editing capabilities and form management.
- Introduced event creation page with banner upload functionality and preview.
- Implemented event listing page displaying active and recent events with associated forms.
- Added layout for user section including header, navbar, and footer.
- Created page for uploading teacher data from Excel files.
- Developed QR code scanning page for attendance registration with modal confirmation.
- Implemented login functionality with fake user data for testing purposes.
- Added event card preview component to display event details and banner.
2025-08-14 20:27:25 -06:00
miguel be8f207ecd feat: Implement event and questionnaire management features
- Added event creation page with form handling and validation.
- Introduced layout for event management with context provider.
- Created event listing page with active and recent events display.
- Developed questionnaire creation page linked to events.
- Implemented breadcrumb navigation for better user experience.
- Added reusable components for event and questionnaire cards.
- Integrated API calls for fetching and managing event data.
- Enhanced slugify utility for better URL handling.
- Established context for managing event state across components.
2025-08-12 20:00:21 -06:00
miguel 5b0f0ad6bf refactor: update event handling to use GetEventoWithCuestionariosWithCupos type and replace EventoCard with CuestionarioCard 2025-08-11 12:55:14 -06:00
jalvarado e0a9a001fa Merge pull request 'fix: add recent events section to the administrator page' (#24) from develop into master
Reviewed-on: #24
2025-08-11 10:24:33 -06:00
miguel 99b444ccf4 fix: add recent events section to the administrator page 2025-08-11 10:22:25 -06:00
jalvarado 904da12911 Merge pull request 'fix: update confirmarAsistencia function to use cuestionario ID instead of evento ID' (#23) from develop into master
Reviewed-on: #23
2025-08-08 09:30:53 -06:00
miguel 3493d99038 fix: update confirmarAsistencia function to use cuestionario ID instead of evento ID 2025-08-08 09:29:07 -06:00
jalvarado 4ff69369b5 Merge pull request 'fix: update cuestionario types to include evento details' (#22) from develop into master
Reviewed-on: #22
2025-08-07 09:49:02 -06:00
miguel f624724521 fix: update cuestionario types to include evento details 2025-08-07 08:55:44 -06:00
jalvarado bd0703d07e Merge pull request 'send' (#21) from develop into master
Reviewed-on: #21
2025-06-30 14:22:22 -06:00
miguel b2205b3f98 send 2025-06-30 14:20:45 -06:00
jalvarado 4d4cd7ec67 Merge pull request 'fix: simplify toast notification rendering function' (#20) from develop into master
Reviewed-on: #20
2025-06-30 13:25:53 -06:00
miguel 4d124a7857 fix: simplify toast notification rendering function 2025-06-30 13:25:25 -06:00
jalvarado 16dc610ba3 Merge pull request 'fix: enhance toast notifications for form submission feedback' (#19) from develop into master
Reviewed-on: #19
2025-06-30 13:23:57 -06:00
miguel 3fc7dfcfda fix: enhance toast notifications for form submission feedback 2025-06-30 13:21:32 -06:00
jalvarado eaea5cd86b Merge pull request 'develop' (#18) from develop into master
Reviewed-on: #18
2025-06-30 11:24:16 -06:00
miguel 60de9fb324 fix: update registration button text from 'Regístrate' to 'Registro' 2025-06-30 11:23:40 -06:00
miguel 68467c2570 fix: correct placeholder logic for cuenta input based on validation type 2025-06-30 11:21:43 -06:00
jalvarado 480b1bc86f Merge pull request 'feat: update placeholder text for cuenta input based on validation type' (#17) from develop into master
Reviewed-on: #17
2025-06-30 11:15:25 -06:00
miguel 670d8558f4 feat: update placeholder text for cuenta input based on validation type 2025-06-30 11:13:56 -06:00
jalvarado 68a041682e Merge pull request 'Implement code changes to enhance functionality and improve performance' (#16) from develop into master
Reviewed-on: #16
2025-06-27 18:18:20 -06:00
jorgemike 2cb42cd4fc Implement code changes to enhance functionality and improve performance 2025-06-27 18:17:49 -06:00
jalvarado 0fb3a141ce Merge pull request 'refactor: improve email validation logic and enhance community response checks in FormularioRegistro' (#15) from develop into master
Reviewed-on: #15
2025-06-27 17:20:04 -06:00
jorgemike a107233007 refactor: improve email validation logic and enhance community response checks in FormularioRegistro 2025-06-27 16:38:02 -06:00
jalvarado 2ac53a2a24 Merge pull request 'fix: correct URL structure in EventoCard component for cuestionario navigation' (#14) from develop into master
Reviewed-on: #14
2025-06-27 15:58:22 -06:00
jorgemike 83c50f088e fix: correct URL structure in EventoCard component for cuestionario navigation 2025-06-27 15:57:23 -06:00
jalvarado 9a49392f8a Merge pull request 'feat: include cupo_maximo in event update payload for edit formulario' (#13) from develop into master
Reviewed-on: #13
2025-06-25 11:38:11 -06:00
miguel 3723f5771d feat: include cupo_maximo in event update payload for edit formulario 2025-06-24 20:34:04 -06:00
jalvarado 4425c2b3f7 Merge pull request 'develop' (#12) from develop into master
Reviewed-on: #12
2025-06-24 10:32:28 -06:00
miguel 544d089824 feat: implement event and questionnaire creation with updated forms; add editing capabilities for questionnaires and events 2025-06-23 18:59:59 -06:00
miguel 613cc0b711 feat: add cupo_maximo field to event creation and editing forms; update related components and types 2025-06-23 12:40:40 -06:00
miguel 50e37aa8fa build 2025-06-20 11:07:05 -06:00
miguel c0cc7da989 subida de profesoresw 2025-06-20 10:35:58 -06:00
miguel f85c3a35df feat: enhance event forms with date formatting and registration link updates; add utility for local date formatting 2025-06-19 18:35:15 -06:00
miguel e092eeec4d feat: add markdown editor for event description and update event forms
- Added @uiw/react-md-editor dependency for rich text editing.
- Implemented dynamic loading of the markdown editor in create and edit event forms.
- Updated event forms to include new fields for 'apellidos', 'genero', and 'rfc'.
- Refactored event and questionnaire pages to improve data fetching and rendering.
- Removed unnecessary Excel files from the public directory.
- Enhanced error handling and logging in event creation and form submission processes.
- Updated carousel component to handle empty image arrays gracefully.
- Created a new FormularioPage component to streamline questionnaire rendering.
2025-06-18 15:51:17 -06:00
miguel ea9f3b72f7 fix: Update variable names from id_evento to id_cuestionario for consistency in QR scanning and attendance registration 2025-06-18 12:41:06 -06:00
miguel b110109cb3 feat: Add event management features, including event editing and participant tracking; refactor layout and API integration 2025-06-18 12:37:47 -06:00
miguel f00bd8ac5e feat: Refactor carousel component and add new event card
- Updated ClientCarousel to accept props for dynamic image rendering.
- Changed CarouselProps interface to export for better type usage.
- Enhanced Carousel component to handle default images and improved styling.
- Introduced EventoCard component for displaying event details with dynamic data.
- Added MarkdownRenderer for rendering markdown content in EventoCard.
- Removed old Formulario component and replaced it with FormularioRegistro for better structure.
- Implemented prefetching logic in FormularioRegistro to auto-fill user data based on account info.
- Created PrefetchAbiertaCorta component for handling short answer questions with pre-filled data.
- Added useGetApi hook for simplified API data fetching.
- Updated plantillas data to include validation types for new form fields.
- Introduced new types for event and questionnaire handling in TypeScript.
- Enhanced styles for better UI/UX, including carousel controls and required field indicators.
2025-06-16 18:25:12 -06:00
jorgemike e275b33131 feat: Refactor layout and page components for admin section
- Simplified layout component by removing unnecessary navigation links.
- Enhanced the main page with a button to create events and adjusted card layout.
- Introduced a new banner uploader component for image uploads.
- Created a password input component with visibility toggle.
- Developed a create event container to manage event creation.
- Implemented a comprehensive form editor for creating and editing forms.
- Updated plantillas data structure for better event registration forms.
- Added global styles and integrated them into Bootstrap SCSS.
- Removed deprecated create-formulario type definitions and replaced them with updated types.
2025-06-16 11:40:00 -06:00
jalvarado 8007d2f409 Refactor AdminFormPage and Formulario components to remove unused state and improve form handling; update plantilla data structure to include new fields. 2025-05-23 09:57:19 -06:00
jalvarado 3e5be626f7 Merge pull request 'Reporte respuestas' (#11) from develop into master
Reviewed-on: #11
2025-04-25 11:42:32 -06:00
jalvarado 20cc8bc4e7 Reporte respuestas 2025-04-25 11:41:19 -06:00
jalvarado 12dcd999c7 Merge pull request 'develop' (#10) from develop into master
Reviewed-on: #10
2025-04-21 12:35:12 -06:00
jalvarado fe1e13c51c imagen de plantilla 2025-04-21 12:33:58 -06:00
jalvarado b298937ffb Merge branch 'develop' of https://repositorio.acatlan.unam.mx/CIDWA/formularios_front into develop 2025-04-21 12:11:58 -06:00
jalvarado c04a7613cd download and plantima image 2025-04-21 12:11:03 -06:00
jalvarado 9142c4c951 Merge pull request 'banner aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' (#9) from develop into master
Reviewed-on: #9
2025-04-09 18:40:21 -06:00
jorgemike b04dc94eec banner aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 2025-04-09 18:33:53 -06:00
jalvarado ec23cef4a0 Merge pull request 'banner' (#8) from develop into master
Reviewed-on: #8
2025-04-09 17:59:06 -06:00
jorgemike 9d10c18a52 banner 2025-04-09 17:58:29 -06:00
jalvarado d69a90a458 banner y plantilla 2025-04-09 16:37:11 -06:00
jalvarado 4d04a5383d Merge branch 'master' of https://repositorio.acatlan.unam.mx/CIDWA/formularios_front 2025-04-08 18:19:56 -06:00
jalvarado 8cbb968676 Merge branch 'develop' 2025-04-08 18:18:39 -06:00
jalvarado 43a9bcfb58 Merge branch 'develop' of https://repositorio.acatlan.unam.mx/CIDWA/formularios_front into develop 2025-04-08 18:17:24 -06:00
jalvarado 404dd3a627 CAmbio de boton 2025-04-08 18:16:04 -06:00
stern b992e3386a Establecer scripts dev y prod con puertos correspondientes 2025-04-08 11:02:45 -06:00
stern 243bd1afc1 Establecer scripts dev y prod con puertos correspondientes 2025-04-08 11:02:05 -06:00
jalvarado 9f4abe1db2 Merge pull request 'styles' (#6) from develop into master
Reviewed-on: #6
2025-04-08 10:46:31 -06:00
jalvarado 587bbc4b7c styles 2025-04-08 10:45:31 -06:00
jalvarado 126377a2b8 Resuelvo conflictos entre develop y master 2025-04-08 10:32:31 -06:00
jalvarado 8d8299b71a fix, and animations 2025-04-08 10:28:56 -06:00
stern e0ed64548a Establecer uso de Node 22 en script start 2025-04-08 08:08:50 -06:00
jalvarado 0d610c470b Merge pull request 'fake login' (#4) from develop into master
Reviewed-on: #4
2025-04-08 08:05:00 -06:00
jorgemike f308cf30c0 fake login 2025-04-07 19:55:37 -06:00
jalvarado 1b817788bb Merge pull request 'Cambio banner' (#3) from develop into master
Reviewed-on: #3
2025-04-07 15:54:53 -06:00
jalvarado 8506d8c018 Cambio banner 2025-04-07 15:54:26 -06:00
jalvarado 8945c1e339 Merge pull request 'button' (#2) from develop into master
Reviewed-on: #2
2025-04-07 15:19:19 -06:00
jalvarado 4ef87790e2 button 2025-04-07 15:18:37 -06:00
stern 842972963a Establecer puerto script start 2025-04-07 15:02:25 -06:00
jalvarado 6eebaa0a94 Merge pull request 'develop' (#1) from develop into master
Reviewed-on: #1
2025-04-07 14:38:22 -06:00
jorgemike 9b0021a166 qr 2025-04-07 11:31:01 -06:00
jalvarado b2e639efcb cambios 2025-04-07 11:17:30 -06:00
jorgemike a0e86b3873 auto complete alumnos 2025-04-06 16:04:56 -06:00
jorgemike 6c51e326dd coneccion muchos endpoint y validacions 2025-04-04 16:43:18 -06:00
jorgemike f522db5211 Solucion build 2025-04-04 10:24:58 -06:00
113 changed files with 12700 additions and 2510 deletions
+85
View File
@@ -0,0 +1,85 @@
name: Despliegue Automatizado Universal
on:
push:
branches:
- develop
- master
jobs:
get-context:
runs-on: host
outputs:
node_version: ${{ steps.setup.outputs.version }}
target_app: ${{ steps.setup.outputs.app }}
steps:
- name: Resolver Aplicación desde apps.conf
id: setup
run: |
# 1. Obtener el nombre del repositorio actual en Gitea (ej: front-Censo)
REPO_NAME="${{ github.event.repository.name }}"
# 2. Determinar si buscamos un entorno -dev (develop) o producción (master)
if [ "${{ github.ref_name }}" = "develop" ]; then
SUFFIX="-dev"
else
SUFFIX=""
fi
echo "Buscando qué sección de apps.conf tiene REPO=$REPO_NAME y termina en '$SUFFIX'..."
# 3. Buscar en /etc/apps.conf el bloque que contenga REPO=nombre_del_repo
TARGET_APP=$(awk -v repo="REPO=$REPO_NAME" -v suf="$SUFFIX" '
/^\[/ { gsub(/[\[\]]/, "", $0); current_box=$0; next }
$0 == repo {
if ((suf == "-dev" && current_box ~ /-dev$/) || (suf == "" && current_box !~ /-dev$/)) {
print current_box;
exit
}
}
' /etc/apps.conf)
if [ -z "$TARGET_APP" ]; then
echo "ERROR: No se encontró ninguna sección en /etc/apps.conf para el repositorio $REPO_NAME con el sufijo correcto."
exit 1
fi
# 4. Leer la versión de Node del bloque encontrado
VERSION=$(awk -v app="$TARGET_APP" '
$0=="["app"]" { found=1; next }
/^\[/ && found { exit }
found && /^NODE=/ { split($0, a, "="); print a[2]; exit }
' /etc/apps.conf)
MAJOR_VERSION=$(echo "$VERSION" | tr -d '[:space:]' | grep -oE '[0-9]+' | head -n1)
echo "-> ID de Aplicación encontrado: $TARGET_APP"
echo "-> Versión Mayor de Node resuelta: $MAJOR_VERSION"
echo "app=${TARGET_APP}" >> $GITHUB_OUTPUT
echo "version=${MAJOR_VERSION}" >> $GITHUB_OUTPUT
build:
needs: get-context
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configurar Node.js dinámicamente
uses: actions/setup-node@v4
with:
node-version: ${{ needs.get-context.outputs.node_version }}
- run: npm ci
- run: npm run build
deploy:
needs: [get-context, build]
runs-on: host
steps:
- name: Ejecutar Despliegue en Servidor Físico
run: |
TARGET_APP="${{ needs.get-context.outputs.target_app }}"
BRANCH_NAME="${{ github.ref_name }}"
sudo -u deploy /home/deploy/scripts/deploy-front.sh "$TARGET_APP" "$BRANCH_NAME"
+249
View File
@@ -0,0 +1,249 @@
{
"pollutionMeasurements": {
"city": "Ciudad de M\\u00e9xico",
"cityCode": "MEX",
"country": "M\\u00e9xico",
"mesurementAgency": "SIMAT",
"unit": { "RH": "%", "TMP": "C", "WSP": "m/s", "WDR": "o" },
"date": {
"2023-01-01 01:00": {
"RH": {
"ACO": "81",
"AJU": "100",
"MON": "56",
"CHO": "",
"CUA": "61",
"CUT": "73",
"FAC": "73",
"HGM": "58",
"MER": "66",
"NEZ": "57",
"PED": "63",
"SAG": "74",
"SFE": "",
"TAH": "65",
"TLA": "60",
"UAX": "71",
"UIZ": "74",
"VIF": "69",
"XAL": "",
"MGH": "",
"AJM": "64",
"MPA": "71",
"BJU": "66",
"INN": "87",
"GAM": "68",
"LAA": "70"
},
"TMP": {
"ACO": "9.3",
"AJU": "2.5",
"MON": "13.9",
"CHO": "",
"CUA": "10.9",
"CUT": "8.4",
"FAC": "8.5",
"HGM": "13.2",
"MER": "13.1",
"NEZ": "13.8",
"PED": "10.9",
"SAG": "13.7",
"SFE": "",
"TAH": "9.3",
"TLA": "12.2",
"UAX": "10",
"UIZ": "12.7",
"VIF": "9.4",
"XAL": "",
"MGH": "",
"AJM": "7.2",
"MPA": "",
"BJU": "12.2",
"INN": "3.2",
"GAM": "13.3",
"LAA": "12.5"
},
"WDR": {
"ACO": "319",
"AJU": "36",
"MON": "177",
"CHO": "",
"CUA": "258",
"CUT": "246",
"FAC": "245",
"HGM": "43",
"MER": "178",
"NEZ": "156",
"PED": "227",
"SAG": "247",
"SFE": "",
"TAH": "9",
"TLA": "347",
"UAX": "139",
"UIZ": "168",
"VIF": "269",
"XAL": "",
"MGH": "",
"AJM": "",
"MPA": "155",
"BJU": "170",
"INN": "105",
"GAM": "66",
"LAA": "58",
"FAR": "159",
"SAC": "141"
},
"WSP": {
"ACO": "1.3",
"AJU": "2.4",
"MON": "1.8",
"CHO": "",
"CUA": "1.7",
"CUT": "1.4",
"FAC": "0.8",
"HGM": "1.4",
"MER": "0.9",
"NEZ": "2.2",
"PED": "1.9",
"SAG": "0",
"SFE": "",
"TAH": "0.3",
"TLA": "0.9",
"UAX": "1.3",
"UIZ": "0.6",
"VIF": "0",
"XAL": "",
"MGH": "",
"AJM": "",
"MPA": "3.1",
"BJU": "0.8",
"INN": "0.5",
"GAM": "0.3",
"LAA": "0.6",
"FAR": "1.4",
"SAC": "2.4"
},
"PBa": { "MER": "" },
"2023-01-01 02:00": {
"RH": {
"ACO": "85",
"AJU": "100",
"MON": "63",
"CHO": "",
"CUA": "67",
"CUT": "73",
"FAC": "78",
"HGM": "61",
"MER": "67",
"NEZ": "60",
"PED": "58",
"SAG": "79",
"SFE": "",
"TAH": "67",
"TLA": "62",
"UAX": "73",
"UIZ": "75",
"VIF": "72",
"XAL": "",
"MGH": "",
"AJM": "61",
"MPA": "73",
"BJU": "67",
"INN": "87",
"GAM": "69",
"LAA": "74"
},
"TMP": {
"ACO": "8.2",
"AJU": "3.1",
"MON": "11.7",
"CHO": "",
"CUA": "10.6",
"CUT": "7.9",
"FAC": "7.2",
"HGM": "12.4",
"MER": "12.8",
"NEZ": "13.1",
"PED": "10.8",
"SAG": "12.7",
"SFE": "",
"TAH": "8.9",
"TLA": "11.1",
"UAX": "9.6",
"UIZ": "12.1",
"VIF": "8.5",
"XAL": "",
"MGH": "",
"AJM": "6.7",
"MPA": "",
"BJU": "11.7",
"INN": "3.2",
"GAM": "13",
"LAA": "11.6"
},
"WDR": {
"ACO": "355",
"AJU": "6",
"MON": "49",
"CHO": "",
"CUA": "265",
"CUT": "223",
"FAC": "235",
"HGM": "39",
"MER": "208",
"NEZ": "205",
"PED": "212",
"SAG": "92",
"SFE": "",
"TAH": "288",
"TLA": "318",
"UAX": "184",
"UIZ": "196",
"VIF": "312",
"XAL": "",
"MGH": "",
"AJM": "",
"MPA": "165",
"BJU": "169",
"INN": "66",
"GAM": "37",
"LAA": "277",
"FAR": "59",
"SAC": "153"
},
"WSP": {
"ACO": "1.3",
"AJU": "1.7",
"MON": "1.1",
"CHO": "",
"CUA": "2.4",
"CUT": "1.4",
"FAC": "1",
"HGM": "0.7",
"MER": "1.1",
"NEZ": "0.9",
"PED": "2.1",
"SAG": "0.1",
"SFE": "",
"TAH": "1.3",
"TLA": "1.3",
"UAX": "0.8",
"UIZ": "0.8",
"VIF": "0",
"XAL": "",
"MGH": "",
"AJM": "",
"MPA": "2",
"BJU": "0.9",
"INN": "0.2",
"GAM": "0",
"LAA": "0.2",
"FAR": "1.1",
"SAC": "1.7"
},
"PBa": { "MER": "" }
}
}
}
}
}
+14 -2
View File
@@ -1,7 +1,19 @@
import type { NextConfig } from "next"; import type { NextConfig } from 'next';
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ images: {
remotePatterns: [
{
protocol: 'http',
hostname: 'localhost',
port: '4200', //4200
},
{
protocol: 'https',
hostname: 'venus.acatlan.unam.mx',
},
],
},
}; };
export default nextConfig; export default nextConfig;
+2785 -1122
View File
File diff suppressed because it is too large Load Diff
+14 -5
View File
@@ -5,28 +5,37 @@
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
"build": "next build", "build": "next build",
"start": "next start", "start:local": "next build && next start -p 3246",
"start:dev": "source ~/.nvm/nvm.sh && nvm use 22.10.0 && npm install && next build && next start -p 3246",
"start:prod": "source ~/.nvm/nvm.sh && nvm use 22.10.0 && npm install && next build && next start -p 3240",
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@uiw/react-md-editor": "^4.0.7",
"@yudiel/react-qr-scanner": "^2.2.1", "@yudiel/react-qr-scanner": "^2.2.1",
"axios": "^1.8.4", "axios": "^1.8.4",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.8",
"bootstrap-icons": "^1.11.3", "bootstrap-icons": "^1.11.3",
"next": "15.2.4", "js-cookie": "^3.0.5",
"next": "^15.5.12",
"react": "^19.0.0", "react": "^19.0.0",
"react-bootstrap": "^2.10.10",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hot-toast": "^2.5.2" "react-hot-toast": "^2.5.2",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"@types/bootstrap": "^5.2.10", "@types/bootstrap": "^5.2.10",
"@types/js-cookie": "^3.0.6",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.2.4", "eslint-config-next": "15.2.4",
"sass": "^1.86.1", "sass": "^1.32.13",
"typescript": "^5" "typescript": "^5"
} }
} }
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

+153
View File
@@ -0,0 +1,153 @@
%PDF-1.3
%ÿÿÿÿ
8 0 obj
<<
/Type /ExtGState
/ca 1
>>
endobj
9 0 obj
<<
/Type /ExtGState
/ca 1
/CA 1
>>
endobj
12 0 obj
<<
/Type /ExtGState
/CA 1
>>
endobj
7 0 obj
<<
/Type /Page
/Parent 1 0 R
/MediaBox [0 0 595.28 841.89]
/Contents 5 0 R
/Resources 6 0 R
>>
endobj
6 0 obj
<<
/ProcSet [/PDF /Text /ImageB /ImageC /ImageI]
/ExtGState <<
/Gs1 8 0 R
/Gs2 9 0 R
/Gs3 12 0 R
>>
/Font <<
/F1 10 0 R
/F2 11 0 R
/F3 13 0 R
>>
/ColorSpace <<
>>
>>
endobj
5 0 obj
<<
/Length 1506
/Filter /FlateDecode
>>
stream
xœÍZ]k\7}¿¿B cÍ·f’¶<Úø-äÁìî--u¡5´¿è~î:«Ôñ]¥Á{Œsæh4sf5bx…!†$)‡ýC‡áŸîÃÇá‹á·.†wDÐ@dàÁ- †¿ŽÝÍwÇ¿Ý~û:ì»)Ìÿ÷t7o1üòØõ—@1]–I Ò›ò5 y…|!Ó›·T~zò÷Ô¿ì»?;¼´¯ï¦ßc@r`ž§p÷ÐÝü€%Üõ݇[U9JVUR–,² í Å]ˆÃÝ»îû»î§«!rÀ˜jlä(8ù/\úb˾ŽŒu¸ íªNñ8°¥—ƒ` 6)'ª²Ñ^ ¼îô°Ïš$ Kuר ªÁS&Y\ÓäÚv!
98炪ìw!—‘ñ{«üĩʬÍN¯VõªÊ*ª"¥&”›Ô¥*eUE#TþLVmÀ9d¿G9^?A
Ý‚‹+-Lì)“5[äEL€ŸaÑ*’¤ªWUšRré!Ò·Èv°:Í-|*[}ý|`9h\{C<f¹èD lojll¹g;òýت"#Uy°õv°ìrl± ÈejÎM¬Eæ£d©9õèd½ù.` ·NãwC;X›âgH©ÊFÐØÅÕ°0y…nÙh™W9^¨Xöh-ÊÞ Ì­æ¸Ñî'ÖºÓ¯WwÈ "RcÒºò\ÀSÕ{«Ò+bcU¯ú™Úk’òî@BoýPn¤áÖŽ†&Mj?äLUS*ºŒl¦P웦d#ù¿ã‚ÉÀо40M>$(V·ˆ‰#«–JE'¿ß…"ÑÖ䣂^‡íÙJ†
óÃ3D«ñ`äH=GêçÀ4 %Ðôéˆc8®\>B6:N!;$L5F>¸—C VñºorHÁÄàX
¾9W>#ËYð×9O™­œÑº8Lœ&i¦a–6š¿Ÿró~âþþÍem\Ööþòñã¡cbPÚy†ñЕéÊVÈ3Œ‡NU€Ò@^¥eKÁ¢@ôªÄô¼ÊÿajÒEŒ¯ßœ‰¼ôÆ
!6´£‰åÒ‚J#hp>d0­†D§é•õCâë+FP±çúo19L<Ì«* (¹˜Ž‰áåçžò×÷tY@¥vCøåõ½\Ð £¹ò䊀ÉÍ-'Wò's O älnb A:‘ÞÁÜ(½ä,›[ Ï0VéÝy†±Jï³!¯QfšÀ<hrÈmoå$ȦN-úz$ˆ(5ÿthâ3U×<¬u^
Êb»0ô´cÑ›J;÷š:æ½Æ0U7h»GH9}~ ž¬´ªçBÀ‹žkÙªçàln\|„œÍ-‹€OËžÌ-‹€³¹‰e ¤ ò‰žæF=Ÿ g-Þy†±êùÈ3ŒUÏŸ
y•©¦@ÌÔÒ…ÊR/ÝulÑh„˜QEYG…\_¹Ò0ÐüTW®ªá•¨E
¿Ì ¥†W×ü® dÑpWЭ>ÎæÈE´GÈÙܹˆö´ìÉܹˆö9››XÆòÿOÜ7lË‚°ª÷ËáNVå~9Ü ÂªÚÏ„»ÒXQÙÁç*:y!'çì6CfGˆ"5­®Ápª:õy¦Ünº€ÄÀV_¶Ž—_ƒb÷Îeª-n=<ƒZÇ&ÓÔâ2Õ|RšÏ@4l“ÁÊ•2^’]xfÃkÁåeÞô¨I(UY4ž»sªzeÁá}侌ћ]¼ró©ÎCºëxÔmyÑ„©Ê¦¿ÆW“ð!
7”O óp!XB('O%,[ß4Š2[•Ô7ENzébÕÙx‰žÁ´é–u˜Æ4
¦€1Õ¸}³Áž¸²"$9s9ÙñôRÊ£/§½iÊ]WH”-
·MŒA¥âûÒ•pŠluÊ÷Œ,|ÏÂ~æý_eϸ
endstream
endobj
15 0 obj
(pdfmake)
endobj
16 0 obj
(pdfmake)
endobj
17 0 obj
(D:20250410211447Z)
endobj
14 0 obj
<<
/Producer 15 0 R
/Creator 16 0 R
/CreationDate 17 0 R
>>
endobj
10 0 obj
<<
/Type /Font
/BaseFont /Helvetica-Bold
/Subtype /Type1
/Encoding /WinAnsiEncoding
>>
endobj
11 0 obj
<<
/Type /Font
/BaseFont /Helvetica
/Subtype /Type1
/Encoding /WinAnsiEncoding
>>
endobj
13 0 obj
<<
/Type /Font
/BaseFont /Helvetica-Oblique
/Subtype /Type1
/Encoding /WinAnsiEncoding
>>
endobj
4 0 obj
<<
>>
endobj
3 0 obj
<<
/Type /Catalog
/Pages 1 0 R
/Names 2 0 R
>>
endobj
1 0 obj
<<
/Type /Pages
/Count 1
/Kids [7 0 R]
>>
endobj
2 0 obj
<<
/Dests <<
/Names [
]
>>
>>
endobj
xref
0 18
0000000000 65535 f
0000002578 00000 n
0000002635 00000 n
0000002516 00000 n
0000002495 00000 n
0000000445 00000 n
0000000264 00000 n
0000000154 00000 n
0000000015 00000 n
0000000059 00000 n
0000002188 00000 n
0000002291 00000 n
0000000109 00000 n
0000002389 00000 n
0000002112 00000 n
0000002024 00000 n
0000002050 00000 n
0000002076 00000 n
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 790 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.
Binary file not shown.
@@ -1,533 +0,0 @@
'use client';
import React, { useState } from 'react';
import Pagination from '@/components/pagination';
import Image from 'next/image';
import axiosInstance from '@/utils/api-config';
import Select from '@/components/select';
import Input from '@/components/input';
import Button from '@/components/button';
import FloatingInput from '@/components/floating-input';
import { plantillasDisponibles } from '@/data/plantillas';
import { FormularioCreacion, PreguntaFormulario, SeccionFormulario } from '@/types/crear-formulario';
import toast from 'react-hot-toast';
export default function AdminFormPage() {
const [nombreFormulario, setNombreFormulario] = useState('');
const [descripcion, setDescripcion] = useState('');
const [secciones, setSecciones] = useState<SeccionFormulario[]>([]);
const [paginaActual, setPaginaActual] = useState(1);
const [bannerPreview, setBannerPreview] = useState<string | null>(null);
const [tipoCuestionario, setTipoCuestionario] = useState<number | null>(null);
const [fechaInicio, setFechaInicio] = useState<string | null>(null);
const [fechaFin, setFechaFin] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const swapItems = <T,>(arr: T[], index1: number, index2: number): T[] => {
const result = [...arr];
[result[index1], result[index2]] = [result[index2], result[index1]];
return result;
};
const moverSeccion = (from: number, to: number) => {
if (to >= 0 && to < secciones.length) {
setSecciones((prev) => swapItems(prev, from, to));
setPaginaActual(to + 1);
}
};
const moverPregunta = (seccionIndex: number, from: number, to: number) => {
const nuevas = [...secciones];
const preguntas = nuevas[seccionIndex].preguntas;
if (to >= 0 && to < preguntas.length) {
nuevas[seccionIndex].preguntas = swapItems(preguntas, from, to);
setSecciones(nuevas);
}
};
const agregarSeccion = () => {
setSecciones((prev) => [
...prev,
{ titulo: '', descripcion: '', preguntas: [] },
]);
setPaginaActual(secciones.length + 1);
};
const eliminarSeccion = (index: number) => {
const nuevas = secciones.filter((_, i) => i !== index);
setSecciones(nuevas);
setPaginaActual((prev) =>
Math.max(1, prev > nuevas.length ? nuevas.length : prev)
);
};
const actualizarSeccion = <K extends keyof SeccionFormulario>(
index: number,
campo: K,
valor: SeccionFormulario[K]
) => {
setSecciones((prev) => {
const nuevas = [...prev];
nuevas[index][campo] = valor;
return nuevas;
});
};
const agregarPregunta = (seccionIndex: number) => {
const nuevas = [...secciones];
nuevas[seccionIndex].preguntas.push({
titulo: '',
tipo: 'Abierta',
opciones: [],
obligatoria: false,
});
setSecciones(nuevas);
};
const eliminarPregunta = (seccionIndex: number, preguntaIndex: number) => {
const nuevas = [...secciones];
nuevas[seccionIndex].preguntas.splice(preguntaIndex, 1);
setSecciones(nuevas);
};
const actualizarPregunta = (
seccionIndex: number,
preguntaIndex: number,
campo: keyof PreguntaFormulario,
valor: PreguntaFormulario[keyof PreguntaFormulario]
) => {
setSecciones((prev) => {
const nuevas = [...prev];
const pregunta = nuevas[seccionIndex].preguntas[preguntaIndex];
nuevas[seccionIndex].preguntas[preguntaIndex] = {
...pregunta,
[campo]: valor,
};
return nuevas;
});
};
const agregarOpcion = (seccionIndex: number, preguntaIndex: number) => {
const nuevas = [...secciones];
if (nuevas[seccionIndex]?.preguntas[preguntaIndex]?.opciones) {
nuevas[seccionIndex].preguntas[preguntaIndex].opciones!.push({ valor: '' });
}
setSecciones(nuevas);
};
const eliminarOpcion = (
seccionIndex: number,
preguntaIndex: number,
opcionIndex: number
) => {
const nuevas = [...secciones];
nuevas[seccionIndex]?.preguntas[preguntaIndex]?.opciones?.splice(
opcionIndex,
1
);
setSecciones(nuevas);
};
const actualizarOpcion = (
seccionIndex: number,
preguntaIndex: number,
opcionIndex: number,
valor: string
) => {
const nuevas = [...secciones];
if (
nuevas[seccionIndex]?.preguntas[preguntaIndex]?.opciones &&
nuevas[seccionIndex].preguntas[preguntaIndex].opciones![opcionIndex]
) {
nuevas[seccionIndex].preguntas[preguntaIndex].opciones![opcionIndex].valor = valor;
}
setSecciones(nuevas);
};
const guardarFormulario = async () => {
if (!fechaInicio || !fechaFin || !tipoCuestionario) {
alert('Todos los campos obligatorios deben estar llenos');
return;
}
const formatearFecha = (fecha: string, tipo: 'inicio' | 'fin'): string => {
const sufijo = tipo === 'inicio' ? 'T00:00:01' : 'T23:59:59';
return `${fecha}${sufijo}`;
};
const formData: FormularioCreacion = {
nombre_form: nombreFormulario,
descripcion,
fecha_inicio: formatearFecha(fechaInicio, 'inicio'),
fecha_fin: formatearFecha(fechaFin, 'fin'),
id_tipo_cuestionario: tipoCuestionario,
evento: nombreFormulario, // temporalmente igual al nombre del form
secciones,
};
console.log('Formulario a guardar:', formData);
setLoading(true);
await toast.promise(
axiosInstance.post('/cuestionario', formData),
{
loading: 'Guardando formulario...',
success: 'Formulario guardado exitosamente!',
error: 'Error al guardar el formulario.',
}
).then((res) => {
console.log('Formulario guardado:', res.data);
}).catch((error) => {
console.error('Error al guardar el formulario:', error);
}).finally(() => {
setLoading(false);
});
};
const handleBannerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] || null;
if (file) {
const reader = new FileReader();
reader.onload = () => setBannerPreview(reader.result as string);
reader.readAsDataURL(file);
} else {
setBannerPreview(null);
}
};
const seccionActual = secciones[paginaActual - 1];
return (
<>
<h2 className="my-4">Crear nuevo formulario</h2>
<div className="row">
{secciones.length === 0 &&
plantillasDisponibles.map((plantilla, index) => (
<div className="col-md-4 mb-3" key={plantilla.id}>
<h3>Plantillas</h3>
<div
className="box w-100 cursor-pointer"
onClick={() => {
const {
nombre_form,
descripcion,
fecha_inicio,
fecha_fin,
id_tipo_cuestionario,
secciones,
} = plantilla.datos;
setNombreFormulario(nombre_form);
setDescripcion(descripcion);
setFechaInicio(fecha_inicio.slice(0, 10));
setFechaFin(fecha_fin.slice(0, 10));
setTipoCuestionario(id_tipo_cuestionario);
setSecciones(
secciones.map((seccion) => ({
...seccion,
preguntas: seccion.preguntas.map((pregunta) => ({
...pregunta,
tipo: pregunta.tipo as
| 'Abierta'
| 'Cerrada'
| 'Multiple',
})),
}))
);
setPaginaActual(1);
}}
>
<Image
src={plantilla.imagen}
width={1000}
height={300}
alt={`Plantilla de formulario ${index + 1}`}
className="img-fluid rounded shadow-sm"
/>
</div>
</div>
))}
</div>
<div className="mb-4">
<label className="form-label">Banner del formulario</label>
<input
type="file"
accept="image/*"
className="form-control mb-2"
onChange={handleBannerChange}
/>
{bannerPreview && (
<div className="text-center">
<Image
src={bannerPreview}
width={1000}
height={300}
alt="Ejemplo de banner"
className="rounded-4 shadow-sm"
style={{
objectFit: 'cover',
objectPosition: 'center',
}}
/>
</div>
)}
</div>
<Select
label="Tipo de cuestionario"
options={[
{ label: 'Seleccione un tipo', value: '' },
{ label: 'Tipo 1', value: 1 },
{ label: 'Tipo 2', value: 2 },
{ label: 'Tipo 3', value: 3 },
]}
value={tipoCuestionario?.toString() || ''}
onChange={(e) => setTipoCuestionario(Number(e.target.value))}
/>
<Input
label="Nombre del formulario"
value={nombreFormulario}
onChange={(e) => setNombreFormulario(e.target.value)}
placeholder="Ingrese el nombre del formulario"
/>
<div className="mb-3">
<label className="form-label">Descripción</label>
<textarea
className="form-control bg-white"
rows={3}
value={descripcion}
onChange={(e) => setDescripcion(e.target.value)}
/>
</div>
<div className="row">
<div className="col-md-6">
<Input
label="Fecha de inicio"
type="date"
value={fechaInicio || ''}
onChange={(e) => setFechaInicio(e.target.value)}
/>
</div>
<div className="col-md-6">
<Input
label="Fecha de fin"
type="date"
value={fechaFin || ''}
onChange={(e) => setFechaFin(e.target.value)}
/>
</div>
</div>
<div className="mb-4">
<Button outline onClick={agregarSeccion} icon="plus">
Agregar Sección
</Button>
{seccionActual && (
<div className="border rounded p-3 mb-4">
<div className="d-flex justify-content-between align-items-center">
<h5>Sección {paginaActual}</h5>
<div className="d-flex gap-2">
<button
className="btn btn-sm btn-outline-secondary"
onClick={() =>
moverSeccion(paginaActual - 1, paginaActual - 2)
}
disabled={paginaActual === 1}
>
</button>
<button
className="btn btn-sm btn-outline-secondary"
onClick={() => moverSeccion(paginaActual - 1, paginaActual)}
disabled={paginaActual === secciones.length}
>
</button>
<button
className="btn btn-sm btn-danger"
onClick={() => eliminarSeccion(paginaActual - 1)}
>
Eliminar sección
</button>
</div>
</div>
<FloatingInput
label="Título de la sección"
className={{ container: 'mb-2' }}
value={seccionActual.titulo}
onChange={(e) =>
actualizarSeccion(paginaActual - 1, 'titulo', e.target.value)
}
placeholder="Título de la sección"
/>
<textarea
className="form-control mb-3"
placeholder="Descripción de la sección"
value={seccionActual.descripcion}
onChange={(e) =>
actualizarSeccion(
paginaActual - 1,
'descripcion',
e.target.value
)
}
/>
{seccionActual.preguntas.map((pregunta, pIdx) => (
<div key={pIdx} className="border rounded p-2 mb-3">
<div className="d-flex justify-content-between align-items-center">
<strong>Pregunta {pIdx + 1}</strong>
<div className="d-flex gap-2">
<button
className="btn btn-sm btn-outline-secondary"
onClick={() =>
moverPregunta(paginaActual - 1, pIdx, pIdx - 1)
}
disabled={pIdx === 0}
>
</button>
<button
className="btn btn-sm btn-outline-secondary"
onClick={() =>
moverPregunta(paginaActual - 1, pIdx, pIdx + 1)
}
disabled={pIdx === seccionActual.preguntas.length - 1}
>
</button>
<button
className="btn btn-sm btn-outline-danger"
onClick={() => eliminarPregunta(paginaActual - 1, pIdx)}
>
Eliminar
</button>
</div>
</div>
<Input
label="Texto de la pregunta"
className={{ container: 'my-2' }}
value={pregunta.titulo}
onChange={(e) =>
actualizarPregunta(
paginaActual - 1,
pIdx,
'titulo',
e.target.value
)
}
placeholder="Texto de la pregunta"
/>
<Select
label="Tipo de pregunta"
options={[
{ label: 'Abierta', value: 'Abierta' },
{ label: 'Cerrada', value: 'Cerrada' },
{ label: 'Opción múltiple', value: 'Multiple' },
]}
value={pregunta.tipo}
onChange={(e) =>
actualizarPregunta(
paginaActual - 1,
pIdx,
'tipo',
e.target.value as PreguntaFormulario['tipo']
)
}
/>
<div className="form-check form-switch mb-3">
<input
className="form-check-input"
type="checkbox"
id={`obligatoria-${pIdx}`}
checked={pregunta.obligatoria}
onChange={(e) =>
actualizarPregunta(
paginaActual - 1,
pIdx,
'obligatoria',
e.target.checked
)
}
/>
<label
className="form-check-label"
htmlFor={`obligatoria-${pIdx}`}
>
¿Es obligatoria?
</label>
</div>
{(pregunta.tipo === 'Cerrada' ||
pregunta.tipo === 'Multiple') && (
<>
<button
className="btn btn-sm btn-outline-secondary mb-2"
onClick={() => agregarOpcion(paginaActual - 1, pIdx)}
>
+ Agregar opción
</button>
{(pregunta.opciones ?? []).map((op, oIdx) => (
<div key={oIdx} className="input-group mb-2">
<input
type="text"
className="form-control bg-white"
placeholder={`Opción ${oIdx + 1}`}
value={op.valor}
onChange={(e) =>
actualizarOpcion(
paginaActual - 1,
pIdx,
oIdx,
e.target.value
)
}
/>
<button
className="btn btn-outline-danger"
onClick={() =>
eliminarOpcion(paginaActual - 1, pIdx, oIdx)
}
>
x
</button>
</div>
))}
</>
)}
</div>
))}
<button
className="btn btn-sm btn-outline-primary"
onClick={() => agregarPregunta(paginaActual - 1)}
>
+ Agregar pregunta
</button>
</div>
)}
{secciones.length > 1 && (
<div className="d-flex justify-content-center mb-4">
<Pagination
currentPage={paginaActual}
totalPages={secciones.length}
onPageChange={setPaginaActual}
/>
</div>
)}
</div>
<Button onClick={guardarFormulario} color="success">
Guardar formulario
</Button>
</>
);
}
@@ -1,21 +0,0 @@
import Link from 'next/link';
import React from 'react';
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div>
<nav className='d-flex gap-2 my-4'>
<Link href={'/administrador/'} className='text-decoration-none'>
<div className='box'>Registros</div>
</Link>
<Link
href={'/administrador/crear-formulario'}
className='text-decoration-none'
>
<div className='box'>Editar formulario</div>
</Link>
</nav>
{children}
</div>
);
}
@@ -1,12 +0,0 @@
'use client';
import { useParams } from 'next/navigation';
import React from 'react';
export default function Page() {
const params = useParams<{
id_cuestionario: string;
}>();
const id_cuestionario = params?.id_cuestionario;
return <div>{id_cuestionario}</div>;
}
-27
View File
@@ -1,27 +0,0 @@
import Footer from '@/components/layout/footer';
import Header from '@/components/layout/header';
import Link from 'next/link';
import React from 'react';
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className='d-flex flex-column min-vh-100'>
<Header />
<main className='container flex-grow-1'>
<nav className='d-flex gap-2 my-4'>
<Link href={'/administrador/'} className='text-decoration-none'>
<div className='box'>Formularios</div>
</Link>
<Link
href={'/administrador/crear-formulario'}
className='text-decoration-none'
>
<div className='box'>Crear cuestionario</div>
</Link>
</nav>
{children}
</main>
<Footer />
</div>
);
}
-60
View File
@@ -1,60 +0,0 @@
import FormularioCard from '@/components/formulario-card';
import { GetCuestionario } from '@/types/cuestionario';
import React from 'react';
async function getEventos(): Promise<GetCuestionario[]> {
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/cuestionario`, {
method: 'GET',
cache: 'no-store', // para que no lo cachee ni en build ni entre requests
headers: {
'Content-Type': 'application/json',
},
});
if (!res.ok) {
throw new Error(`Error ${res.status}: ${res.statusText}`);
}
const data: GetCuestionario[] = await res.json();
return data;
} catch (error) {
console.error('Error en getEventos:', error);
throw error;
}
}
export default async function Page() {
let eventos;
try {
eventos = await getEventos();
} catch (error) {
console.error('Error al cargar los eventos:', error);
return <div>Error al cargar los eventos</div>;
}
return (
<>
<div className="cards-1 mt-3">
<div className="container">
<div className="row">
{eventos.map((cuestionario) => (
<FormularioCard
key={cuestionario.id_cuestionario}
cuestionario={cuestionario}
link={`/administrador/formulario/${cuestionario.id_cuestionario}`}
/>
))}
{eventos.length === 0 && (
<p className="text-center text-muted">
No hay formularios disponibles.
</p>
)}
</div>
</div>
</div>
</>
);
}
-139
View File
@@ -1,139 +0,0 @@
'use client';
import React, { useState } from 'react';
import dynamic from 'next/dynamic';
import { Scanner } from '@yudiel/react-qr-scanner';
import Button from '@/components/button';
import axiosInstance from '@/utils/api-config';
import Header from '@/components/layout/header';
import Footer from '@/components/layout/footer';
const Modal = dynamic(() => import('@/components/modal'), { ssr: false });
export default function Page() {
const [scannedData, setScannedData] = useState<{
id_participante: number;
id_evento: number;
} | null>(null);
const [showModal, setShowModal] = useState(false);
const [enableScan, setEnableScan] = useState(true);
const [statusMessage, setStatusMessage] = useState('');
const handleScan = (rawValue: string) => {
if (!enableScan) return;
try {
const data = JSON.parse(rawValue);
if (data.id_participante && data.id_evento) {
setScannedData({
id_participante: data.id_participante,
id_evento: data.id_evento,
});
setEnableScan(false);
setShowModal(true);
} else {
setStatusMessage('El QR no contiene los campos requeridos');
}
} catch (err) {
console.error('QR malformado:', err);
setStatusMessage('Error al leer el QR: formato inválido');
}
};
const handleConfirm = async () => {
if (!scannedData) return;
try {
const response = await axiosInstance.post(
`/participante-evento/asistencia/${scannedData.id_participante}/${scannedData.id_evento}`
);
console.log('Asistencia registrada:', response.data);
} catch (err) {
console.error('Error en la petición:', err);
setStatusMessage('❌ Error de red al registrar asistencia');
}
setShowModal(false);
setEnableScan(true);
setScannedData(null);
};
return (
<div className="d-flex flex-column min-vh-100">
<Header />
<main className='container flex-grow-1 d-flex flex-column align-items-center justify-content-center'>
<h1 className="mb-4">Lectura de QRs</h1>
{enableScan ? (
<>
<div style={{ width: '100%', maxWidth: '400px' }}>
<Scanner
onScan={(codes) => {
if (codes.length > 0) {
handleScan(codes[0].rawValue);
}
}}
onError={(err) => console.error('QR Error:', err)}
constraints={{ facingMode: 'environment' }}
/>
</div>
<Button
onClick={() => setEnableScan(false)}
variant="outline-danger"
className="mt-3"
>
Detener cámara
</Button>
</>
) : (
<>
<p className="text-muted mb-3">La cámara está detenida.</p>
<Button
onClick={() => setEnableScan(true)}
variant="outline-primary"
>
Activar cámara
</Button>
</>
)}
{statusMessage && <p className="mt-3">{statusMessage}</p>}
<Modal
isVisible={showModal}
size="md"
onClose={() => {
setShowModal(false);
setEnableScan(true);
setScannedData(null);
}}
closeButton
className={{
content: 'p-3',
body: 'text-center',
}}
>
<h5 className="mb-3">Datos escaneados</h5>
{scannedData ? (
<>
<p>
<strong>ID Participante:</strong> {scannedData.id_participante}
</p>
<p>
<strong>ID Evento:</strong> {scannedData.id_evento}
</p>
<Button variant="success" onClick={handleConfirm}>
Confirmar lectura
</Button>
</>
) : (
<p>QR inválido</p>
)}
</Modal>
</main>
<Footer />
</div>
);
}
@@ -0,0 +1,224 @@
'use client';
import React, { useRef, useState } from 'react';
import axiosInstance from '@/utils/api-config';
import { Button, Form, Alert } from 'react-bootstrap';
import { FaUpload, FaDownload } from 'react-icons/fa';
type TipoCarga = 'alumnos' | 'trabajadores';
interface ResultadoCarga {
insertados: number;
omitidos: number;
}
interface SeccionState {
archivo: File | null;
loading: boolean;
error: string | null;
success: string | null;
resultado: ResultadoCarga | null;
}
const initialState: SeccionState = {
archivo: null,
loading: false,
error: null,
success: null,
resultado: null,
};
export default function Page() {
const [alumnos, setAlumnos] = useState<SeccionState>(initialState);
const [trabajadores, setTrabajadores] = useState<SeccionState>(initialState);
const alumnosRef = useRef<HTMLInputElement>(null);
const trabajadoresRef = useRef<HTMLInputElement>(null);
const getState = (tipo: TipoCarga) =>
tipo === 'alumnos' ? alumnos : trabajadores;
const setState = (tipo: TipoCarga) =>
tipo === 'alumnos' ? setAlumnos : setTrabajadores;
const validarExt = (file: File | null): boolean => {
if (!file) return false;
return /\.xlsx$/i.test(file.name);
};
const handleArchivoChange = (tipo: TipoCarga, file: File | null) => {
if (file && !validarExt(file)) {
setState(tipo)((prev) => ({
...prev,
archivo: null,
error: 'Selecciona un archivo .xlsx válido',
success: null,
}));
return;
}
setState(tipo)((prev) => ({
...prev,
archivo: file,
error: null,
success: null,
}));
};
const handleCargaMasiva = async (tipo: TipoCarga) => {
const estado = getState(tipo);
if (!estado.archivo) {
setState(tipo)((prev) => ({
...prev,
error: 'Selecciona un archivo xlsx',
}));
return;
}
const formData = new FormData();
formData.append('file', estado.archivo);
setState(tipo)((prev) => ({
...prev,
loading: true,
error: null,
success: null,
}));
try {
const res = await axiosInstance.post(`/${tipo}/cargar`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
setState(tipo)((prev) => ({
...prev,
archivo: null,
success: `${tipo === 'alumnos' ? 'Alumnos' : 'Trabajadores'} cargados exitosamente`,
resultado: res.data ?? null,
}));
const ref = tipo === 'alumnos' ? alumnosRef : trabajadoresRef;
if (ref.current) ref.current.value = '';
} catch (error) {
const err = error as { response?: { data?: { message?: string } } };
setState(tipo)((prev) => ({
...prev,
error: err?.response?.data?.message || 'Error al cargar el archivo',
}));
} finally {
setState(tipo)((prev) => ({ ...prev, loading: false }));
}
};
const handleDescargarPlantilla = (tipo: TipoCarga) => {
const nombre = tipo === 'alumnos' ? 'carga-alumnos' : 'carga-trabajadores';
const link = document.createElement('a');
link.href = `/plantillas/${nombre}.xlsx`;
link.setAttribute('download', `${nombre}.xlsx`);
document.body.appendChild(link);
link.click();
link.remove();
};
const SeccionCarga = ({
tipo,
label,
}: {
tipo: TipoCarga;
label: string;
}) => {
const estado = getState(tipo);
const inputRef = tipo === 'alumnos' ? alumnosRef : trabajadoresRef;
return (
<div className="p-4 border rounded bg-light mb-4">
<h2 className="mb-3">{label}</h2>
{estado.error && (
<Alert
variant="danger"
onClose={() => setState(tipo)((p) => ({ ...p, error: null }))}
dismissible
>
{estado.error}
</Alert>
)}
{estado.success && (
<Alert
variant="success"
onClose={() =>
setState(tipo)((p) => ({ ...p, success: null, resultado: null }))
}
dismissible
>
<p className="mb-1">{estado.success}</p>
{estado.resultado && (
<ul className="mb-0">
<li>
Insertados: <strong>{estado.resultado.insertados}</strong>
</li>
<li>
Omitidos: <strong>{estado.resultado.omitidos}</strong>
</li>
</ul>
)}
</Alert>
)}
<Form.Group className="mb-3">
<Form.Label>Archivo Excel (.xlsx)</Form.Label>
<div
className="border p-4 text-center rounded"
style={{ cursor: 'pointer' }}
onClick={() => inputRef.current?.click()}
>
<FaUpload size={36} className="mb-2 text-secondary" />
<p className="mb-0">
{estado.archivo?.name ||
'Arrastra aquí tu archivo o haz click para buscar'}
</p>
</div>
<input
ref={inputRef}
type="file"
accept=".xlsx"
style={{ display: 'none' }}
onChange={(e) =>
handleArchivoChange(tipo, e.target.files?.[0] ?? null)
}
/>
</Form.Group>
<div className="d-flex gap-2 flex-wrap">
<Button
variant="primary"
disabled={estado.loading || !estado.archivo}
onClick={() => handleCargaMasiva(tipo)}
>
{estado.loading ? 'Cargando...' : 'Cargar archivo'}
</Button>
<Button
variant="outline-success"
onClick={() => handleDescargarPlantilla(tipo)}
>
<FaDownload className="me-2" />
Descargar plantilla
</Button>
</div>
</div>
);
};
return (
<div className="container-fluid my-4">
<div className="row mb-4">
<div className="col-12">
<div className="p-4 border rounded bg-light">
<h1 className="mb-1">Carga masiva de usuarios</h1>
<p className="mb-0 text-muted">
Sube un archivo Excel para registrar alumnos o trabajadores en el
sistema.
</p>
</div>
</div>
</div>
<SeccionCarga tipo="alumnos" label="Carga de alumnos" />
<SeccionCarga tipo="trabajadores" label="Carga de trabajadores" />
</div>
);
}
@@ -0,0 +1,228 @@
'use client';
import Breadcrumb from '@/components/breadcrumb';
import SimpleInput from '@/components/input';
import Table, { Header } from '@/components/table';
import EditFormulario from '@/containers/edit-formulario';
import { useGetApi } from '@/hooks/use-get-api';
import { GetCuestionario } from '@/types/cuestionario';
import { ParticipacionEvento } from '@/types/participante-evento';
import axiosInstance from '@/utils/api-config';
import { useParams } from 'next/navigation';
import React, { useEffect } from 'react';
import toast from 'react-hot-toast';
import { useEvento } from '@/context/evento';
import dynamic from 'next/dynamic';
import Button from '@/components/button';
const Modal = dynamic(() => import('@/components/modal'), { ssr: false });
type Params = {
id_evento: string;
id_cuestionario: string;
};
export default function Page() {
const params = useParams<Params>();
const { evento } = useEvento();
const [cuestionario, setCuestionario] =
React.useState<GetCuestionario | null>(null);
const [busquedaCorreo, setBusquedaCorreo] = React.useState('');
const [loadingAsistencia, setLoadingAsistencia] = React.useState<
Record<number, boolean>
>({});
const [contadorModal, setContadorModal] = React.useState<number | null>(null);
const { data: participantes, setData } = useGetApi<ParticipacionEvento[]>(
`/participante-evento/evento/${params.id_cuestionario}`
);
const { data } = useGetApi<GetCuestionario>(
`/cuestionario/${params.id_cuestionario}`
);
useEffect(() => {
if (data) setCuestionario(data);
}, [data]);
const confirmarAsistencia = async (
id_participante: number,
id_evento: number
) => {
setLoadingAsistencia((prev) => ({ ...prev, [id_participante]: true }));
try {
const response = await axiosInstance.post(
`/participante-evento/asistencia/${id_participante}/${id_evento}`
);
if (response.data?.contador != null) {
setContadorModal(response.data.contador);
}
toast.success('Asistencia confirmada');
const { data } = await axiosInstance.get<ParticipacionEvento[]>(
`/participante-evento/evento/${id_evento}`
);
setData(data);
} catch (error) {
toast.error('Error al confirmar asistencia');
console.error(error);
} finally {
setLoadingAsistencia((prev) => ({ ...prev, [id_participante]: false }));
}
};
const headers: Header<ParticipacionEvento>[] = [
{
key: 'id_participante',
label: '#',
render: (_, row) => row.participante.id_participante,
},
{
key: 'participante',
label: 'Correo',
render: (_, row) => row.participante.correo,
},
{
key: 'fecha_registro',
label: 'Fecha registro',
render: (_, row) =>
row.fecha_registro
? new Date(row.fecha_registro).toLocaleString()
: 'Sin registro',
},
{
key: 'estatus',
label: 'Asistencia',
render: (_, row) => {
const isLoading = loadingAsistencia[row.id_participante];
return row.fecha_asistencia ? (
<span className="text-success">
<i className="bi bi-check-circle-fill me-2"></i>
Asistió
</span>
) : (
<button
className="btn btn-sm btn-outline-success"
onClick={() =>
confirmarAsistencia(row.id_participante, row.id_cuestionario)
}
disabled={isLoading}
>
{isLoading ? (
<>
<span
className="spinner-border spinner-border-sm me-2"
role="status"
aria-hidden="true"
></span>
Confirmando...
</>
) : (
'Confirmar asistencia'
)}
</button>
);
},
},
];
const handleChange = (field: keyof GetCuestionario, value: string | Date) => {
setCuestionario((prev) =>
prev
? {
...prev,
[field]: value,
}
: null
);
};
const handleCuestionarioActualizado = (
eventoActualizado: GetCuestionario
) => {
setCuestionario(eventoActualizado);
};
const participantesFiltrados =
participantes &&
participantes.filter((p) =>
p.participante.correo.toLowerCase().includes(busquedaCorreo.toLowerCase())
);
const breadcrumbItems = [
{ label: 'Inicio', href: '/user/eventos' },
{
label: evento?.nombre_evento || 'Evento',
href: `/user/evento/${params.id_evento}`,
},
{ label: cuestionario?.nombre_form || 'Formulario' },
];
return (
<div className="my-4">
<Breadcrumb items={breadcrumbItems} />
{cuestionario && (
<EditFormulario
cuestionario={cuestionario}
evento={evento}
handleChange={handleChange}
handleOnChange={handleCuestionarioActualizado}
/>
)}
<h3 className="mt-5">Participantes</h3>
{participantes?.length === 0 && (
<p className="text-muted">No hay participantes registrados aún.</p>
)}
{participantes && participantes.length > 0 && (
<>
<SimpleInput
label="Buscar por correo"
placeholder="ejemplo@correo.com"
value={busquedaCorreo}
onChange={(e) => setBusquedaCorreo(e.target.value)}
/>
<div className="mt-4">
<Table
headers={headers}
data={participantesFiltrados ?? []}
rowKey={(row) => row.id_participante}
/>
</div>
</>
)}
<Modal
isVisible={contadorModal !== null}
size="sm"
onClose={() => setContadorModal(null)}
closeButton
className={{ content: 'border-0 rounded-3', body: 'text-center' }}
>
<div className="modal-header bg-primary text-white border-0 rounded-top-3">
<h5 className="modal-title mb-0 fw-bold">
<i className="bi bi-hash me-2"></i>
Contador
</h5>
</div>
<div className="modal-body p-4">
<p className="display-4 fw-bold text-primary mb-1">{contadorModal}</p>
<p className="text-muted mb-4">
Este es el numero de estampillas a entregar al usuario
</p>
<Button
variant="primary"
onClick={() => setContadorModal(null)}
className="px-4 py-2 rounded-pill"
>
Cerrar
</Button>
</div>
</Modal>
</div>
);
}
@@ -0,0 +1,293 @@
'use client';
import { plantillasDisponibles } from '@/data/plantillas';
import { FormularioCreacion } from '@/types/create-formulario';
import React, { useState } from 'react';
import CreateFormulario from '@/containers/create-formulario';
import FormularioCardPreview from '@/components/formulario/formulario-card-preview';
import Button from '@/components/button';
import Breadcrumb from '@/components/breadcrumb';
import { useEvento } from '@/context/evento';
import { useParams, useRouter } from 'next/navigation';
import axiosInstance from '@/utils/api-config';
import { getAxiosError } from '@/utils/errors-utils';
import toast from 'react-hot-toast';
import { useGetApi } from '@/hooks/use-get-api';
import { TipoEvento } from '@/types/evento';
type Params = {
id_evento: string;
};
export default function Page() {
const params = useParams<Params>();
const router = useRouter();
const { evento, refetch } = useEvento();
const [datosFormulario, setDatosFormulario] =
useState<FormularioCreacion | null>(null);
const { data: tipo_eventos } = useGetApi<TipoEvento[]>(`/tipo-evento`);
const handleSeleccionarPlantilla = (plantillaId: string) => {
const seleccionada = plantillasDisponibles.find(
(p) => p.id === plantillaId
);
if (seleccionada && evento) {
// Crear una copia de los datos de la plantilla
const datosPlantilla = { ...seleccionada.datos };
// Ajustar las fechas para que estén dentro del rango del evento
const fechaInicioEvento = new Date(evento.fecha_inicio);
const fechaFinEvento = new Date(evento.fecha_fin);
// Formatear las fechas para inputs datetime-local (YYYY-MM-DDTHH:MM)
const formatoFechaInput = (fecha: Date) => {
return fecha.toISOString().slice(0, 16);
};
// Establecer fecha de inicio del formulario igual a la del evento
datosPlantilla.fecha_inicio = formatoFechaInput(fechaInicioEvento);
// Establecer fecha de fin del formulario igual a la del evento
datosPlantilla.fecha_fin = formatoFechaInput(fechaFinEvento);
setDatosFormulario(datosPlantilla);
console.log('Plantilla seleccionada:', seleccionada);
console.log('Fechas ajustadas al evento:', {
inicio: datosPlantilla.fecha_inicio,
fin: datosPlantilla.fecha_fin,
});
} else if (seleccionada) {
// Si no hay evento disponible, usar las fechas originales de la plantilla
setDatosFormulario(seleccionada.datos);
console.log(
'Plantilla seleccionada (sin ajuste de fechas):',
seleccionada
);
}
};
const breadcrumbItems = [
{ label: 'Inicio', href: '/user/eventos' },
{
label: evento?.nombre_evento || 'Evento',
href: `/user/evento/${params.id_evento}`,
},
{ label: 'Crear formulario' },
];
const handleCrearEvento = async () => {
if (!datosFormulario || !evento) {
toast.error('Faltan datos para crear el formulario');
return;
}
// Validar fechas del formulario respecto al evento
const fechaInicioFormulario = new Date(datosFormulario.fecha_inicio);
const fechaFinFormulario = new Date(datosFormulario.fecha_fin);
const fechaInicioEvento = new Date(evento.fecha_inicio);
const fechaFinEvento = new Date(evento.fecha_fin);
// Convertir a solo fecha (sin hora) para comparación
const fechaInicioFormularioSoloFecha = new Date(
fechaInicioFormulario.getFullYear(),
fechaInicioFormulario.getMonth(),
fechaInicioFormulario.getDate()
);
const fechaFinFormularioSoloFecha = new Date(
fechaFinFormulario.getFullYear(),
fechaFinFormulario.getMonth(),
fechaFinFormulario.getDate()
);
const fechaInicioEventoSoloFecha = new Date(
fechaInicioEvento.getFullYear(),
fechaInicioEvento.getMonth(),
fechaInicioEvento.getDate()
);
const fechaFinEventoSoloFecha = new Date(
fechaFinEvento.getFullYear(),
fechaFinEvento.getMonth(),
fechaFinEvento.getDate()
);
console.log('Fechas del formulario:', {
inicio: fechaInicioFormulario,
fin: fechaFinFormulario,
});
console.log('Fechas del evento:', {
inicio: fechaInicioEvento,
fin: fechaFinEvento,
});
if (fechaInicioFormularioSoloFecha < fechaInicioEventoSoloFecha) {
toast.error(
'La fecha de inicio del formulario debe ser posterior o igual a la fecha de inicio del evento'
);
return;
}
if (fechaFinFormularioSoloFecha > fechaFinEventoSoloFecha) {
toast.error(
'La fecha de fin del formulario debe ser anterior o igual a la fecha de fin del evento'
);
return;
}
if (fechaInicioFormulario > fechaFinFormulario) {
toast.error(
'La fecha de inicio del formulario debe ser anterior a la fecha de fin'
);
return;
}
try {
const res = await axiosInstance.post(`/cuestionario`, {
id_evento: evento?.id_evento || params.id_evento,
...datosFormulario,
});
console.log('Formulario creado:', res.data);
refetch();
toast.success('Formulario creado exitosamente');
router.push(`/user/evento/${params.id_evento}`);
} catch (error) {
const msg = getAxiosError(error);
toast.error(msg.message || 'Error al crear el formulario');
}
};
return (
<div className="container-fluid my-4">
<Breadcrumb items={breadcrumbItems} />
{/* Header */}
<div className="row mb-4">
<div className="col-12">
<div className="p-4 border rounded bg-light">
<h2 className="mb-2">Creación de formulario</h2>
<p className="mb-0 text-muted">
Esta sección te permite crear y configurar sub-eventos en forma de
formularios asociados al evento. Selecciona una plantilla
compatible, ajusta los campos necesarios y publícalo para
habilitar el registro, encuestas o confirmaciones dentro de este
evento.
</p>
</div>
</div>
</div>
{!datosFormulario && (
<div className="border p-3 rounded my-4">
<h2 className="h5 fw-bold mb-2">
Selecciona una plantilla para el formulario:
</h2>
<p className="text-secondary small lh-sm">
Elige una de las plantillas disponibles para crear tu formulario de
registro.
<br />
Estas plantillas están diseñadas para garantizar la correcta
integración con el sistema, permitiendo que los datos se obtengan y
se prellenan automáticamente de forma precisa.
<br />
<span className="text-danger fw-semibold">
Para asegurar el funcionamiento óptimo del prellenado, modifica la
estructura solo si es necesario.
</span>
</p>
<div className="d-flex gap-2 flex-wrap">
{plantillasDisponibles.map((plantilla) => (
<div
className="cursor-pointer"
key={plantilla.id}
onClick={() => handleSeleccionarPlantilla(plantilla.id)}
>
<div
className="d-flex align-items-center justify-content-center rounded shadow-sm border text-center fw-semibold bg-azul text-white"
style={{ padding: '1rem' }}
>
{plantilla.nombre}
</div>
</div>
))}
</div>
</div>
)}
{datosFormulario && (
<>
<div className="row">
{/* Columna Izquierda - Formulario */}
<div className="col-xl-7 col-lg-6">
<div className="pe-lg-4">
{(() => {
const formularioEditor = CreateFormulario({
formulario: datosFormulario,
onChange: (nuevo) => setDatosFormulario({ ...nuevo }),
evento: evento ? evento : undefined,
tipo_eventos: tipo_eventos?.map((tipo) => ({
value: tipo.id_tipo_evento,
label: tipo.tipo_evento,
})),
});
return formularioEditor.informacionBasica();
})()}
{/* Botón de crear */}
<div className="d-flex justify-content-end mt-4">
<Button icon="save" onClick={handleCrearEvento}>
Crear Formulario
</Button>
</div>
</div>
</div>
{/* Columna Derecha - Preview */}
<div className="col-xl-5 col-lg-6">
<div className="ps-lg-4">
<div className="sticky-top" style={{ top: '20px' }}>
<h4 className="mb-3 d-none d-lg-block">
Preview del Formulario
</h4>
<div className="d-lg-none mt-4">
<h4 className="mb-3">Preview del Formulario</h4>
</div>
<FormularioCardPreview
formulario={datosFormulario}
evento={evento || undefined}
/>
{/* Alert de imagen del evento */}
{evento?.banner && (
<div className="alert alert-info mt-3">
<i className="bi bi-info-circle-fill me-2"></i>
<strong>Nota:</strong> Al no proporcionar una imagen para
este formulario, se usará automáticamente la imagen del
evento.
</div>
)}
<div className="alert alert-warning text-center mt-3">
<strong>Vista previa:</strong> Así se verá tu formulario en
las listas de formularios.
</div>
</div>
</div>
</div>
</div>
{/* Secciones y preguntas - Ancho completo */}
<div className="row mt-5">
<div className="col-12">
{(() => {
const formularioEditor = CreateFormulario({
formulario: datosFormulario,
onChange: (nuevo) => setDatosFormulario({ ...nuevo }),
evento: evento ? evento : undefined,
});
return formularioEditor.seccionesYPreguntas();
})()}
</div>
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,20 @@
'use client';
import { EventoProvider } from '@/context/evento/evento-context';
import { useParams } from 'next/navigation';
import React from 'react';
type Params = {
id_evento: string;
};
export default function Layout({ children }: { children: React.ReactNode }) {
const params = useParams<Params>();
if (!params.id_evento) {
return <div>Error: ID de evento no encontrado</div>;
}
return (
<EventoProvider id_evento={params.id_evento}>{children}</EventoProvider>
);
}
@@ -0,0 +1,226 @@
'use client';
import EditEvento from '@/containers/edit-evento';
import { GetEventoWithCuestionariosWithCupos } from '@/types/evento';
import { useParams, useRouter } from 'next/navigation';
import React, { useState } from 'react';
import { CreateEventoType } from '@/types/create-evento';
import { useEvento } from '@/context/evento';
import axiosInstance from '@/utils/api-config';
import toast from 'react-hot-toast';
import { downloadFile } from '@/utils/downloas-utils';
import Breadcrumb from '@/components/breadcrumb';
import Button from '@/components/button';
import FormularioCardAdmin from '@/components/formulario/formulario-card-admin';
type Params = {
id_evento: string;
};
export default function Page() {
const router = useRouter();
const params = useParams<Params>();
const { evento, loading, error, updateEvento, setEventoData } = useEvento();
const tipo_usuario = sessionStorage.getItem('axxxd')?.replace(/"/g, '') || '';
const [loadingStates, setLoadingStates] = useState<Record<number, boolean>>(
{}
);
type TipoCarga = 'alumnos' | 'trabajadores';
const [tipoCarga, setTipoCarga] = useState<TipoCarga>('alumnos');
const [archivoCarga, setArchivoCarga] = useState<File | null>(null);
const [loadingCarga, setLoadingCarga] = useState(false);
const handleCargaMasiva = async () => {
if (!archivoCarga) {
toast.error('Selecciona un archivo xlsx');
return;
}
const formData = new FormData();
formData.append('file', archivoCarga);
formData.append('id_evento', params.id_evento);
setLoadingCarga(true);
try {
await axiosInstance.post(
`/evento/${params.id_evento}/usuarios`,
formData,
{
headers: { 'Content-Type': 'multipart/form-data' },
}
);
toast.success(
`${tipoCarga === 'alumnos' ? 'Alumnos' : 'Trabajadores'} cargados exitosamente`
);
setArchivoCarga(null);
} catch (error) {
const err = error as { response?: { data?: { message?: string } } };
toast.error(err?.response?.data?.message || 'Error al cargar el archivo');
} finally {
setLoadingCarga(false);
}
};
const handleChange = (
field: keyof CreateEventoType,
value: string | Date
) => {
// Update the evento state directly using the context
updateEvento({ [field]: value });
};
const handleEventoActualizado = (
eventoActualizado: GetEventoWithCuestionariosWithCupos
) => {
// Set the complete updated evento data
setEventoData(eventoActualizado);
};
if (loading) return <p>Cargando...</p>;
if (error) return <p>Error: {error}</p>;
if (!evento) return <p>No se encontró el evento</p>;
const handleDownload = async (
id_cuestionario: number,
nombre_formulario: string,
nombre_evento: string
) => {
setLoadingStates((prev) => ({ ...prev, [id_cuestionario]: true }));
try {
const res = await axiosInstance.get(
`/cuestionario-respondido/reporte-respuestas/${id_cuestionario}`,
{
responseType: 'blob',
}
);
downloadFile(res.data, `${nombre_evento} - ${nombre_formulario}`, 'csv');
} catch (error) {
console.error('Error al descargar el archivo:', error);
toast.error('Error al descargar el archivo');
} finally {
setLoadingStates((prev) => ({ ...prev, [id_cuestionario]: false }));
}
};
return (
<div className="my-4">
<Breadcrumb
items={[
{ label: 'Eventos', href: '/user/eventos' },
{
label: `${evento.nombre_evento}`,
href: `/user/evento/${params.id_evento}`,
},
]}
/>
<EditEvento
evento={evento}
handleChange={handleChange}
handleOnChange={handleEventoActualizado}
/>
{/* Carga masiva de alumnos o trabajadores */}
{tipo_usuario !== 'staff' && (
<div className="p-4 border rounded bg-light mb-4">
<h2 className="mb-1">Carga masiva de participantes</h2>
<p className="text-muted mb-3">
Sube un archivo <strong>.xlsx</strong> para registrar los
participantes que podrán inscribirse a este evento. Solo los
usuarios incluidos en la lista tendrán acceso al registro.
</p>
<div className="d-flex gap-3 align-items-end flex-wrap">
<div>
<label className="form-label mb-1">Tipo de participante</label>
<div className="d-flex gap-2">
<button
type="button"
className={`btn ${tipoCarga === 'alumnos' ? 'btn-primary' : 'btn-outline-primary'}`}
onClick={() => setTipoCarga('alumnos')}
>
<i className="bi bi-mortarboard me-2"></i>Alumnos
</button>
<button
type="button"
className={`btn ${tipoCarga === 'trabajadores' ? 'btn-primary' : 'btn-outline-primary'}`}
onClick={() => setTipoCarga('trabajadores')}
>
<i className="bi bi-person-badge me-2"></i>Trabajadores
</button>
</div>
</div>
<div className="flex-grow-1">
<label className="form-label mb-1">Archivo xlsx</label>
<input
type="file"
accept=".xlsx"
className="form-control"
onChange={(e) => setArchivoCarga(e.target.files?.[0] ?? null)}
/>
</div>
<Button
icon="upload"
variant="success"
onClick={handleCargaMasiva}
disabled={loadingCarga || !archivoCarga}
>
{loadingCarga ? 'Cargando...' : 'Cargar'}
</Button>
</div>
</div>
)}
{/* Validacion del tipo de usuario */}
{tipo_usuario !== 'staff' && (
<div className="p-4 border rounded bg-light">
<div className="d-flex justify-content-between align-items-center">
<div>
<h2 className="mb-0">Formularios del evento</h2>
<p className="mb-0 text-muted">
Aquí puedes ver y gestionar los formularios asociados a este
evento.
</p>
</div>
<Button
icon="plus"
variant="primary"
onClick={() =>
router.push(
`/user/evento/${params.id_evento}/formularios/crear`
)
}
>
Crear nuevo formulario
</Button>
</div>
</div>
)}
{evento?.cuestionarios && evento.cuestionarios.length > 0 && (
<div className="row mt-3">
{evento.cuestionarios.map((item, key) => {
const fadeClass = `delay-${(key % 5) + 1}`;
return (
<div
className={`col-md-6 col-lg-4 my-4 fade-in-up-bounce ${fadeClass}`}
key={key}
>
<FormularioCardAdmin
cuestionario={item}
evento={evento}
handleDownload={handleDownload}
disabled={loadingStates[item.id_cuestionario] || false}
/>
</div>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,133 @@
'use client'
import axiosInstance from "@/utils/api-config";
import React, { useState } from "react";
import { Button, Form, ProgressBar } from "react-bootstrap";
import { FaUpload } from "react-icons/fa";
export default function CargaMasiva() {
const [xlsx, setXlsx] = useState<File | null>(null);
const [progress, setProgress] = useState<number>(0);
const [loading, setLoading] = useState<boolean>(false);
const validarExt = (file: File | null) => {
if (!file) return;
const extPermitidas = /(.xlsx)$/i;
if (!extPermitidas.exec(file.name)) {
console.error("Asegúrate de ingresar un archivo .xlsx");
setXlsx(null);
} else {
setXlsx(file);
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] || null;
validarExt(file);
};
const enviarCargaMasiva = async () => {
if (!xlsx) return;
const formData = new FormData();
formData.append("file", xlsx);
try {
setLoading(true);
setProgress(0);
const res = await axiosInstance.post(
"/cuestionario/carga-masiva",
formData,
{
headers: { "Content-Type": "multipart/form-data" },
onUploadProgress: (event) => {
if (event.total) {
const porcentaje = Math.round(
(event.loaded * 100) / event.total
);
setProgress(porcentaje);
}
},
}
);
//Falta hacer validacion
console.log("Carga masiva exitosa", res.data);
setTimeout(() => {
setProgress(100);
setLoading(false);
}, 500);
} catch (error) {
console.error("Carga masiva fallida", error);
setLoading(false);
setProgress(0);
}
};
return (
<div className="container-fluid my-4">
<div className="row mb-4">
<div className="col-12">
<div className="p-4 border rounded bg-light">
<h2 className="mb-2">Carga Masiva de Eventos</h2>
<p className="mb-0 text-muted">
Sube tu archivo excel para poder crear los eventos.
</p>
</div>
</div>
</div>
<Form.Group className="mb-3">
<Form.Label>Archivo Excel</Form.Label>
<div
className="border p-4 text-center rounded"
style={{ cursor: "pointer" }}
onClick={() => document.getElementById("xlsxInput")?.click()}
>
<FaUpload size={40} className="mb-2" />
<p className="mb-1">
{xlsx?.name ||
"Arrastra aquí tu archivo o da click aquí para buscar"}
</p>
<p className="is-size-6">Tamaño máximo 20MB</p>
<p className="is-size-7">
Si al momento de elegir un archivo este no se selecciona,
haga click en cancelar en la ventana emergente e intente de
nuevo.
</p>
</div>
<input
id="xlsxInput"
type="file"
accept=".xlsx"
style={{ display: "none" }}
onChange={handleFileChange}
/>
</Form.Group>
<div className="d-flex gap-2 mt-3">
<Button
variant="outline-primary"
disabled={!xlsx || loading}
onClick={enviarCargaMasiva}
>
{loading ? "Subiendo..." : "Enviar archivo"}
</Button>
</div>
{/* Barra de progreso */}
{loading && (
<div className="mt-3">
<ProgressBar
now={progress}
label={`${progress}%`}
animated
striped
variant="info"
/>
</div>
)}
</div>
);
}
@@ -0,0 +1,161 @@
"use client";
import React, { useState } from "react";
import { Button, Image } from "react-bootstrap";
import { FaUpload } from "react-icons/fa";
import axiosInstance from "@/utils/api-config";
//Para poder subir las imagenes
interface UploadedImage {
originalName: string;
filename: string;
path: string;
}
export default function BannerUpload() {
const [files, setFiles] = useState<File[]>([]);
const [loading, setLoading] = useState(false);
const [uploaded, setUploaded] = useState<UploadedImage[]>([]);
const [progress, setProgress] = useState(0);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
setFiles(Array.from(e.target.files));
}
};
const handleUpload = async () => {
if (files.length === 0) {
alert("Selecciona al menos un archivo");
return;
}
const formData = new FormData();
files.forEach((file) => {
formData.append("banners", file);
});
try {
setLoading(true);
setProgress(0);
const res = await axiosInstance.post("/cuestionario/banners/bulk", formData, {
headers: { "Content-Type": "multipart/form-data" },
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
setProgress(
Math.round((progressEvent.loaded * 100) / progressEvent.total)
);
}
},
});
//Falta hacer validacion
setUploaded(res.data);
alert("Banners subidos correctamente ✅");
setFiles([]);
} catch (error) {
console.error(error);
alert(error || "Error al subir archivos");
} finally {
setLoading(false);
}
};
return (
<div className="container-fluid my-4">
<div className="row mb-4">
<div className="col-12">
<div className="p-4 border rounded bg-light">
<h2 className="mb-2">Subir Banners</h2>
<p className="mb-0 text-muted">
Arrastra o selecciona imágenes para subir tus banners (jpg, png, webp).
</p>
</div>
</div>
</div>
{/* Área Drag & Drop */}
<div
className="border p-4 text-center rounded"
style={{ cursor: "pointer" }}
onClick={() => document.getElementById("bannerInput")?.click()}
>
<FaUpload size={40} className="mb-2 text-muted" />
<p className="mb-1">
Arrastra aquí tus imágenes o haz clic para buscar
</p>
<p className="is-size-6">Tamaño máximo 20MB</p>
</div>
<input
id="bannerInput"
type="file"
accept=".jpg,.jpeg,.png,.webp"
multiple
style={{ display: "none" }}
onChange={handleFileChange}
/>
{/* Previsualización */}
{files.length > 0 && (
<div className="d-flex flex-wrap gap-3 mt-4">
{files.map((file, i) => (
<div
key={i}
className="border rounded overflow-hidden shadow-sm"
style={{ width: "350px", height: "350px" }}
>
<Image
src={URL.createObjectURL(file)}
alt={file.name}
className="img-fluid h-75 w-100 object-fit-cover"
/>
<p className="small text-center bg-light p-2 m-0">{file.name}</p>
</div>
))}
</div>
)}
{/* Botón */}
<div className="d-flex gap-2 mt-3">
<Button
variant="outline-primary"
disabled={loading}
onClick={handleUpload}
>
{loading ? "Subiendo..." : "Subir Banners"}
</Button>
</div>
{/* Barra de progreso */}
{loading && (
<div className="progress mt-3" style={{ height: "8px" }}>
<div
className="progress-bar progress-bar-striped bg-primary"
role="progressbar"
style={{ width: `${progress}%` }}
></div>
</div>
)}
{/* Archivos subidos */}
{uploaded.length > 0 && (
<div className="mt-4">
<h5 className="mb-2 text-muted">Archivos subidos:</h5>
<ul className="list-group">
{uploaded.map((file, i) => (
<li key={i} className="list-group-item d-flex justify-content-between align-items-center">
<span>{file.originalName}</span>
<a href={`/${file.path}`} target="_blank" className="text-primary">
Ver
</a>
</li>
))}
</ul>
</div>
)}
</div>
);
}
@@ -0,0 +1,128 @@
'use client';
import React, { useState } from 'react';
import { CreateEventoType } from '@/types/create-evento';
import CreateEvento from '@/containers/create-evento';
import EventoCardPreview from '@/components/evento/evento-card-preview';
import axiosInstance from '@/utils/api-config';
import toast from 'react-hot-toast';
import { useRouter } from 'next/navigation';
import { getAxiosError } from '@/utils/errors-utils';
import Button from '@/components/button';
export default function Page() {
const [eventoBanner, setEventoBanner] = useState<File | null>(null);
const [evento, setEvento] = useState<CreateEventoType>({
tipo_evento: '',
nombre_evento: '',
descripcion_evento: '',
fecha_inicio: new Date(),
fecha_fin: new Date(),
});
const router = useRouter();
const handleChange = (
field: keyof CreateEventoType,
value: string | Date
) => {
setEvento((prev) => ({
...prev,
[field]: value,
}));
};
const handleBannerChange = (file: File | null) => {
setEventoBanner(file);
};
const handleCrearEvento = async () => {
try {
const createEvento = await axiosInstance.post('/evento', evento);
if (createEvento) {
const formData = new FormData();
if (eventoBanner) {
formData.append('banner', eventoBanner);
const bannercreated = await axiosInstance.post(
`/evento/${createEvento.data.id_evento}/banner`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
);
console.log('Banner creado:', bannercreated.data);
}
}
toast.success('Evento y formulario creados exitosamente');
router.push(
`/user/evento/${createEvento.data.id_evento}/formularios/crear`
);
} catch (error) {
const msg = getAxiosError(error);
toast.error(
`Error al crear el evento o formulario: ${
msg.message || 'Error desconocido'
}`
);
console.error('Error al crear evento y formulario:', error);
}
};
return (
<div className="container-fluid my-4">
{/* Header */}
<div className="row mb-4">
<div className="col-12">
<div className="p-4 border rounded bg-light">
<h2 className="mb-2">Creación del Evento</h2>
<p className="mb-0 text-muted">
Completa la siguiente información para describir los detalles
generales del evento. Presiona siguiente para continuar con la
creación del formulario.
</p>
</div>
</div>
</div>
<div className="row">
{/* Columna Izquierda - Formulario */}
<div className="col-xl-7 col-lg-6">
<div className="pe-lg-4">
<CreateEvento
evento={evento}
handleChange={handleChange}
handleBannerChange={handleBannerChange}
/>
{/* Botón de guardar */}
<div className="d-flex justify-content-end">
<Button icon="arrow-right" onClick={handleCrearEvento}>
Siguiente
</Button>
</div>
</div>
</div>
{/* Columna Derecha - Preview */}
<div className="col-xl-5 col-lg-6">
<div className="ps-lg-4">
<div className="sticky-top" style={{ top: '20px' }}>
<h4 className="mb-3 d-none d-lg-block">Preview del Evento</h4>
<div className="d-lg-none mt-4">
<h4 className="mb-3">Preview del Evento</h4>
</div>
<EventoCardPreview evento={evento} eventoBanner={eventoBanner} />
<div className="alert alert-info text-center">
<strong>Nota:</strong> Esta preview es la que veras en tu ruta
de eventos y en la ruta publica de eventos
</div>
</div>
</div>
</div>
</div>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
'use client';
import EventoCardAdmin from '@/components/evento/evento-card-admin';
import FormularioCardAdmin from '@/components/formulario/formulario-card-admin';
import { useGetApi } from '@/hooks/use-get-api';
import { GetEventoWithCuestionariosWithCupos } from '@/types/evento';
import axiosInstance from '@/utils/api-config';
import { downloadFile } from '@/utils/downloas-utils';
import React, { useState } from 'react';
import toast from 'react-hot-toast';
export default function Page() {
const [loadingStates, setLoadingStates] = useState<Record<number, boolean>>(
{}
);
const {
loading,
data: activos,
error,
} = useGetApi<GetEventoWithCuestionariosWithCupos[]>(
'/evento/activos/cuestionarios'
);
const { data: recientes } = useGetApi<GetEventoWithCuestionariosWithCupos[]>(
'/evento/recientes/cuestionarios'
);
if (loading) return <div>Cargando formularios...</div>;
if (error) return <div className="alert alert-danger">{error.message}</div>;
const handleDownload = async (
id_cuestionario: number,
nombre_formulario: string,
nombre_evento: string
) => {
setLoadingStates((prev) => ({ ...prev, [id_cuestionario]: true }));
try {
const res = await axiosInstance.get(
`/cuestionario-respondido/reporte-respuestas/${id_cuestionario}`,
{
responseType: 'blob',
}
);
downloadFile(res.data, `${nombre_evento} - ${nombre_formulario}`, 'csv');
} catch (error) {
console.error('Error al descargar el archivo:', error);
toast.error('Error al descargar el archivo');
} finally {
setLoadingStates((prev) => ({ ...prev, [id_cuestionario]: false }));
}
};
return (
<div className="my-4">
{loading && <div>Cargando...</div>}
{activos && activos.length > 0 && (
<div className="row">
<h1>Eventos Activos</h1>
{activos.map((evento, key) => {
const fadeClass = `delay-${(key % 5) + 1}`;
return (
<div
className={`col-md-6 col-lg-4 my-4 fade-in-up-bounce ${fadeClass}`}
key={key}
>
<EventoCardAdmin evento={evento} />
</div>
);
})}
</div>
)}
{recientes && recientes.length > 0 && (
<div className="row">
<h2>Formularios Recientes</h2>
<p className="text-muted">Eventos de los ultimos 30 días.</p>
{recientes.map((evento) =>
evento.cuestionarios.map((cuestionario, cuestionarioIndex) => {
const fadeClass = `delay-${(cuestionarioIndex % 5) + 1}`;
return (
<div
className={`col-md-6 col-lg-4 my-4 fade-in-up-bounce ${fadeClass}`}
key={`${evento.id_evento}-${cuestionario.id_cuestionario}`}
>
<FormularioCardAdmin
cuestionario={cuestionario}
evento={evento}
handleDownload={handleDownload}
disabled={
loadingStates[cuestionario.id_cuestionario] || false
}
/>
</div>
);
})
)}
</div>
)}
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
'use client'
import dynamic from 'next/dynamic';
import Footer from '@/components/layout/footer';
import Header from '@/components/layout/header';
const Navbar = dynamic(() => import('@/components/navbar'), { ssr: false });
import React from 'react';
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="d-flex flex-column min-vh-100">
<Header small />
<Navbar />
<main className="container flex-grow-1">{children}</main>
<Footer />
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
'use client';
import SimpleInput from '@/components/input';
import axiosInstance from '@/utils/api-config';
import React, { useState } from 'react';
export default function Page() {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [response, setResponse] = useState<{
insertados: number;
omitidos: number;
} | null>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
setSelectedFile(e.target.files[0]);
}
};
const handleSubmit = async () => {
if (!selectedFile) return;
const formData = new FormData();
formData.append('file', selectedFile);
try {
const res = await axiosInstance.post('/trabajadores/cargar', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
setResponse(res.data);
} catch (error) {
console.error('Error al subir el archivo:', error);
}
};
return (
<div>
<h2>Subir Excel de Profesores</h2>
<SimpleInput type="file" onChange={handleFileChange} />
<button onClick={handleSubmit} disabled={!selectedFile}>
Enviar
</button>
{response && (
<div>
<h3>Resultado de la carga:</h3>
<p>Insertados: {response.insertados}</p>
<p>Omitidos: {response.omitidos}</p>
</div>
)}
</div>
);
}
+307
View File
@@ -0,0 +1,307 @@
'use client';
import React, { useState } from 'react';
import dynamic from 'next/dynamic';
import { Scanner } from '@yudiel/react-qr-scanner';
import Button from '@/components/button';
import axiosInstance from '@/utils/api-config';
import toast from 'react-hot-toast';
const Modal = dynamic(() => import('@/components/modal'), { ssr: false });
export default function Page() {
const [showModal, setShowModal] = useState(false);
const [enableScan, setEnableScan] = useState(true);
const [statusMessage, setStatusMessage] = useState('');
const [participante, setParticipante] = useState('');
const [contador, setContador] = useState(0);
const [message, setMessage] = useState('');
const [token, setToken] = useState('');
const [tokenValid, setTokenValid] = useState(false);
const handleScan = async (rawValue: string) => {
console.log('QR Escaneado:', rawValue);
if (!enableScan) return;
if (!rawValue) {
setStatusMessage('⚠️ El QR no contiene los campos requeridos');
return;
}
try {
const response = await axiosInstance.post(
'/participante-evento/decode-token',
{ token: rawValue }
);
if (response.data) {
// Token válido
setToken(rawValue);
setTokenValid(true);
setShowModal(true);
setEnableScan(false);
setContador(response.data.contador);
} else {
// Token caducado o cualquier otro error
setStatusMessage(`❌ Error el código qr ya caduco`);
setTokenValid(false);
setShowModal(true); // mostramos modal con el mensaje de error
setEnableScan(false);
}
} catch (err) {
console.error('Error al decodificar el QR:', err);
setStatusMessage('❌ Error al leer el QR');
setTokenValid(false);
setShowModal(true);
setEnableScan(false);
}
};
const handleConfirm = async () => {
if (!tokenValid) return;
try {
const response = await axiosInstance.post(
`/participante-evento/asistencia`,
{
token: token,
}
);
//Validacion
if (response.data.success === false) {
console.error('El usuario ya se registro');
setMessage(response.data.message);
toast.error('❌ Error el usaurio ya se registro previamente.');
} else {
setParticipante(response.data.data.participante);
setMessage(response.data.message);
console.log('Asistencia registrada:', response.data);
toast.success('✅ Asistencia registrada correctamente');
}
} catch (err) {
console.error('Error en la petición:', err);
setStatusMessage('❌ Error de red al registrar asistencia');
}
setShowModal(false);
setEnableScan(true);
};
return (
<main className="container-fluid flex-grow-1 d-flex flex-column justify-content-center py-4">
<div className="row justify-content-center">
<div className="col-12 col-md-8 col-lg-6 col-xl-5">
<div className="card shadow border-0 rounded-3">
<div className="card-header bg-primary text-white text-center py-4 border-0">
<div className="mb-3">
<i className="bi bi-qr-code-scan display-4"></i>
</div>
<h2 className="mb-2 fw-bold">Registro de Asistencia</h2>
<p className="mb-0 opacity-75">
Escanea el código QR para registrar la asistencia
</p>
</div>
<div className="card-body p-4">
{enableScan ? (
<div className="text-center">
<div className="mb-4 mx-auto position-relative d-inline-block">
<div className="border border-3 border-primary rounded-3 p-3 bg-light">
<Scanner
onScan={(codes) => {
if (codes.length > 0) {
handleScan(codes[0].rawValue);
}
}}
onError={(err) => console.error('QR Error:', err)}
constraints={{ facingMode: 'environment' }}
styles={{
container: {
width: '100%',
maxWidth: '300px',
borderRadius: '8px',
overflow: 'hidden',
},
}}
/>
</div>
</div>
<div className="mb-4">
<div className="alert alert-info border-0 rounded-3 d-flex align-items-center">
<i className="bi bi-info-circle-fill me-2"></i>
<small>
Mantén el código QR centrado en el marco de la cámara
</small>
</div>
</div>
{participante && (
<div className="mb-4">
<div className="alert alert-success border-0 rounded-3 d-flex align-items-center">
<i className="bi bi-person-check-fill me-2"></i>
<span>
Ultimo participante registrado: {participante}
</span>
</div>
</div>
)}
{message && (
<div className="mb-4">
<div className="alert alert-info border-0 rounded-3 d-flex align-items-center">
<i className="bi bi-info-circle-fill me-2"></i>
<span>{message}</span>
</div>
</div>
)}
<Button
onClick={() => setEnableScan(false)}
variant="outline-danger"
className="px-4 py-2 rounded-pill"
>
<i className="bi bi-camera-video-off me-2"></i>
Detener cámara
</Button>
</div>
) : (
<div className="text-center py-5">
<div className="mb-4">
<i
className="bi bi-camera-video-off-fill text-muted"
style={{ fontSize: '4rem' }}
></i>
</div>
<h4 className="text-muted mb-3">Cámara desactivada</h4>
<p className="text-muted mb-4 lead">
La cámara está detenida. Actívala para continuar escaneando
códigos QR.
</p>
<Button
onClick={() => setEnableScan(true)}
variant="primary"
className="px-4 py-2 rounded-pill"
>
<i className="bi bi-camera-video me-2"></i>
Activar cámara
</Button>
</div>
)}
{statusMessage && (
<div className="mt-4">
<div
className={`alert border-0 rounded-3 d-flex align-items-center ${
statusMessage.includes('❌')
? 'alert-danger'
: statusMessage.includes('⚠️')
? 'alert-warning'
: 'alert-success'
}`}
>
<div className="me-2">
{statusMessage.includes('❌') && (
<i className="bi bi-exclamation-triangle-fill"></i>
)}
{statusMessage.includes('⚠️') && (
<i className="bi bi-exclamation-circle-fill"></i>
)}
{!statusMessage.includes('❌') &&
!statusMessage.includes('⚠️') && (
<i className="bi bi-check-circle-fill"></i>
)}
</div>
<span>{statusMessage}</span>
</div>
</div>
)}
</div>
</div>
</div>
</div>
<Modal
isVisible={showModal}
size="md"
onClose={() => {
setShowModal(false);
setEnableScan(true);
setTokenValid(false);
}}
closeButton
className={{
content: 'border-0 rounded-3',
body: 'text-center',
}}
>
<div className="modal-header bg-success text-white border-0 rounded-top-3">
<h5 className="modal-title mb-0 fw-bold">
<i className="bi bi-check-circle-fill me-2"></i>
QR Escaneado Correctamente
</h5>
</div>
<div className="modal-body p-4">
{tokenValid ? (
// Modal de confirmación de asistencia
<>
<p className="text-muted mb-3">
Contador del usuario: <strong>{contador}</strong>
</p>
<div className="d-grid gap-2 d-md-flex justify-content-center">
<Button
variant="success"
onClick={handleConfirm}
className="px-4 py-2 rounded-pill fw-semibold"
>
<i className="bi bi-check2 me-2"></i>
Confirmar Asistencia
</Button>
<Button
variant="outline-secondary"
onClick={() => {
setShowModal(false);
setEnableScan(true);
setTokenValid(false);
setToken('');
}}
className="px-4 py-2 rounded-pill"
>
<i className="bi bi-x-lg me-2"></i>
Cancelar
</Button>
</div>
</>
) : (
// Modal de error
<div className="py-4">
<div className="mb-3">
<i
className="bi bi-exclamation-triangle-fill text-warning"
style={{ fontSize: '2.5rem' }}
></i>
</div>
<h6 className="text-danger mb-0">{statusMessage}</h6>
<div className="mt-3">
<Button
variant="secondary"
onClick={() => {
setShowModal(false);
setEnableScan(true);
setStatusMessage('');
}}
className="px-4 py-2 rounded-pill"
>
Cerrar
</Button>
</div>
</div>
)}
</div>
</Modal>
</main>
);
}
+352
View File
@@ -0,0 +1,352 @@
'use client';
import Button from '@/components/button';
import Table, { Header } from '@/components/table';
const Modal = dynamic(() => import('@/components/modal'), { ssr: false });
import { useGetApi } from '@/hooks/use-get-api';
import { Administrador } from '@/types/administradores';
import React, { useState } from 'react';
import axiosInstance from '@/utils/api-config';
import dynamic from 'next/dynamic';
export default function Page() {
const { data, setData } = useGetApi<Administrador[]>('/administrador');
// Estado del modal
const [isModalVisible, setIsModalVisible] = useState(false);
const [modalMode, setModalMode] = useState<'add' | 'edit'>('add');
const [selectedUser, setSelectedUser] = useState<Administrador | null>(null);
// Estado del modal de eliminación
const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
const [userToDelete, setUserToDelete] = useState<Administrador | null>(null);
// Estado del formulario
const [formData, setFormData] = useState({
nombre_usuario: '',
correo: '',
password: '',
id_tipo_user: '',
});
// Handlers del modal
const handleOpenAddModal = () => {
setModalMode('add');
setSelectedUser(null);
setFormData({
nombre_usuario: '',
correo: '',
password: '',
id_tipo_user: '',
});
setIsModalVisible(true);
};
const handleOpenEditModal = (user: Administrador) => {
setModalMode('edit');
setSelectedUser(user);
setFormData({
nombre_usuario: user.nombre_usuario,
correo: user.correo,
password: '',
id_tipo_user: user.tipoUser.id.toString(),
});
setIsModalVisible(true);
};
const handleCloseModal = () => {
setIsModalVisible(false);
setSelectedUser(null);
};
// Handlers del modal de eliminación
const handleOpenDeleteModal = (user: Administrador) => {
setUserToDelete(user);
setIsDeleteModalVisible(true);
};
const handleCloseDeleteModal = () => {
setIsDeleteModalVisible(false);
setUserToDelete(null);
};
const handleConfirmDelete = async () => {
// Aquí iría la lógica para eliminar el usuario
try {
if (userToDelete) {
// Lógica para eliminar el usuario
const response = await axiosInstance.delete(
`/administrador/${userToDelete.id_administrador}`
);
console.log('Usuario eliminado:', response.data);
// Actualizar la lista de usuarios en el estado
if (data) {
const updatedData = data.filter(
(user) => user.id_administrador !== userToDelete.id_administrador
);
setData(updatedData);
}
}
} catch (error) {
console.error('Error al eliminar el usuario:', error);
}
handleCloseDeleteModal();
};
// Handler para envío del formulario
const handleSubmitForm = async () => {
try {
if (modalMode === 'add') {
// Crear nuevo usuario
const payload = {
nombre_usuario: formData.nombre_usuario,
correo: formData.correo,
password: formData.password,
id_tipo_user: parseInt(formData.id_tipo_user),
};
const response = await axiosInstance.post('/administrador', payload);
console.log('Usuario creado:', response.data);
// Actualizar la lista de usuarios
if (data) {
setData([
...data,
{
correo: formData.correo,
id_administrador: response.data.id_administrador,
nombre_usuario: formData.nombre_usuario,
tipoUser: {
id: parseInt(formData.id_tipo_user),
tipo: formData.id_tipo_user === '1' ? 'Administrador' : 'Staff',
},
},
]);
}
} else if (modalMode === 'edit' && selectedUser) {
// Editar usuario existente
const payload = {
nombre_usuario: formData.nombre_usuario,
correo: formData.correo,
id_tipo_user: parseInt(formData.id_tipo_user),
};
const response = await axiosInstance.put(
`/administrador/${selectedUser.id_administrador}`,
payload
);
console.log('Usuario actualizado:', response.data);
// Actualizar la lista de usuarios
if (data) {
const updatedData = data.map((user) =>
user.id_administrador === selectedUser.id_administrador
? { ...user, ...response.data }
: user
);
setData(updatedData);
}
}
handleCloseModal();
} catch (error) {
console.error('Error al guardar el usuario:', error);
}
};
const headers: Header<Administrador>[] = [
{ key: 'id_administrador', label: 'ID' },
{ key: 'nombre_usuario', label: 'Nombre de Usuario' },
{ key: 'correo', label: 'Correo' },
{
key: 'tipoUser',
render: (_, row) => row.tipoUser.tipo,
label: 'Tipo de Usuario',
},
{
key: 'tipoUser',
render: (_, row) => (
<div className="d-flex gap-2">
<Button className="w-100" onClick={() => handleOpenEditModal(row)}>
Editar
</Button>
<Button
variant="danger"
className="w-100"
onClick={() => handleOpenDeleteModal(row)}
>
Eliminar
</Button>
</div>
),
label: 'Acciones',
},
];
return (
<div>
<div className="d-flex justify-content-between align-items-center my-3">
<h1>Administradores</h1>
<Button variant="primary" onClick={handleOpenAddModal}>
Agregar Administrador
</Button>
</div>
{data && <Table headers={headers} data={data} />}
<Modal
isVisible={isModalVisible}
onClose={handleCloseModal}
size="lg"
closeButton={true}
>
<div className="p-3">
<h3 className="mb-4">
{modalMode === 'add'
? 'Agregar Nuevo Administrador'
: 'Editar Administrador'}
</h3>
{modalMode === 'edit' && selectedUser && (
<div className="mb-3">
<p>
<strong>ID:</strong> {selectedUser.id_administrador}
</p>
<p>
<strong>Usuario actual:</strong> {selectedUser.nombre_usuario}
</p>
<p>
<strong>Correo actual:</strong> {selectedUser.correo}
</p>
<p>
<strong>Tipo:</strong> {selectedUser.tipoUser.tipo}
</p>
</div>
)}
<div className="mb-3">
<label htmlFor="nombre_usuario" className="form-label">
Nombre de Usuario
</label>
<input
type="text"
className="form-control"
id="nombre_usuario"
value={formData.nombre_usuario}
onChange={(e) =>
setFormData((prev) => ({
...prev,
nombre_usuario: e.target.value,
}))
}
placeholder="Ingrese el nombre de usuario"
/>
</div>
<div className="mb-3">
<label htmlFor="correo" className="form-label">
Correo Electrónico
</label>
<input
type="email"
className="form-control"
id="correo"
value={formData.correo}
onChange={(e) =>
setFormData((prev) => ({
...prev,
correo: e.target.value,
}))
}
placeholder="Ingrese el correo electrónico"
/>
</div>
{modalMode === 'add' && (
<div className="mb-3">
<label htmlFor="password" className="form-label">
Contraseña
</label>
<input
type="password"
className="form-control"
id="password"
value={formData.password}
onChange={(e) =>
setFormData((prev) => ({
...prev,
password: e.target.value,
}))
}
placeholder="Ingrese la contraseña"
/>
</div>
)}
<div className="mb-4">
<label htmlFor="tipoUser" className="form-label">
Tipo de Usuario
</label>
<select
className="form-select"
id="tipoUser"
value={formData.id_tipo_user}
onChange={(e) =>
setFormData((prev) => ({
...prev,
id_tipo_user: e.target.value,
}))
}
>
<option value="">Seleccione un tipo</option>
<option value="1">Administrador</option>
<option value="2">Staff</option>
</select>
</div>
<div className="d-flex gap-2 justify-content-end">
<Button variant="secondary" onClick={handleCloseModal}>
Cancelar
</Button>
<Button variant="primary" onClick={handleSubmitForm}>
{modalMode === 'add' ? 'Crear Administrador' : 'Guardar Cambios'}
</Button>
</div>
</div>
</Modal>
{/* Modal de confirmación de eliminación */}
<Modal
isVisible={isDeleteModalVisible}
onClose={handleCloseDeleteModal}
size="md"
closeButton={true}
>
<div className="p-4 text-center">
<div className="mb-4">
<i className="bi bi-exclamation-triangle text-warning display-1"></i>
</div>
<h4 className="mb-3">¿Está seguro de eliminar al usuario?</h4>
{userToDelete && (
<div className="mb-4">
<p className="fs-5 fw-bold text-danger">
{userToDelete.nombre_usuario}
</p>
<p className="text-muted">Esta acción no se puede deshacer</p>
</div>
)}
<div className="d-flex gap-3 justify-content-center">
<Button variant="secondary" onClick={handleCloseDeleteModal}>
Cancelar
</Button>
<Button variant="danger" onClick={handleConfirmDelete}>
, Eliminar
</Button>
</div>
</div>
</Modal>
</div>
);
}
@@ -1,5 +0,0 @@
import React from 'react';
export default function Page() {
return <div>Page</div>;
}
+168
View File
@@ -0,0 +1,168 @@
'use client';
import SimpleInput from '@/components/input';
import PasswordInput from '@/components/password';
import React from 'react';
import { useRouter } from 'next/navigation';
import axiosInstance from '@/utils/api-config';
export default function LoginForm() {
const router = useRouter();
const [loginData, setLoginData] = React.useState({
nombre_usuario: '',
password: '',
});
const [error, setError] = React.useState('');
const [isLoading, setIsLoading] = React.useState(false);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setLoginData((prevData) => ({ ...prevData, [name]: value }));
// Clear error when user starts typing
if (error) setError('');
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setError('');
try {
const response = await axiosInstance.post('/administrador/login', {
nombre_usuario: loginData.nombre_usuario,
password: loginData.password,
});
console.log(response)
// Guardar el token en sessionStorage
if (response.data.token) {
sessionStorage.setItem('token', response.data.token);
// Opcional: guardar información adicional del usuario si viene en la respuesta
if (response.data.tipo_usuario) {
sessionStorage.setItem('axxxd', JSON.stringify(response.data.tipo_usuario));
}
// Redireccionar al dashboard
router.push('/user/eventos');
} else {
setError('No se recibió token de autenticación');
}
} catch (error: unknown) {
console.error('Error en login:', error);
// Type guard para axios error
if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error as {
response?: { status?: number; data?: { message?: string } };
};
if (axiosError.response?.status === 401) {
setError('Credenciales incorrectas');
} else if (axiosError.response?.data?.message) {
setError(axiosError.response.data.message);
} else {
setError('Error al iniciar sesión. Inténtalo de nuevo.');
}
} else {
setError('Error al iniciar sesión. Inténtalo de nuevo.');
}
} finally {
setIsLoading(false);
}
};
return (
<div className="container my-5 my-md-0">
<div className="row justify-content-center">
<div className="col-xl-10">
<div className="card border-0">
<div className="card-body p-0">
<div className="row">
<div className="col-lg-6">
<div className="p-5">
<h3 className="fs-4 font-weight-bold text-azul mb-3 login">
Iniciar sesión
</h3>
<h6 className="h5 mb-0">
Bienvenido de vuelta al{' '}
<span className="text-dorado">Sistema de Eventos</span>
</h6>
<p className="text-muted mt-2 mb-4">
Ingresa tu correo electrónico y tu contraseña para acceder
a tu cuenta.
</p>
{/* Error message */}
{error && (
<div className="alert alert-danger mb-4">{error}</div>
)}
<form onSubmit={handleSubmit}>
<SimpleInput
label="Nombre de usuario"
type="text"
id="nombre_usuario"
name="nombre_usuario"
value={loginData.nombre_usuario}
onChange={handleChange}
required
/>
<PasswordInput
label="Contraseña"
id="password"
name="password"
value={loginData.password}
onChange={handleChange}
required
/>
<div className="justify-content-between align-items-center d-flex">
<button
type="submit"
className="btn btn-azul"
disabled={isLoading}
>
{isLoading ? (
<>
<span
className="spinner-border spinner-border-sm me-2"
role="status"
aria-hidden="true"
></span>
Ingresando...
</>
) : (
'Ingresar'
)}
</button>
</div>
</form>
</div>
</div>
<div className="col-lg-6 d-none d-lg-inline-block">
<div className="position-relative h-100 p-2">
<div
style={{
borderRadius: '0.5rem',
backgroundImage: 'url(/assets/image.png)',
backgroundSize: 'cover',
backgroundPosition: 'center',
height: '100%',
width: '100%',
}}
></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
-33
View File
@@ -1,33 +0,0 @@
'use client';
import Button from '@/components/button';
import Input from '@/components/input';
import { useRouter } from 'next/navigation';
import React from 'react';
export default function Page() {
const router = useRouter();
const handleOnSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
// Aquí puedes agregar la lógica para manejar el inicio de sesión
// Por ejemplo, validar las credenciales y redirigir al usuario
router.push('/staff');
};
return (
<div className='d-flex flex-column justify-content-center align-items-center'>
<h1 className='text-dorado'>Iniciar sesión</h1>
<h2 className='text-azul'>Staff</h2>
<form className='w-300px' onSubmit={handleOnSubmit}>
<Input label='Usuario' />
<Input type='password' label='Contraseña' />
<div className='mb-3'>
<Button className='w-100' type='submit'>
Iniciar sesión
</Button>
</div>
</form>
</div>
);
}
@@ -0,0 +1,70 @@
// src/app/(public)/evento/[evento]/[cuestionario]/page.tsx
import FormularioPage from '@/containers/pages/formulario-page';
import { GetEventoWithCuestionario } from '@/types/evento';
import { fromParam } from '@/utils/slugify';
import type { Metadata } from 'next';
type PageParams = {
evento: string; // "carrera-de-botargas-123"
cuestionario: string; // "formulario-inscripcion-456"
};
type Props = {
params: Promise<PageParams>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { evento, cuestionario } = await params; // <- await
const { id: id_evento } = fromParam(evento);
const { id: id_cuestionario } = fromParam(cuestionario);
if (!id_evento || !id_cuestionario) {
return { title: 'Evento', description: 'Descripción del evento' };
}
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/evento/${id_evento}/cuestionario/${id_cuestionario}`,
{ next: { revalidate: 60 } }
);
if (!res.ok) {
return { title: 'Evento', description: 'Descripción del evento' };
}
const data: GetEventoWithCuestionario = await res.json();
const title = data?.nombre_evento || 'Evento';
const description = data?.descripcion_evento || 'Descripción del evento';
const banner = data?.banner
? `${process.env.NEXT_PUBLIC_API_URL}/banners/${data.banner}`
: undefined;
return {
title,
description,
openGraph: {
title,
description,
images: banner
? [{ url: banner, width: 800, height: 400, alt: title }]
: undefined,
},
};
}
export default async function Page({ params }: Props) {
const { evento, cuestionario } = await params; // <- await
const { id: id_evento } = fromParam(evento);
const { id: id_cuestionario } = fromParam(cuestionario);
if (!id_evento || !id_cuestionario) {
// import { notFound } from 'next/navigation';
// return notFound();
}
return (
<FormularioPage
id_evento={String(id_evento)}
id_cuestionario={String(id_cuestionario)}
/>
);
}
+101 -55
View File
@@ -1,64 +1,110 @@
'use client';
import ClientCarousel from '@/client-components/client-carousel'; import ClientCarousel from '@/client-components/client-carousel';
import FormularioCard from '@/components/formulario-card'; import FormularioCardUser from '@/components/formulario/formulario-card-user';
import { GetCuestionario } from '@/types/cuestionario'; import EmptyEventsState from '@/components/empty-events-state';
import { useGetApi } from '@/hooks/use-get-api';
import { GetEventoWithCuestionariosWithCupos } from '@/types/evento';
import React from 'react'; import React from 'react';
async function getEventos(): Promise<GetCuestionario[]> { export default function Page() {
try { const { loading, data } = useGetApi<GetEventoWithCuestionariosWithCupos[]>(
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/cuestionario`, { '/evento/activos/cuestionarios'
method: 'GET', );
cache: 'no-store', // para que no lo cachee ni en build ni entre requests
headers: {
'Content-Type': 'application/json',
},
});
if (!res.ok) {
throw new Error(`Error ${res.status}: ${res.statusText}`);
}
const data: GetCuestionario[] = await res.json();
return data;
} catch (error) {
console.error('Error en getEventos:', error);
throw error;
}
}
export default async function Page() {
let eventos;
try {
eventos = await getEventos();
} catch (error) {
console.error('Error al cargar los eventos:', error);
return <div>Error al cargar los eventos</div>;
}
return ( return (
<> <div>
<div className="mx-auto my-5"> {data && data.length > 0 && (
<ClientCarousel /> <div className="mx-auto mb-5 mt-3 fade-in-down-bounce">
</div> <ClientCarousel
<div className="cards-1 mt-5"> images={
<div className="container"> data && data.length > 0
<div className="row"> ? data.map((evento) => ({
{eventos.map((cuestionario) => ( src: `${process.env.NEXT_PUBLIC_API_URL}/banners/${evento.banner}`,
<FormularioCard alt: `Banner de ${evento.nombre_evento}`,
key={cuestionario.id_cuestionario} }))
cuestionario={cuestionario} : [
link={`/registro/${cuestionario.id_cuestionario}`} {
/> src: '/default-banner.png',
))} alt: 'Banner de eventos activos',
{eventos.length === 0 && ( },
<p className="text-center text-muted"> ]
No hay formularios disponibles. }
</p> />
)} </div>
)}
{loading && (
<div className="text-center py-5 fade-in-scale">
<div className="d-flex justify-content-center align-items-center mb-3">
<div
className="spinner-border text-primary me-3"
role="status"
style={{ width: '2rem', height: '2rem' }}
>
<span className="visually-hidden">Cargando...</span>
</div>
<div>
<h5 className="mb-0 text-primary">Cargando eventos...</h5>
<small className="text-muted">
Esto solo tomará unos segundos
</small>
</div>
</div> </div>
</div> </div>
</div> )}
</>
{!loading && data && data.length === 0 && (
<div className="flex-grow-1 d-flex flex-column justify-content-center align-items-center">
<EmptyEventsState />
</div>
)}
{/*
{!loading && data && data.length > 0 && (
<div className="row">
{data.flatMap((evento) =>
evento.cuestionarios
// Filtramos solo los cuestionarios con id_cuestionario >= 148
.filter((cuestionario) => !cuestionario.id_cuestionario > 10 && !cuestionario.id_cuestionario < 148)
// Luego mapeamos para renderizarlos
.map((cuestionario, index) => (
<div
className={`col-md-6 col-lg-4 my-3 fade-in-up-bounce delay-${(index % 5) + 1}`}
key={`${evento.id_evento}-${cuestionario.id_cuestionario}`}
>
<FormularioCardUser
evento={evento}
formulario={cuestionario}
/>
</div>
))
)}
</div>
)}
*/}
{!loading && data && data.length > 0 && (
<div className="row">
{data.flatMap((evento) =>
evento.cuestionarios.map((cuestionario, index) => {
return (
<div
className={`col-md-6 col-lg-4 my-3 fade-in-up-bounce delay-${
(index % 5) + 1
}`}
key={`${evento.id_evento}-${cuestionario.id_cuestionario}`}
>
<FormularioCardUser
evento={evento}
formulario={cuestionario}
/>
</div>
);
})
)}
</div>
)}
</div>
); );
} }
@@ -1,270 +0,0 @@
'use client';
import Button from '@/components/button';
import CheckboxOptionGroup from '@/components/checkbox-option-group';
import Input from '@/components/input';
import RadioOptionGroup from '@/components/radio-option-group';
import { CuestionarioResponse } from '@/types/responder-formulario';
import axiosInstance from '@/utils/api-config';
import Image from 'next/image';
import { useParams, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
import toast from 'react-hot-toast';
export default function Page() {
const router = useRouter();
const params = useParams<{ id_formulario: string }>();
const id_formulario = params?.id_formulario;
const [cuestionario, setCuestionario] = useState<CuestionarioResponse | null>(
null
);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [respuestas, setRespuestas] = useState<
Record<number, string | number | Array<string | number>>
>({});
const [isSubmitting, setIsSubmitting] = useState(false); // New state for submission
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const { data } = await axiosInstance.get<CuestionarioResponse>(
`/cuestionario/${id_formulario}/formulario`
);
if (data) {
console.log(data);
setCuestionario(data);
} else {
setError('Error fetching data');
}
} catch (error) {
console.error('Error fetching data:', error);
setError('Error de conexión');
} finally {
setLoading(false);
}
};
if (id_formulario) fetchData();
}, [id_formulario]);
const handleInputChange = (id: number, value: string) => {
setRespuestas((prev) => ({ ...prev, [id]: value }));
};
const handleRadioChange = (id: number, opcion: { value: number }) => {
setRespuestas((prev) => ({ ...prev, [id]: opcion.value }));
};
const handleCheckboxChange = (id: number, opcion: { value: number }) => {
setRespuestas((prev) => {
const actuales: number[] = Array.isArray(prev[id])
? (prev[id] as number[])
: [];
const existe = actuales.includes(opcion.value);
const nuevos = existe
? actuales.filter((v: number) => v !== opcion.value)
: [...actuales, opcion.value];
return { ...prev, [id]: nuevos };
});
};
const formatearRespuestas = async () => {
if (!validarRespuestasObligatorias()) return;
const respuestasArray = Object.entries(respuestas).flatMap(
([id, valor]) => {
if (Array.isArray(valor)) {
return valor.map((v) => ({
id_pregunta: Number(id),
valor: v,
}));
}
return [{ id_pregunta: Number(id), valor }];
}
);
const correoEntry = respuestasArray.find(
(r) =>
typeof r.valor === 'string' &&
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(r.valor)
);
const resultado = {
id_formulario: Number(id_formulario),
correo: correoEntry?.valor || 'correo@no-encontrado.com',
respuestas: respuestasArray,
fecha_envio: new Date().toISOString(),
};
setIsSubmitting(true); // Set submitting state to true
toast
.promise(
axiosInstance.post('/cuestionario-respondido/submit', resultado),
{
loading: 'Enviando formulario...',
success: 'Formulario enviado con éxito!',
error: 'Error al enviar el formulario.',
}
)
.then(() => {
router.push('/');
})
.catch((error) => {
console.error('Error al enviar el formulario:', error);
})
.finally(() => {
setIsSubmitting(false); // Reset submitting state
});
};
const validarRespuestasObligatorias = (): boolean => {
const faltantes: number[] = [];
cuestionario?.cuestionario.secciones.forEach((seccion) => {
seccion.preguntas.forEach(({ pregunta }) => {
if (pregunta.obligatoria) {
const valor = respuestas[pregunta.id_pregunta];
const tipo = pregunta.tipo_pregunta.tipo_pregunta;
const estaRespondida =
(tipo === 'Abierta' &&
typeof valor === 'string' &&
valor.trim() !== '') ||
(tipo === 'Cerrada' && valor !== undefined) ||
(tipo === 'Multiple' && Array.isArray(valor) && valor.length > 0);
if (!estaRespondida) {
faltantes.push(pregunta.id_pregunta);
}
}
});
});
if (faltantes.length > 0) {
alert(`Responde todas las preguntas obligatorias antes de enviar.`);
return false;
}
return true;
};
if (loading) return <p>Cargando...</p>;
if (error) return <p className="text-danger">{error}</p>;
if (!cuestionario) return <p>No hay datos</p>;
return (
<div className="container py-4">
<Button icon="arrow-left" onClick={() => router.back()}>
Volver
</Button>
<div className="text-center mb-4">
<Image
src={'/banner1.png'}
width={1000}
height={300}
alt="Ejemplo de banner"
className="rounded-4 shadow-sm img-fluid"
style={{
objectFit: 'cover',
objectPosition: 'center',
height: 300,
}}
/>
</div>
<div className="px-lg-5 mx-lg-5">
<h2>{cuestionario.cuestionario.nombre_form}</h2>
<p>{cuestionario.cuestionario.descripcion}</p>
{cuestionario.cuestionario.secciones.map((seccion, index) => (
<section key={index} className="mb-4">
<h4>{seccion.seccion.titulo}</h4>
<p>{seccion.seccion.descripcion}</p>
{seccion.preguntas.map((p) => {
const pregunta = p.pregunta;
const id = pregunta.id_pregunta;
const tipo = pregunta.tipo_pregunta.tipo_pregunta;
if (tipo === 'Abierta') {
return (
<Input
key={id}
label={pregunta.pregunta}
name={`pregunta_${id}`}
value={
Array.isArray(respuestas[id])
? respuestas[id].join(', ')
: respuestas[id] || ''
}
onChange={(e) => handleInputChange(id, e.target.value)}
required={pregunta.obligatoria}
/>
);
}
if (tipo === 'Cerrada' || tipo === 'Multiple') {
const opciones = (pregunta.opciones ?? []).map((o) => ({
label: o.opcion.opcion,
value: o.opcion.id_opcion,
}));
if (tipo === 'Multiple') {
return (
<div key={id} className="mb-3">
<p>{pregunta.pregunta}</p>
<CheckboxOptionGroup
name={`pregunta_${id}`}
options={opciones}
selectedValues={
Array.isArray(respuestas[id]) ? respuestas[id] : []
}
onChange={(opcion) => {
if (typeof opcion.value === 'number') {
handleCheckboxChange(id, { value: opcion.value });
} else {
console.error('Invalid value type:', opcion.value);
}
}}
/>
</div>
);
}
return (
<div key={id} className="mb-3">
<p>{pregunta.pregunta}</p>
<RadioOptionGroup
name={`pregunta_${id}`}
options={opciones}
selectedValue={respuestas[id]}
onChange={(opcion) => {
if (typeof opcion.value === 'number') {
handleRadioChange(id, { value: opcion.value });
} else {
console.error('Invalid value type:', opcion.value);
}
}}
disabled={false}
/>
</div>
);
}
return null;
})}
</section>
))}
<button
type="button"
className="btn btn-primary"
onClick={formatearRespuestas}
disabled={isSubmitting} // Disable button while submitting
>
{isSubmitting ? 'Enviando...' : 'Enviar'}
</button>
</div>
</div>
);
}
+21 -2
View File
@@ -12,13 +12,32 @@ const roboto = Roboto({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Sistema de formularios", title: "Registros - Eventos FES Acatlán",
description: "", description: "Plataforma para consultar y registrarse a eventos organizados dentro de la FES Acatlán. Completa un formulario para confirmar tu asistencia.",
icons: { icons: {
icon: "/unam.png", icon: "/unam.png",
}, },
keywords: ["eventos", "FES Acatlán", "UNAM", "registro", "formulario", "asistencia", "registros"],
authors: [{ name: "FES Acatlán" }],
creator: "FES Acatlán",
metadataBase: new URL("https://registro.acatlan.unam.mx/"), // cámbialo por el dominio real
openGraph: {
title: "Registros - Eventos FES Acatlán",
description: "Consulta los eventos disponibles en la FES Acatlán y regístrate para asistir.",
url: "https://registro.acatlan.unam.mx/",
siteName: "Registros FES Acatlán",
images: [
{
url: "/ogimage.png",
alt: "Logo UNAM",
},
],
locale: "es_MX",
type: "website",
},
}; };
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
+21
View File
@@ -0,0 +1,21 @@
import Footer from '@/components/layout/footer';
import Header from '@/components/layout/header';
import Image from 'next/image';
import Link from 'next/link';
import React from 'react';
export default function Page() {
return (
<div className='d-flex flex-column min-vh-100'>
<Header small />
<div className="flex-grow-1 d-flex flex-column align-items-center justify-content-center">
<Image src={'/404.png'} width={350} height={350} alt="404" />
<p>
La página que buscas no existe. Por favor, verifica la URL o regresa
al <Link href="/">inicio</Link>
</p>
</div>
<Footer />
</div>
);
}
+3 -2
View File
@@ -1,10 +1,11 @@
'use client'; 'use client';
import { CarouselProps } from '@/components/carousel';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import React from 'react'; import React from 'react';
const Carousel = dynamic(() => import('@/components/carousel'), { const Carousel = dynamic(() => import('@/components/carousel'), {
ssr: false, ssr: false,
}); });
export default function ClientCarousel() { export default function ClientCarousel(props: CarouselProps) {
return <Carousel />; return <Carousel {...props} />;
} }
+81
View File
@@ -0,0 +1,81 @@
'use client';
import Image from 'next/image';
import React, { useRef, useState } from 'react';
import Button from './button';
interface BannerUploaderProps {
label?: string;
onChange?: (file: File | null) => void;
defaultBanner?: string;
className?: string;
showPreview?: boolean;
}
export default function ImageUploader({
label = 'Subir imagen',
onChange,
defaultBanner,
className = '',
showPreview = true,
}: BannerUploaderProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [preview, setPreview] = useState<string | null>(defaultBanner || null);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] || null;
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
setPreview(reader.result as string);
};
reader.readAsDataURL(file);
} else {
setPreview(null);
}
onChange?.(file);
};
const handleRemoveImage = () => {
setPreview(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
onChange?.(null);
};
return (
<div className={className}>
{label && <label className="block mb-2 font-semibold">{label}</label>}
<div className="d-flex gap-2 align-items-start mb-2">
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
className="form-control"
/>
{preview && (
<Button
variant="danger"
onClick={handleRemoveImage}
className="flex-shrink-0"
>
<i className="bi bi-trash"></i>
</Button>
)}
</div>
{preview && showPreview && (
<div className="d-flex justify-content-center">
<Image
src={preview}
alt="Previsualización"
className="img-fluid rounded"
width={600}
height={300}
/>
</div>
)}
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import Link from 'next/link';
import React from 'react';
interface BreadcrumbItem {
label: string;
href?: string;
}
interface BreadcrumbProps {
items: BreadcrumbItem[];
}
export default function Breadcrumb({ items }: BreadcrumbProps) {
return (
<nav aria-label="breadcrumb" className="mb-3">
<ol className="breadcrumb">
{items.map((item, index) => {
const isLast = index === items.length - 1;
return (
<li
key={index}
className={`breadcrumb-item ${isLast ? 'active' : ''}`}
aria-current={isLast ? 'page' : undefined}
>
{isLast ? (
item.label
) : (
<Link href={item.href || '#'} className="text-decoration-none">
{item.label}
</Link>
)}
</li>
);
})}
</ol>
</nav>
);
}
+9 -16
View File
@@ -1,7 +1,7 @@
import Image from 'next/image'; import Image from 'next/image';
import React from 'react'; import React from 'react';
interface CarouselProps { export interface CarouselProps {
id?: string; id?: string;
images?: { images?: {
src: string; src: string;
@@ -10,20 +10,11 @@ interface CarouselProps {
} }
export default function Carousel({ export default function Carousel({
id = 'carouselExample', id = 'carousel-banners-eventos',
images = [ images,
{
src: '/banner1.png',
alt: 'Imagen de ejemplo',
},
{
src: '/banner2.png',
alt: 'Imagen de ejemplo',
},
],
}: CarouselProps) { }: CarouselProps) {
if (!images || images.length === 0) { if (!images || images.length === 0) {
return <div>No hay imágenes para mostrar</div>; return <div></div>;
} }
return ( return (
@@ -35,14 +26,16 @@ export default function Carousel({
className={`carousel-item ${index === 0 ? 'active' : ''}`} className={`carousel-item ${index === 0 ? 'active' : ''}`}
> >
<Image <Image
src={img.src} src={img.src ? img.src : '/default-banner.png'}
width={1000} width={1200}
height={400} height={400}
alt="Ejemplo de banner" alt="Ejemplo de banner"
className="d-block w-100 rounded-3" className="d-block w-100 rounded-3 img-fluid"
style={{ style={{
height: '400px',
objectFit: 'cover', objectFit: 'cover',
objectPosition: 'center', objectPosition: 'center',
maxHeight: '400px',
}} }}
/> />
</div> </div>
+106
View File
@@ -0,0 +1,106 @@
'use client';
import { GetEventoWithCuestionariosWithCupos } from '@/types/evento';
import Image from 'next/image';
import Link from 'next/link';
import React, { useState } from 'react';
import MarkdownRenderer from './markdown-render';
export default function CuestionarioCard({
evento,
user = 'public',
}: {
evento: GetEventoWithCuestionariosWithCupos;
user?: 'public' | 'administrador' | 'staff';
}) {
const [verMas, setVerMas] = useState(false);
const descripcion = evento.descripcion_evento ?? '';
const limite = 100;
const descripcionRecortada =
descripcion.length > limite && !verMas
? `${descripcion.slice(0, limite)}...`
: descripcion;
return (
<div className="card card-blog d-flex flex-column justify-content-between">
<div>
<div className="card-image scale-hover-1">
<Link
href={
user === 'administrador' || user === 'staff'
? `/${user}/evento/${evento.id_evento}`
: `/evento/${evento.nombre_evento.split(' ').join('_')}/${
evento.id_evento
}/${evento.cuestionarios[0].id_cuestionario}`
}
>
<Image
width={400}
height={300}
className="img-fluid"
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${evento.banner}`}
alt="Banner formulario"
/>
<div className="card-caption">{evento.nombre_evento}</div>
</Link>
<div className="ripple-cont"></div>
</div>
<div className="card-body">
<div className="card-description mb-0">
{evento.cuestionarios[0]?.cupo_maximo === null ? (
<span className="badge bg-success mb-2">Sin límite</span>
) : (
<span className="badge bg-primary mb-2">
{evento.cuestionarios[0]?.cupos_disponibles} cupos disponibles
</span>
)}
<MarkdownRenderer markdown={descripcionRecortada} />
{descripcion.length > limite && (
<button
onClick={() => setVerMas(!verMas)}
className="btn btn-link btn-sm p-0 ms-1 align-baseline"
style={{ fontSize: '0.875rem' }}
>
{verMas ? 'Ver menos' : 'Ver más'}
</button>
)}
</div>
{evento.cuestionarios.length > 0 &&
evento.cuestionarios.map((cuestionario) => (
<div key={cuestionario.id_cuestionario} className="">
{user === 'administrador' || user === 'staff' ? (
<Link
href={`/${user}/evento/${evento.id_evento}`}
className="btn mt-2 w-100 btn-outline-primary"
>
Ver evento
</Link>
) : cuestionario.cupos_disponibles === 0 &&
cuestionario.cupo_maximo !== null ? (
<button
className="btn mt-2 w-100 btn-outline-secondary"
disabled
>
Cupo lleno
</button>
) : (
<Link
href={`/evento/${evento.nombre_evento
.split(' ')
.join('_')}/${evento.id_evento}/${
cuestionario.id_cuestionario
}`}
className="btn mt-2 w-100 btn-outline-primary"
>
Registro
</Link>
)}
</div>
))}
</div>
</div>
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
'use client';
import React from 'react';
import Button from './button';
export default function EmptyEventsState() {
return (
<div className="container py-5">
<div className="row justify-content-center">
<div className="col-lg-8">
<div className="text-center fade-in-scale">
{/* Ilustración principal con Bootstrap */}
<div className="mb-2">
<div
className="d-inline-flex align-items-center justify-content-center bg-dorado bg-opacity-10 rounded-circle mb-4"
style={{ width: '150px', height: '150px' }}
>
<i className="bi bi-calendar-x text-dorado display-1"></i>
</div>
</div>
{/* Título y mensaje principal */}
<div className="mb-5">
<h1 className="display-5 fw-bold text-azul mb-3 fade-in-up delay-1">
No hay eventos disponibles
</h1>
<p className="lead text-muted mb-0 fade-in-up delay-2">
En este momento no tenemos eventos activos para mostrar.
<br className="d-none d-md-block" />
<span className="text-azul fw-semibold">
¡No te preocupes!
</span>{' '}
Pronto habrá nuevas oportunidades de participación.
</p>
</div>
{/* Botón de acción usando clases Bootstrap */}
<div className="fade-in-up delay-4">
<Button
icon="bi bi-arrow-clockwise"
onClick={() => window.location.reload()}
size="lg"
className="rounded-pill px-5"
>
Actualizar pagina
</Button>
</div>
{/* Mensaje adicional */}
<div className="mt-4 fade-in-up delay-5">
<small className="text-muted">
<i className="bi bi-info-circle me-1"></i>
Esta página se actualiza automáticamente para mostrar los
eventos más recientes
</small>
</div>
</div>
</div>
</div>
</div>
);
}
+166
View File
@@ -0,0 +1,166 @@
import { GetEventoWithCuestionariosWithCupos } from '@/types/evento';
import Image from 'next/image';
import Link from 'next/link';
import React, { useState } from 'react';
import MarkdownRenderer from './markdown-render';
import { formatearRangoFechas } from '@/utils/date-utils';
export default function EventoCard({
evento,
user = 'public',
}: {
evento: GetEventoWithCuestionariosWithCupos;
user?: 'public' | 'administrador' | 'staff';
}) {
const [verMas, setVerMas] = useState(false);
const descripcion = evento.descripcion_evento ?? '';
const limite = 100;
const descripcionRecortada =
descripcion.length > limite && !verMas
? `${descripcion.slice(0, limite)}...`
: descripcion;
return (
<div className="card card-blog d-flex flex-column justify-content-between">
<div>
<div className="card-image scale-hover-1">
<Link
href={
user === 'administrador' || user === 'staff'
? `/${user}/evento/${evento.id_evento}`
: `/evento/${evento.nombre_evento.split(' ').join('_')}/${
evento.id_evento
}/${evento.cuestionarios[0].id_cuestionario}`
}
>
<Image
width={400}
height={300}
className="img-fluid"
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${evento.banner}`}
alt="Banner formulario"
/>
<div className="card-caption">{evento.nombre_evento}</div>
</Link>
</div>
<div className="card-body">
<div className="card-description mb-0">
<MarkdownRenderer markdown={descripcionRecortada} />
{descripcion.length > limite && (
<button
onClick={() => setVerMas(!verMas)}
className="btn btn-link btn-sm p-0 ms-1 align-baseline"
style={{ fontSize: '0.875rem' }}
>
{verMas ? 'Ver menos' : 'Ver más'}
</button>
)}
</div>
{evento.cuestionarios.length > 0 ? (
<>
<h2 className="my-2 fs-3">Formularios</h2>
<table className="table table-white">
<thead>
<tr>
<th scope="col">Nombre</th>
<th scope="col">Inscritos</th>
<th scope="col">Capacidad total</th>
</tr>
</thead>
<tbody>
{evento.cuestionarios.map((cuestionario) => (
<tr key={cuestionario.id_cuestionario}>
<td>
<Link
href={
user === 'administrador' || user === 'staff'
? `/${user}/evento/${evento.id_evento}/formulario/${cuestionario.id_cuestionario}`
: `/evento/${evento.nombre_evento
.split(' ')
.join('_')}/${evento.id_evento}/${
cuestionario.id_cuestionario
}`
}
>
{cuestionario.nombre_form}
</Link>
</td>
{cuestionario.cupo_maximo ? (
<>
<td>{cuestionario.cupos_usados}</td>
<td>{cuestionario.cupo_maximo}</td>
</>
) : (
<td colSpan={2} className="text-center">
Sin límite
</td>
)}
</tr>
))}
</tbody>
</table>
</>
) : (
<div className="text-center px-4 py-3 border rounded bg-light my-3">
<i className="bi bi-clipboard-x fs-1 text-muted mb-3"></i>
<p className="text-muted mb-3">
Este evento no tiene formularios disponibles.
</p>
{(user === 'administrador' || user === 'staff') && (
<Link
href={`/${user}/evento/${evento.id_evento}/formularios/crear`}
className="btn btn-primary"
>
<i className="bi bi-plus-circle me-2"></i>
Crear formulario
</Link>
)}
</div>
)}
<span className="badge fs-6 w-100 bg-primary">
Evento {formatearRangoFechas(evento.fecha_inicio, evento.fecha_fin)}
</span>
{/* Botón único del evento */}
<div className="mt-2">
{user === 'administrador' || user === 'staff' ? (
<Link
href={`/${user}/evento/${evento.id_evento}`}
className="btn w-100 btn-outline-primary"
>
Ver/Editar evento
</Link>
) : evento.cuestionarios.length === 0 ? (
<button className="btn w-100 btn-outline-secondary" disabled>
Sin formularios disponibles
</button>
) : evento.cuestionarios.some(
(cuestionario) =>
cuestionario.cupos_disponibles === 0 &&
cuestionario.cupo_maximo !== null
) &&
evento.cuestionarios.every(
(cuestionario) =>
cuestionario.cupos_disponibles === 0 &&
cuestionario.cupo_maximo !== null
) ? (
<button className="btn w-100 btn-outline-secondary" disabled>
Cupo lleno
</button>
) : (
<Link
href={`/evento/${evento.nombre_evento.split(' ').join('_')}/${
evento.id_evento
}/${evento.cuestionarios[0]?.id_cuestionario || ''}`}
className="btn w-100 btn-outline-primary"
>
Registro
</Link>
)}
</div>
</div>
</div>
</div>
);
}
+153
View File
@@ -0,0 +1,153 @@
import { GetEventoWithCuestionariosWithCupos } from '@/types/evento';
import Image from 'next/image';
import Link from 'next/link';
import React, { useState } from 'react';
import MarkdownRenderer from '../markdown-render';
import { formatearFechaCard } from '@/utils/date-utils';
export default function EventoCardAdmin({
evento,
}: {
evento: GetEventoWithCuestionariosWithCupos;
}) {
const [verMas, setVerMas] = useState(false);
const descripcion = evento.descripcion_evento ?? '';
const limite = 100;
const descripcionRecortada =
descripcion.length > limite && !verMas
? `${descripcion.slice(0, limite)}...`
: descripcion;
// Obtener información de fecha usando la utilidad
const { mesTexto, diaTexto } = formatearFechaCard(
evento.fecha_inicio,
evento.fecha_fin
);
return (
<div
className="card shadow-sm border-0"
style={{ borderRadius: '12px', overflow: 'hidden' }}
>
<div className="position-relative">
<Link href={`/user/evento/${evento.id_evento}`}>
<Image
width={400}
height={250}
className="img-fluid w-100"
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${evento.banner}`}
alt="Banner formulario"
style={{ objectFit: 'cover', height: '200px' }}
/>
</Link>
{/* Badge de cantidad de formularios en la esquina superior derecha */}
<div className="position-absolute top-0 end-0 m-2">
{evento.cuestionarios.length > 0 ? (
<span className="badge bg-primary">
{evento.cuestionarios.length} formulario
{evento.cuestionarios.length !== 1 ? 's' : ''}
</span>
) : (
<span className="badge bg-secondary">Sin formularios</span>
)}
</div>
</div>
<div className="card-body p-3 d-flex flex-column">
{/* Fecha del evento */}
<div className="d-flex align-items-center mb-2">
<div className="me-3" style={{ minWidth: '50px' }}>
<div
className="text-muted text-center small"
style={{
fontSize: mesTexto.includes(' - ') ? '0.65rem' : '0.75rem',
lineHeight: 1.1,
}}
>
{mesTexto}
</div>
<div
className="fw-bold mb-0 text-center"
style={{
lineHeight: 1,
whiteSpace: 'nowrap',
fontSize: diaTexto.toString().length > 2 ? '1rem' : '1.25rem',
}}
>
{diaTexto}
</div>
</div>
<div>
<h5 className="card-title mb-1 fw-bold">{evento.nombre_evento}</h5>
</div>
</div>
{/* Descripción */}
<div className="card-description flex-grow-1 mb-3">
<MarkdownRenderer markdown={descripcionRecortada} />
{descripcion.length > limite && (
<button
onClick={() => setVerMas(!verMas)}
className="btn btn-link btn-sm p-0 ms-1 align-baseline text-decoration-none"
style={{ fontSize: '0.875rem' }}
>
{verMas ? 'Ver menos' : 'Ver más'}
</button>
)}
</div>
{/* Formularios/sub eventos */}
{evento.cuestionarios.length > 0 ? (
<div className="mb-3">
<h6 className="fw-bold mb-2 text-muted">
Formularios disponibles:
</h6>
<div className="row row-cols-1 g-2">
{evento.cuestionarios.slice(0, 3).map((cuestionario) => (
<div key={cuestionario.id_cuestionario} className="col">
<div className="py-2 px-3 bg-light border-0">
<div className="d-flex justify-content-between align-items-center">
<Link
href={`/user/evento/${evento.id_evento}/formulario/${cuestionario.id_cuestionario}`}
className="text-decoration-none fw-semibold"
>
{cuestionario.nombre_form}
</Link>
<small className="text-muted">
{cuestionario.cupo_maximo
? `${cuestionario.cupos_usados}/${cuestionario.cupo_maximo}`
: 'Sin límite'}
</small>
</div>
</div>
</div>
))}
</div>
</div>
) : (
<div className="text-center px-3 py-3 bg-light rounded mb-3">
<i className="bi bi-clipboard-x fs-4 text-muted mb-2 d-block"></i>
<p className="text-muted mb-2 small">Sin formularios disponibles</p>
<Link
href={`/user/evento/${evento.id_evento}/formularios/crear`}
className="btn btn-sm btn-outline-primary"
>
<i className="bi bi-plus-circle me-1"></i>
Crear formulario
</Link>
</div>
)}
{/* Botón principal */}
<Link
href={`/user/evento/${evento.id_evento}`}
className="btn btn-primary w-100 rounded-pill"
>
Ver/Editar evento
</Link>
</div>
</div>
);
}
@@ -0,0 +1,161 @@
'use client';
import { CreateEventoType } from '@/types/create-evento';
import Image from 'next/image';
import React, { useState, useEffect } from 'react';
import MarkdownRenderer from '@/components/markdown-render';
import { formatearFechaCard } from '@/utils/date-utils';
import Button from '../button';
interface EventoPreviewProps {
evento: CreateEventoType;
eventoBanner?: File | null;
existingBanner?: string | null; // Banner existente desde la API
cuestionariosCount?: number; // Número de formularios existentes
}
export default function EventoCardPreview({
evento,
eventoBanner,
existingBanner,
cuestionariosCount = 0,
}: EventoPreviewProps) {
const [verMas, setVerMas] = useState(false);
const [bannerUrl, setBannerUrl] = useState<string>('/default-banner.png');
const descripcion = evento.descripcion_evento ?? '';
const limite = 100;
const descripcionRecortada =
descripcion.length > limite && !verMas
? `${descripcion.slice(0, limite)}...`
: descripcion;
// Actualizar URL del banner cuando cambie el archivo o cuando haya un banner existente
useEffect(() => {
if (eventoBanner) {
// Si hay un nuevo banner seleccionado, usarlo
const url = URL.createObjectURL(eventoBanner);
setBannerUrl(url);
// Limpiar URL anterior cuando el componente se desmonte
return () => URL.revokeObjectURL(url);
} else if (existingBanner) {
// Si no hay nuevo banner pero sí hay uno existente desde la API, usarlo
setBannerUrl(
`${process.env.NEXT_PUBLIC_API_URL}/banners/${existingBanner}`
);
} else {
// Si no hay ningún banner, usar el banner por defecto
setBannerUrl('/default-banner.png');
}
}, [eventoBanner, existingBanner]);
// Obtener información de fecha usando la utilidad
const getDateInfo = () => {
if (!evento.fecha_inicio || !evento.fecha_fin) {
return { mesTexto: 'MES', diaTexto: 'DD' };
}
return formatearFechaCard(evento.fecha_inicio, evento.fecha_fin);
};
const { mesTexto, diaTexto } = getDateInfo();
return (
<div
className="card shadow-sm border-0 mb-2"
style={{ borderRadius: '12px', overflow: 'hidden' }}
>
<div className="position-relative">
<Image
width={400}
height={250}
className="img-fluid w-100"
src={bannerUrl}
alt="Banner formulario"
style={{ objectFit: 'cover', height: '200px' }}
/>
{/* Badge de cantidad de formularios en la esquina superior derecha */}
<div className="position-absolute top-0 end-0 m-2">
{cuestionariosCount > 0 ? (
<span className="badge bg-primary">
{cuestionariosCount} formulario
{cuestionariosCount !== 1 ? 's' : ''}
</span>
) : (
<span className="badge bg-secondary">Sin formularios</span>
)}
</div>
</div>
<div className="card-body p-3 d-flex flex-column">
{/* Fecha del evento */}
<div className="d-flex align-items-center mb-2">
<div className="me-3" style={{ minWidth: '50px' }}>
<div
className="text-muted text-center small"
style={{
fontSize: mesTexto.includes(' - ') ? '0.65rem' : '0.75rem',
lineHeight: 1.1,
}}
>
{mesTexto}
</div>
<div
className="fw-bold mb-0 text-center"
style={{
lineHeight: 1,
whiteSpace: 'nowrap',
fontSize: diaTexto.toString().length > 2 ? '1rem' : '1.25rem',
}}
>
{diaTexto}
</div>
</div>
<div>
<h5 className="card-title mb-1 fw-bold">
{evento.nombre_evento || 'Nombre del evento'}
</h5>
</div>
</div>
{/* Tipo de evento */}
{evento.tipo_evento && (
<div className="mb-2">
<small className="text-muted">
<i className="bi bi-tag-fill me-1"></i>
{evento.tipo_evento}
</small>
</div>
)}
{/* Descripción */}
<div className="card-description flex-grow-1 mb-3">
{descripcion ? (
<>
<MarkdownRenderer markdown={descripcionRecortada} />
{descripcion.length > limite && (
<button
onClick={() => setVerMas(!verMas)}
className="btn btn-link btn-sm p-0 ms-1 align-baseline text-decoration-none"
style={{ fontSize: '0.875rem' }}
>
{verMas ? 'Ver menos' : 'Ver más'}
</button>
)}
</>
) : (
<p className="text-muted small">Descripción del evento...</p>
)}
</div>
{/* Botón principal - deshabilitado */}
<Button variant="primary" className="w-100 rounded-pill" disabled>
Ver/Editar evento
</Button>
</div>
</div>
);
}
+166
View File
@@ -0,0 +1,166 @@
import { GetEventoWithCuestionariosWithCupos } from '@/types/evento';
import Image from 'next/image';
import Link from 'next/link';
import React, { useState } from 'react';
import MarkdownRenderer from '../markdown-render';
import { formatearRangoFechas } from '@/utils/date-utils';
export default function EventoCard({
evento,
user = 'public',
}: {
evento: GetEventoWithCuestionariosWithCupos;
user?: 'public' | 'administrador' | 'staff';
}) {
const [verMas, setVerMas] = useState(false);
const descripcion = evento.descripcion_evento ?? '';
const limite = 100;
const descripcionRecortada =
descripcion.length > limite && !verMas
? `${descripcion.slice(0, limite)}...`
: descripcion;
return (
<div className="card card-blog d-flex flex-column justify-content-between">
<div>
<div className="card-image scale-hover-1">
<Link
href={
user === 'administrador' || user === 'staff'
? `/${user}/evento/${evento.id_evento}`
: `/evento/${evento.nombre_evento.split(' ').join('_')}/${
evento.id_evento
}/${evento.cuestionarios[0].id_cuestionario}`
}
>
<Image
width={400}
height={300}
className="img-fluid"
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${evento.banner}`}
alt="Banner formulario"
/>
<div className="card-caption">{evento.nombre_evento}</div>
</Link>
</div>
<div className="card-body">
<div className="card-description mb-0">
<MarkdownRenderer markdown={descripcionRecortada} />
{descripcion.length > limite && (
<button
onClick={() => setVerMas(!verMas)}
className="btn btn-link btn-sm p-0 ms-1 align-baseline"
style={{ fontSize: '0.875rem' }}
>
{verMas ? 'Ver menos' : 'Ver más'}
</button>
)}
</div>
{evento.cuestionarios.length > 0 ? (
<>
<h2 className="my-2 fs-3">Formularios</h2>
<table className="table table-white">
<thead>
<tr>
<th scope="col">Nombre</th>
<th scope="col">Inscritos</th>
<th scope="col">Capacidad total</th>
</tr>
</thead>
<tbody>
{evento.cuestionarios.map((cuestionario) => (
<tr key={cuestionario.id_cuestionario}>
<td>
<Link
href={
user === 'administrador' || user === 'staff'
? `/${user}/evento/${evento.id_evento}/formulario/${cuestionario.id_cuestionario}`
: `/evento/${evento.nombre_evento
.split(' ')
.join('_')}/${evento.id_evento}/${
cuestionario.id_cuestionario
}`
}
>
{cuestionario.nombre_form}
</Link>
</td>
{cuestionario.cupo_maximo ? (
<>
<td>{cuestionario.cupos_usados}</td>
<td>{cuestionario.cupo_maximo}</td>
</>
) : (
<td colSpan={2} className="text-center">
Sin límite
</td>
)}
</tr>
))}
</tbody>
</table>
</>
) : (
<div className="text-center px-4 py-3 border rounded bg-light my-3">
<i className="bi bi-clipboard-x fs-1 text-muted mb-3"></i>
<p className="text-muted mb-3">
Este evento no tiene formularios disponibles.
</p>
{(user === 'administrador' || user === 'staff') && (
<Link
href={`/${user}/evento/${evento.id_evento}/formularios/crear`}
className="btn btn-primary"
>
<i className="bi bi-plus-circle me-2"></i>
Crear formulario
</Link>
)}
</div>
)}
<span className="badge fs-6 w-100 bg-primary">
Evento {formatearRangoFechas(evento.fecha_inicio, evento.fecha_fin)}
</span>
{/* Botón único del evento */}
<div className="mt-2">
{user === 'administrador' || user === 'staff' ? (
<Link
href={`/${user}/evento/${evento.id_evento}`}
className="btn w-100 btn-outline-primary"
>
Ver/Editar evento
</Link>
) : evento.cuestionarios.length === 0 ? (
<button className="btn w-100 btn-outline-secondary" disabled>
Sin formularios disponibles
</button>
) : evento.cuestionarios.some(
(cuestionario) =>
cuestionario.cupos_disponibles === 0 &&
cuestionario.cupo_maximo !== null
) &&
evento.cuestionarios.every(
(cuestionario) =>
cuestionario.cupos_disponibles === 0 &&
cuestionario.cupo_maximo !== null
) ? (
<button className="btn w-100 btn-outline-secondary" disabled>
Cupo lleno
</button>
) : (
<Link
href={`/evento/${evento.nombre_evento.split(' ').join('_')}/${
evento.id_evento
}/${evento.cuestionarios[0]?.id_cuestionario || ''}`}
className="btn w-100 btn-outline-primary"
>
Registro
</Link>
)}
</div>
</div>
</div>
</div>
);
}
+50 -28
View File
@@ -4,13 +4,21 @@ import Link from 'next/link';
import { GetCuestionario } from '@/types/cuestionario'; import { GetCuestionario } from '@/types/cuestionario';
import React, { useState } from 'react'; import React, { useState } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import Button from './button';
import { useRouter } from 'next/navigation';
interface Props { interface Props {
cuestionario: GetCuestionario; cuestionario: GetCuestionario;
link: string; link: string;
button_message?: string;
} }
export default function FormularioCard({ cuestionario, link }: Props) { export default function FormularioCard({
cuestionario,
link,
button_message,
}: Props) {
const router = useRouter();
const [verMas, setVerMas] = useState(false); const [verMas, setVerMas] = useState(false);
const descripcion = cuestionario.descripcion; const descripcion = cuestionario.descripcion;
const limite = 100; const limite = 100;
@@ -21,34 +29,48 @@ export default function FormularioCard({ cuestionario, link }: Props) {
: descripcion; : descripcion;
return ( return (
<div className="col-md-4 my-4"> <div className="card card-blog d-flex flex-column justify-content-between">
<div className="card card-blog d-flex flex-column justify-content-between"> <div>
<div> <div className="card-image scale-hover-1">
<div className="card-image"> <Link href={link}>
<Link href={link}> <Image
<Image width={400} height={300} className="img" src="/banner1.png" alt="Banner formulario" /> width={400}
<div className="card-caption">{cuestionario.nombre_form}</div> height={300}
</Link> className="img"
<div className="ripple-cont"></div> src="/feriasex.png"
</div> alt="Banner formulario"
/>
<div className="card-caption">{cuestionario.nombre_form}</div>
</Link>
<div className="ripple-cont"></div>
</div>
<div className="table px-3 pt-3 mb-0"> <div className="table px-3 pt-3 mb-0">
<h6 className="category text-info"> <h6 className="category text-info">
{cuestionario.id_tipo_cuestionario === 1 ? 'Feria' : 'Evento'} {cuestionario.id_tipo_cuestionario === 1 ? 'Feria' : 'Evento'}
</h6> </h6>
<p className="card-description mb-0"> <p className="card-description mb-0">
{descripcionRecortada} {descripcionRecortada}
{descripcion.length > limite && ( {descripcion.length > limite && (
<button <button
onClick={() => setVerMas(!verMas)} onClick={() => setVerMas(!verMas)}
className="btn btn-link btn-sm p-0 ms-1 align-baseline" className="btn btn-link btn-sm p-0 ms-1 align-baseline"
style={{ fontSize: '0.875rem' }} style={{ fontSize: '0.875rem' }}
> >
{verMas ? 'Ver menos' : 'Ver más'} {verMas ? 'Ver menos' : 'Ver más'}
</button> </button>
)} )}
</p> </p>
</div> {button_message && (
<Button
onClick={() => router.push(link)}
outline
className="mt-3 w-100 py-1 rounded"
variant="azul"
>
{button_message}
</Button>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -0,0 +1,181 @@
import { CuestionarioWithCupo, GetEvento } from '@/types/evento';
import Image from 'next/image';
import Link from 'next/link';
import React, { useState } from 'react';
import MarkdownRenderer from '../markdown-render';
import { formatearFechaCard } from '@/utils/date-utils';
import Button from '../button';
export default function FormularioCardAdmin({
cuestionario,
evento,
handleDownload,
disabled,
}: {
cuestionario: CuestionarioWithCupo;
evento: GetEvento;
handleDownload: (
id_cuestionario: number,
nombre_formulario: string,
nombre_evento: string
) => void;
disabled: boolean;
}) {
const [verMas, setVerMas] = useState(false);
const descripcion = cuestionario.descripcion ?? '';
const limite = 100;
const descripcionRecortada =
descripcion.length > limite && !verMas
? `${descripcion.slice(0, limite)}...`
: descripcion;
// Obtener información de fecha usando la utilidad
const { mesTexto, diaTexto } = formatearFechaCard(
cuestionario.fecha_inicio,
cuestionario.fecha_fin
);
return (
<div
className="card shadow-sm border-0"
style={{ borderRadius: '12px', overflow: 'hidden' }}
>
<div className="position-relative">
<Link
href={`/user/evento/${evento.id_evento}/formulario/${cuestionario.id_cuestionario}`}
>
<Image
width={400}
height={250}
className="img-fluid w-100"
src={
cuestionario.banner
? `${process.env.NEXT_PUBLIC_API_URL}/banners/${cuestionario.banner}`
: evento.banner
? `${process.env.NEXT_PUBLIC_API_URL}/banners/${evento.banner}`
: `/default-banner.png`
}
alt="Banner formulario"
style={{ objectFit: 'cover', height: '200px' }}
/>
</Link>
{/* Mensaje cuando se usa la imagen del evento */}
{!cuestionario.banner && evento.banner && (
<div className="position-absolute top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center bg-black bg-opacity-25">
<div className="bg-white bg-opacity-90 px-3 py-2 rounded-pill text-center small fw-bold">
<i className="bi bi-info-circle-fill me-1 text-primary"></i>
Usando imagen del evento
</div>
</div>
)}
{/* Badge de tipo de cuestionario en la esquina superior izquierda */}
<div className="position-absolute top-0 start-0 m-2">
<span className="badge bg-info">
{cuestionario.tipoCuestionario?.tipo_cuestionario || 'Formulario'}
</span>
</div>
</div>
<div className="card-body p-3 d-flex flex-column">
{/* Fecha del formulario */}
<div className="d-flex align-items-center mb-2">
<div className="me-3" style={{ minWidth: '50px' }}>
<div
className="text-muted text-center small"
style={{
fontSize: mesTexto.includes(' - ') ? '0.65rem' : '0.75rem',
lineHeight: 1.1,
}}
>
{mesTexto}
</div>
<div
className="fw-bold mb-0 text-center"
style={{
lineHeight: 1,
whiteSpace: 'nowrap',
fontSize: diaTexto.toString().length > 2 ? '1rem' : '1.25rem',
}}
>
{diaTexto}
</div>
</div>
<div>
<h5 className="card-title mb-1 fw-bold">
{cuestionario.nombre_form}
</h5>
{evento && (
<small className="text-muted">
<i className="bi bi-calendar-event me-1"></i>
{evento.nombre_evento}
</small>
)}
</div>
</div>
{/* Descripción */}
<div className="card-description flex-grow-1 mb-3">
<MarkdownRenderer markdown={descripcionRecortada} />
{descripcion.length > limite && (
<button
onClick={() => setVerMas(!verMas)}
className="btn btn-link btn-sm p-0 ms-1 align-baseline text-decoration-none"
style={{ fontSize: '0.875rem' }}
>
{verMas ? 'Ver menos' : 'Ver más'}
</button>
)}
</div>
{/* Información de cupos y estadísticas */}
<div className="mb-3">
<div className="py-2 px-3 bg-light rounded">
<div className="d-flex align-items-center">
<i className="bi bi-people-fill me-2 text-primary"></i>
<div>
<small className="text-muted d-block">Cupos</small>
<span className="fw-semibold">
{cuestionario.cupo_maximo !== null
? `${cuestionario.cupos_usados} / ${cuestionario.cupo_maximo}`
: 'Sin límite'}
</span>
</div>
</div>
</div>
</div>
{/* Botones de acción */}
<div className="d-grid gap-2">
<Link
href={`/user/evento/${evento.id_evento}/formulario/${cuestionario.id_cuestionario}`}
className="btn btn-primary rounded-pill"
>
Ver/Editar formulario
</Link>
{/* Botón secundario para ver respuestas */}
<Button
outline
variant="success"
size="sm"
className="rounded-pill"
onClick={() =>
handleDownload(
cuestionario.id_cuestionario,
cuestionario.nombre_form,
evento.nombre_evento
)
}
disabled={disabled}
>
<i className="bi bi-download me-2"></i>
Descargar respuestas
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,280 @@
import { GetCuestionario } from '@/types/cuestionario';
import { FormularioCreacion } from '@/types/create-formulario';
import { GetEvento, GetEventoWithCuestionariosWithCupos } from '@/types/evento';
import Image from 'next/image';
import React, { useState, useEffect } from 'react';
import MarkdownRenderer from '../markdown-render';
import { formatearFechaCard } from '@/utils/date-utils';
interface FormularioCardPreviewProps {
// Para formularios existentes
cuestionario?: GetCuestionario;
// Para formularios en creación
formulario?: FormularioCreacion;
// Evento asociado
evento?: GetEvento | GetEventoWithCuestionariosWithCupos | null;
// Banner personalizado
nuevoBanner?: File | null;
}
export default function FormularioCardPreview({
cuestionario,
formulario,
evento,
nuevoBanner,
}: FormularioCardPreviewProps) {
const [verMas, setVerMas] = useState(false);
const [bannerUrl, setBannerUrl] = useState<string>('/default-banner.png');
// Determinar qué datos usar (formulario existente o en creación)
const isCreating = !!formulario && !cuestionario;
const data = cuestionario || formulario;
// Manejar la imagen del banner
useEffect(() => {
if (nuevoBanner) {
// Si hay un nuevo banner seleccionado, usarlo
const url = URL.createObjectURL(nuevoBanner);
setBannerUrl(url);
return () => URL.revokeObjectURL(url);
} else if (!isCreating && cuestionario?.banner) {
// Si es un formulario existente y tiene banner propio
setBannerUrl(
`${process.env.NEXT_PUBLIC_API_URL}/banners/${cuestionario.banner}`
);
} else if (evento?.banner) {
// Si hay banner del evento, usarlo
setBannerUrl(
`${process.env.NEXT_PUBLIC_API_URL}/banners/${evento.banner}`
);
} else {
// Banner por defecto
setBannerUrl('/default-banner.png');
}
}, [nuevoBanner, evento?.banner, cuestionario?.banner, isCreating]);
if (!data) {
return null; // No hay datos para mostrar
}
const descripcion = data.descripcion ?? '';
const limite = 100;
const descripcionRecortada =
descripcion.length > limite && !verMas
? `${descripcion.slice(0, limite)}...`
: descripcion;
// Obtener información de fecha usando la utilidad
const getDateInfo = () => {
if (!data.fecha_inicio || !data.fecha_fin) {
return { mesTexto: 'MES', diaTexto: 'DD' };
}
let fechaInicio: Date, fechaFin: Date;
if (isCreating) {
// Para formularios en creación, las fechas vienen como string
fechaInicio = new Date(data.fecha_inicio);
fechaFin = new Date(data.fecha_fin);
} else {
// Para formularios existentes, convertir a Date también
fechaInicio = new Date(data.fecha_inicio);
fechaFin = new Date(data.fecha_fin);
}
return formatearFechaCard(fechaInicio, fechaFin);
};
const { mesTexto, diaTexto } = getDateInfo();
// Determinar si se está usando la imagen del evento
const isUsingEventImage =
!nuevoBanner && (isCreating || !cuestionario?.banner) && evento?.banner;
// Obtener el tipo de cuestionario
const getTipoCuestionario = () => {
const tipoId = isCreating
? (formulario as FormularioCreacion).id_tipo_cuestionario
: (cuestionario as GetCuestionario).id_tipo_cuestionario;
if (tipoId === 1) return 'Registro';
if (tipoId === 2) return 'Evaluación';
return 'Formulario';
};
// Obtener nombre del formulario
const getNombreFormulario = () => {
return data.nombre_form || 'Nombre del formulario';
};
// Obtener información de cupos
const getCuposInfo = () => {
if (isCreating) {
const form = formulario as FormularioCreacion;
return form.cupo_maximo && form.cupo_maximo > 0
? `0 / ${form.cupo_maximo}`
: 'Sin límite';
} else {
const cuest = cuestionario as GetCuestionario;
return cuest.cupo_maximo !== null && cuest.cupo_maximo !== undefined
? `0 / ${cuest.cupo_maximo}`
: 'Sin límite';
}
};
// Obtener información de secciones y preguntas (solo para formularios en creación)
const getSeccionesInfo = () => {
if (!isCreating || !formulario) return null;
const totalSecciones = formulario.secciones.length;
const totalPreguntas = formulario.secciones.reduce(
(total, seccion) => total + seccion.preguntas.length,
0
);
return { totalSecciones, totalPreguntas };
};
const seccionesInfo = getSeccionesInfo();
return (
<div
className="card shadow-sm border-0 mb-2"
style={{ borderRadius: '12px', overflow: 'hidden' }}
>
<div className="position-relative">
<Image
width={400}
height={250}
className="img-fluid w-100"
src={bannerUrl}
alt="Banner formulario"
style={{ objectFit: 'cover', height: '200px' }}
/>
{/* Mensaje cuando se usa la imagen del evento */}
{isUsingEventImage && (
<div className="position-absolute top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center bg-black bg-opacity-25">
<div className="bg-white bg-opacity-90 px-3 py-2 rounded-pill text-center small fw-bold">
<i className="bi bi-info-circle-fill me-1 text-primary"></i>
Usando imagen del evento
</div>
</div>
)}
{/* Badge de tipo de cuestionario en la esquina superior izquierda */}
<div className="position-absolute top-0 start-0 m-2">
<span className="badge bg-info">{getTipoCuestionario()}</span>
</div>
</div>
<div className="card-body p-3 d-flex flex-column">
{/* Fecha del formulario */}
<div className="d-flex align-items-center mb-2">
<div className="me-3" style={{ minWidth: '50px' }}>
<div
className="text-muted text-center small"
style={{
fontSize: mesTexto.includes(' - ') ? '0.65rem' : '0.75rem',
lineHeight: 1.1,
}}
>
{mesTexto}
</div>
<div
className="fw-bold mb-0 text-center"
style={{
lineHeight: 1,
whiteSpace: 'nowrap',
fontSize: diaTexto.toString().length > 2 ? '1rem' : '1.25rem',
}}
>
{diaTexto}
</div>
</div>
<div>
<h5 className="card-title mb-1 fw-bold">{getNombreFormulario()}</h5>
{evento && (
<small className="text-muted">
<i className="bi bi-calendar-event me-1"></i>
{evento.nombre_evento}
</small>
)}
</div>
</div>
{/* Descripción */}
<div className="card-description flex-grow-1 mb-3">
{descripcion ? (
<>
<MarkdownRenderer markdown={descripcionRecortada} />
{descripcion.length > limite && (
<button
onClick={() => setVerMas(!verMas)}
className="btn btn-link btn-sm p-0 ms-1 align-baseline text-decoration-none"
style={{ fontSize: '0.875rem' }}
>
{verMas ? 'Ver menos' : 'Ver más'}
</button>
)}
</>
) : (
<p className="text-muted small">Descripción del formulario...</p>
)}
</div>
{/* Información de cupos */}
<div className="mb-3">
<div className="py-2 px-3 bg-light rounded">
<div className="d-flex align-items-center">
<i className="bi bi-people-fill me-2 text-primary"></i>
<div>
<small className="text-muted d-block">Cupos</small>
<span className="fw-semibold">{getCuposInfo()}</span>
</div>
</div>
</div>
</div>
{/* Información de secciones y preguntas (solo para formularios en creación) */}
{seccionesInfo && (
<div className="mb-3">
<div className="py-2 px-3 bg-light rounded">
<div className="d-flex justify-content-between align-items-center text-sm">
<div className="d-flex align-items-center">
<i className="bi bi-list-task me-2 text-success"></i>
<span className="fw-semibold">
{seccionesInfo.totalSecciones} sección
{seccionesInfo.totalSecciones !== 1 ? 'es' : ''}
</span>
</div>
<div className="d-flex align-items-center">
<i className="bi bi-question-circle me-2 text-warning"></i>
<span className="fw-semibold">
{seccionesInfo.totalPreguntas} pregunta
{seccionesInfo.totalPreguntas !== 1 ? 's' : ''}
</span>
</div>
</div>
</div>
</div>
)}
{/* Botón de acción */}
<div className="d-grid gap-2">
<button className="btn btn-primary rounded-pill" disabled>
Ver/Editar formulario
</button>
<button
className="btn btn-outline-success btn-sm rounded-pill"
disabled
>
<i className="bi bi-download me-2"></i>
Descargar respuestas
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,211 @@
'use client';
import { CuestionarioWithCupo, GetEvento } from '@/types/evento';
import Image from 'next/image';
import Link from 'next/link';
import React, { useState } from 'react';
import MarkdownRenderer from '../markdown-render';
import { toParam } from '@/utils/slugify';
import { formatearFechaCard, formatearHorario } from '@/utils/date-utils';
import Button from '../button';
export default function FormularioCardUser({
evento,
formulario,
}: {
evento: GetEvento;
formulario: CuestionarioWithCupo;
}) {
const [verMas, setVerMas] = useState(false);
const descripcion = formulario.descripcion ?? '';
const limite = 100;
// Determinar si hay cupos disponibles
const sinCupos =
formulario.cupo_maximo !== null && formulario.cupos_disponibles <= 0;
// Verificar si el evento es el mismo día
const fechaInicio = new Date(formulario.fecha_inicio);
const fechaFin = new Date(formulario.fecha_fin);
const esElMismoDia = fechaInicio.toDateString() === fechaFin.toDateString();
// Obtener información de fecha formateada usando las utilidades
const { mesTexto, diaTexto } = formatearFechaCard(
formulario.fecha_inicio,
formulario.fecha_fin
);
// Obtener horario formateado si es el mismo día
const horarioFormateado = esElMismoDia
? formatearHorario(formulario.fecha_inicio, formulario.fecha_fin)
: null;
const descripcionRecortada =
descripcion.length > limite && !verMas
? `${descripcion.slice(0, limite)}...`
: descripcion;
const imgSrc = formulario.banner
? `${process.env.NEXT_PUBLIC_API_URL}/banners/${formulario.banner}`
: evento.banner
? `${process.env.NEXT_PUBLIC_API_URL}/banners/${evento.banner}`
: `/default-banner.png`;
return (
<div
className="card shadow-sm border-0"
style={{ borderRadius: '12px', overflow: 'hidden' }}
>
<div className="position-relative">
{sinCupos ? (
<div className="position-relative">
<Image
width={400}
height={250}
className="img-fluid w-100"
src={imgSrc}
alt="Banner formulario"
style={{
objectFit: 'cover',
height: '200px',
filter: 'grayscale(50%) opacity(0.7)',
}}
/>
<div className="position-absolute top-50 start-50 translate-middle bg-gradient bg-black bg-opacity-75 text-white px-3 py-2 rounded-pill">
Sin cupos disponibles
</div>
</div>
) : (
<Link
href={`/evento/${toParam(
evento.nombre_evento,
evento.id_evento
)}/${toParam(formulario.nombre_form, formulario.id_cuestionario)}`}
>
<Image
width={400}
height={250}
className="img-fluid w-100"
src={imgSrc}
alt="Banner formulario"
style={{ objectFit: 'cover', height: '200px' }}
/>
</Link>
)}
{/* Badge de cupos en la esquina superior derecha */}
<div className="position-absolute top-0 end-0 m-2">
<span className="badge bg-primary">
{formulario.tipoCuestionario.tipo_cuestionario}{' '}
</span>
</div>
</div>
<div className="card-body p-3 d-flex flex-column">
{/* Fecha del evento */}
<div className="d-flex align-items-center mb-2">
<div className="me-3">
<div className="text-muted text-center small">{mesTexto}</div>
<div
className="fw-bold h4 mb-0"
style={{ lineHeight: 1, whiteSpace: 'nowrap' }}
>
{diaTexto}
</div>
</div>
<div>
<span className="text-muted fw-bold">{evento.nombre_evento}</span>
<h5 className="card-title mb-1 fw-bold">
{formulario.nombre_form}
</h5>
{formulario.tipoEvento && (
<h6 className="small text-muted">
{formulario.tipoEvento.tipo_evento}
</h6>
)}
</div>
</div>
{/* Descripción */}
<div className="card-description flex-grow-1 mb-2">
<MarkdownRenderer markdown={descripcionRecortada} />
{descripcion.length > limite && (
<button
onClick={() => setVerMas(!verMas)}
className="btn btn-link btn-sm p-0 ms-1 align-baseline text-decoration-none"
style={{ fontSize: '0.875rem' }}
>
{verMas ? 'Ver menos' : 'Ver más'}
</button>
)}
</div>
{/* Información de cupos y estadísticas */}
<div className="row mb-3">
{/* Columna de horario - solo visible si es el mismo día */}
{esElMismoDia && (
<div className="col-7">
<div
className={`py-2 px-3 rounded ${
sinCupos ? 'bg-primary-subtle' : 'bg-light'
}`}
>
<div className="d-flex align-items-center">
<i className={`bi bi-clock me-2 text-azul`}></i>
<div>
<small className="text-muted d-block">Horario</small>
<span className={`fw-semibold text-azul`}>
{horarioFormateado} horas.
</span>
</div>
</div>
</div>
</div>
)}
{/* Columna de cupos - siempre visible */}
<div className={esElMismoDia ? 'col-4' : 'col-12'}>
<div
className={`py-2 px-3 rounded ${
sinCupos ? 'bg-primary-subtle' : 'bg-light'
}`}
>
<div className="d-flex align-items-center">
<i className={`bi bi-people-fill me-2 text-primary`}></i>
<div>
<small className="text-muted d-block">Registro</small>
<span className={`fw-semibold text-primary`}>
{formulario.cupo_maximo !== null
? `${formulario.cupos_usados} / ${formulario.cupo_maximo}`
: 'Sin límite'}
</span>
{sinCupos && (
<div className="small text-primary mt-1">
<i className="bi bi-exclamation-circle me-1"></i>
Cupos agotados
</div>
)}
</div>
</div>
</div>
</div>
</div>
{/* Botón de registro */}
{sinCupos ? (
<Button disabled className="rounded-pill">
Sin cupos disponibles
</Button>
) : (
<Link
href={`/evento/${toParam(
evento.nombre_evento,
evento.id_evento
)}/${toParam(formulario.nombre_form, formulario.id_cuestionario)}`}
className="btn btn-primary w-100 rounded-pill"
>
Registro
</Link>
)}
</div>
</div>
);
}
+17 -54
View File
@@ -1,79 +1,42 @@
import React, { useState } from 'react'; import React from 'react';
export interface InputProps export interface SimpleInputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'className'> { extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'className'> {
/** Etiqueta visible asociada al campo de entrada */
label?: string; label?: string;
/** Clases CSS personalizables para distintos elementos */
className?: { className?: {
/** Clases para el contenedor del input */
container?: string; container?: string;
/** Clases para la etiqueta */
label?: string; label?: string;
/** Clases para el input */
input?: string; input?: string;
}; };
} }
/** /**
* Componente de entrada (`Input`) reutilizable en React. * Componente de entrada (`Input`) sin lógica de mostrar/ocultar contraseña.
*
* Este componente renderiza un campo de entrada (`<input>`) con una etiqueta opcional y
* funcionalidad para mostrar u ocultar contraseñas cuando el tipo es `password`.
* También permite personalizar clases CSS y es accesible mediante `aria-label` y `htmlFor`.
*
* @component
* @param {Object} props - Propiedades del componente.
* @param {string} [props.label] - Etiqueta visible asociada al campo de entrada.
* @param {Object} [props.className] - Clases CSS personalizables para distintos elementos.
* @param {string} [props.className.container] - Clases para el contenedor del input.
* @param {string} [props.className.label] - Clases para la etiqueta del input.
* @param {string} [props.className.input] - Clases para el input en sí.
* @param {string} [props.type] - Tipo del input (`text`, `password`, `email`, etc.).
* @param {string} [props.name] - Nombre del input (útil para formularios).
* @param {string} [props.id] - ID personalizado para el input (para accesibilidad).
* @param {string} [props.value] - Valor del input (para uso controlado).
* @param {function} [props.onChange] - Manejador del cambio de valor del input.
* @param {React.InputHTMLAttributes<HTMLInputElement>} [rest] - Atributos adicionales compatibles con `<input>`.
*/ */
export default function Input(props: InputProps) { export default function SimpleInput(props: SimpleInputProps) {
const { className, label, type, name, id, ...rest } = props; const { label, name, id, className, ...rest } = props;
const [showPassword, setShowPassword] = useState(false);
const isPassword = type === 'password';
const inputType = isPassword && showPassword ? 'text' : type;
const inputId = const inputId =
id || name || (label ? `input-${label.replace(/\s+/g, '-')}` : undefined); id || name || (label ? `input-${label.replace(/\s+/g, '-')}` : undefined);
const togglePasswordVisibility = () => setShowPassword(!showPassword);
return ( return (
<div className={className?.container || 'mb-3'}> <div className={className?.container || 'mb-3'}>
{label && inputId && ( {label && inputId && (
<label <label htmlFor={inputId} className={className?.label || 'form-label'}>
htmlFor={inputId}
className={`form-label ${className?.label || ''}`}
>
{label} {label}
</label> </label>
)} )}
<div className='input-group'> <input
<input id={inputId}
id={inputId} name={name}
name={name} className={className?.input || 'form-control'}
type={inputType} {...rest}
className={`form-control bg-white ${className?.input || ''}`} />
{...rest}
/>
{isPassword && (
<button
type='button'
className='btn btn-light border'
onClick={togglePasswordVisibility}
aria-label={
showPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'
}
title={showPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'}
>
<i className={`bi ${showPassword ? 'bi-eye-slash' : 'bi-eye'}`}></i>
</button>
)}
</div>
</div> </div>
); );
} }
+7 -7
View File
@@ -5,15 +5,15 @@ export default function Footer() {
const year = date.getFullYear(); const year = date.getFullYear();
return ( return (
<footer className='bg-azul p-3 text-center text-white small'> <footer className="bg-azul p-3 text-center text-white small">
<p className='m-0'> <p className="m-0">
Hecho en México. Todos los derechos reservados {year}. Hecho en México. Todos los derechos reservados {year}.
</p> </p>
<p className='m-0'> <p className="m-0">
Esta página puede ser reproducida con fines no lucrativos, Esta página puede ser reproducida con fines no lucrativos, siempre y
siempre y cuando no se mutile, se cite la fuente completa y su cuando no se mutile, se cite la fuente completa y su dirección
dirección electrónica. De otra forma, requiere permiso previo electrónica. De otra forma, requiere permiso previo por escrito de la
por escrito de la institución. institución.
</p> </p>
</footer> </footer>
); );
+26 -26
View File
@@ -3,30 +3,30 @@ import React from 'react';
import Link from 'next/link'; import Link from 'next/link';
export default function Header({ small = false }: { small?: boolean }) { export default function Header({ small = false }: { small?: boolean }) {
return ( return (
<header className='bg-azul p-3'> <header className="bg-azul p-3">
<div className='container flex-center justify-content-sm-between'> <div className="container flex-center justify-content-sm-between">
<Link <Link
href={'https://www.unam.mx/'} href={'https://www.unam.mx/'}
target='_blank' target="_blank"
className='d-none d-sm-block' className="d-none d-sm-block"
> >
<Image <Image
src='/logo-unam.png' src="/logo-unam.png"
alt='Logo de la UNAM' alt="Logo de la UNAM"
width={small ? 200 : 280} width={small ? 200 : 280}
height={small ? 60 : 84} height={small ? 60 : 84}
/> />
</Link> </Link>
<Link href={'https://acatlan.unam.mx/'} target='_blank'> <Link href={'https://acatlan.unam.mx/'} target="_blank">
<Image <Image
src='/logo-fes.png' src="/logo-fes.png"
alt='Logo de la fes Acatlàn' alt="Logo de la fes Acatlàn"
width={small ? 200 : 250} width={small ? 200 : 250}
height={small ? 48 : 60} height={small ? 48 : 60}
/> />
</Link> </Link>
</div> </div>
</header> </header>
); );
} }
+18
View File
@@ -0,0 +1,18 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
type MarkdownRendererProps = {
markdown: string;
};
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ markdown }) => (
<ReactMarkdown
components={{
p: ({ ...props }) => <p style={{ margin: 0 }} {...props} />,
}}
>
{markdown}
</ReactMarkdown>
);
export default MarkdownRenderer;
+93 -35
View File
@@ -1,9 +1,55 @@
import React from "react"; 'use client';
import Link from "next/link"; import React from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
type NavChild = {
href: string; // siempre string
label: string;
};
type NavItem =
| { href: string; label: string } // items normales
| { label: string; children: NavChild[] }; // items con submenu
const Navbar: React.FC = () => { const Navbar: React.FC = () => {
const pathname = usePathname();
const tipo_usuario = sessionStorage.getItem('axxxd')?.replace(/"/g, '') || '';
const navItemsAdmin: NavItem[] = [
{ href: '/', label: 'Inicio' },
{ href: '/user/eventos', label: 'Eventos' },
{ href: '/user/qr', label: 'Lector de QRs' },
{ href: '/user/eventos/crear', label: 'Crear Evento' },
{
label: 'Carga Masiva',
children: [
{ href: '/user/eventos/carga_masiva', label: 'Carga Masiva Eventos' },
{
href: '/user/eventos/carga_masiva_banner',
label: 'Carga Masiva Imagenes',
},
{
href: '/user/carga_masiva_usuarios',
label: 'Carga Masiva Usuarios',
},
],
},
{ href: '/user/usuarios', label: 'Usuarios' },
{ href: '/login', label: 'Cerrar sesión' },
];
const navItemsUser: NavItem[] = [
{ href: '/user/eventos', label: 'Eventos' },
{ href: '/user/qr', label: 'Lector de QRs' },
{ href: '/login', label: 'Cerrar sesión' },
];
const navItems =
tipo_usuario === 'administrador' ? navItemsAdmin : navItemsUser;
return ( return (
<nav className="navbar navbar-expand-lg navbar-light bg-transparent fixed-top"> <nav className="navbar navbar-expand-lg navbar-dark bg-azul">
<div className="container"> <div className="container">
<button <button
className="navbar-toggler border-0 ms-auto" className="navbar-toggler border-0 ms-auto"
@@ -20,7 +66,7 @@ const Navbar: React.FC = () => {
id="offcanvasNavbar" id="offcanvasNavbar"
aria-labelledby="offcanvasNavbarLabel" aria-labelledby="offcanvasNavbarLabel"
> >
<div className="offcanvas-header"> <div className="offcanvas-header bg-azul text-white">
<h5 className="offcanvas-title" id="offcanvasNavbarLabel"> <h5 className="offcanvas-title" id="offcanvasNavbarLabel">
Menu Menu
</h5> </h5>
@@ -31,38 +77,50 @@ const Navbar: React.FC = () => {
aria-label="Close" aria-label="Close"
></button> ></button>
</div> </div>
<div className="offcanvas-body justify-content-center"> <div className="offcanvas-body align-items-center justify-content-between bg-azul">
<h2 className="h5 mb-0 text-white">
Sistema de Registro de Eventos
</h2>
<ul className="navbar-nav gap-lg-5"> <ul className="navbar-nav gap-lg-5">
<li className="nav-item" data-bs-dismiss="offcanvas"> {navItems.map((item, index) => (
<Link className="nav-link" href="/"> <li
Inicio key={index}
</Link> className="nav-item"
</li> data-bs-dismiss="offcanvas"
<li className="nav-item" data-bs-dismiss="offcanvas"> >
<Link className="nav-link" href="/table"> {'href' in item ? (
Table <Link
</Link> className={`nav-link ${
</li> pathname === item.href ? 'text-white' : ''
<li className="nav-item" data-bs-dismiss="offcanvas"> }`}
<Link className="nav-link" href="/input"> href={item.href}
Input >
</Link> {item.label}
</li> </Link>
<li className="nav-item" data-bs-dismiss="offcanvas"> ) : (
<Link className="nav-link" href="/pagination"> <div className="nav-item dropdown">
Pagination <a
</Link> className="nav-link dropdown-toggle"
</li> href="#"
<li className="nav-item" data-bs-dismiss="offcanvas"> role="button"
<Link className="nav-link" href="/options"> data-bs-toggle="dropdown"
Options aria-expanded="false"
</Link> >
</li> {item.label}
<li className="nav-item" data-bs-dismiss="offcanvas"> </a>
<Link className="nav-link" href="/styles"> <ul className="dropdown-menu">
Styles {item.children.map((child: NavChild, idx: number) => (
</Link> <li key={idx}>
</li> <Link className="dropdown-item" href={child.href}>
{child.label}
</Link>
</li>
))}
</ul>
</div>
)}
</li>
))}
</ul> </ul>
</div> </div>
</div> </div>
+141
View File
@@ -0,0 +1,141 @@
'use client';
import { CuestionarioWithCupo } from '@/types/evento';
import Image from 'next/image';
import Link from 'next/link';
import React, { useState } from 'react';
import MarkdownRenderer from './markdown-render';
import { formatearRangoFechas } from '@/utils/date-utils';
export default function NewCuestionarioCard({
cuestionario,
user = 'public',
onDownload,
loading = false,
}: {
cuestionario: CuestionarioWithCupo;
user?: 'public' | 'administrador' | 'staff';
onDownload?: (id_cuestionario: number, nombre: string) => void;
loading?: boolean;
}) {
const [verMas, setVerMas] = useState(false);
const descripcion = cuestionario.descripcion ?? '';
const limite = 100;
const descripcionRecortada =
descripcion.length > limite && !verMas
? `${descripcion.slice(0, limite)}...`
: descripcion;
return (
<div className="card card-blog d-flex flex-column justify-content-between">
<div>
<div className="card-image scale-hover-1">
<Link
href={
user === 'administrador' || user === 'staff'
? `/${user}/evento/${cuestionario.id_evento}`
: `/evento/${cuestionario.nombre_form.split(' ').join('_')}/${
cuestionario.id_evento
}/${cuestionario.id_cuestionario}`
}
>
<Image
width={400}
height={300}
className="img-fluid"
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${cuestionario.banner}`}
alt="Banner formulario"
/>
<div className="card-caption">{cuestionario.nombre_form}</div>
</Link>
<div className="ripple-cont"></div>
</div>
<div className="card-body">
<div className="card-description mb-0">
{cuestionario.cupo_maximo === null ? (
<span className="badge bg-success mb-2">Sin límite</span>
) : (
<span className="badge bg-primary mb-2">
{cuestionario.cupos_disponibles} cupos disponibles
</span>
)}
<MarkdownRenderer markdown={descripcionRecortada} />
{descripcion.length > limite && (
<button
onClick={() => setVerMas(!verMas)}
className="btn btn-link btn-sm p-0 ms-1 align-baseline"
style={{ fontSize: '0.875rem' }}
>
{verMas ? 'Ver menos' : 'Ver más'}
</button>
)}
</div>
<span className="badge fs-6 w-100 bg-primary">
Evento{' '}
{formatearRangoFechas(
cuestionario.fecha_inicio,
cuestionario.fecha_fin
)}
</span>
<div className="">
{user === 'administrador' || user === 'staff' ? (
<>
<Link
href={`/${user}/evento/${cuestionario.id_evento}/formulario/${cuestionario.id_cuestionario}`}
className="btn mt-2 w-100 btn-outline-primary"
>
Ver formulario
</Link>
{user === 'administrador' && onDownload && (
<button
className="btn mt-2 w-100 btn-success"
onClick={() =>
onDownload(
cuestionario.id_cuestionario,
cuestionario.nombre_form
)
}
disabled={loading}
>
{loading ? (
<>
<span
className="spinner-border spinner-border-sm me-2"
role="status"
aria-hidden="true"
></span>
Descargando...
</>
) : (
<>
<i className="bi bi-download me-2"></i>
Descargar respuestas
</>
)}
</button>
)}
</>
) : cuestionario.cupos_disponibles === 0 &&
cuestionario.cupo_maximo !== null ? (
<button className="btn mt-2 w-100 btn-outline-secondary" disabled>
Cupo lleno
</button>
) : (
<Link
href={`/evento/${cuestionario.nombre_form
.split(' ')
.join('_')}/${cuestionario.id_evento}/${
cuestionario.id_cuestionario
}`}
className="btn mt-2 w-100 btn-outline-primary"
>
Registro
</Link>
)}
</div>
</div>
</div>
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
import React, { useState } from 'react';
export interface PasswordInputProps
extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
'type' | 'className'
> {
/**
* Texto de la etiqueta asociada al input (opcional).
*/
label?: string;
/**
* Clases CSS personalizables para distintos elementos.
*/
className?: {
/** Clases para el contenedor del componente */
container?: string;
/** Clases para la etiqueta */
label?: string;
/** Clases para el campo input */
input?: string;
/** Clases para el botón de toggle */
button?: string;
};
}
/**
* Componente especializado para inputs de contraseña con botón de mostrar/ocultar.
*/
export default function PasswordInput(props: PasswordInputProps) {
const { label, name, id, className, ...rest } = props;
const [visible, setVisible] = useState(false);
// Determina el tipo de input según el estado de visibilidad
const inputType = visible ? 'text' : 'password';
const inputId =
id ||
name ||
(label ? `password-${label.replace(/\s+/g, '-')}` : undefined);
const toggleVisibility = () => setVisible((v) => !v);
return (
<div className={className?.container || 'mb-3'}>
{label && inputId && (
<label htmlFor={inputId} className={className?.label || 'form-label'}>
{label}
</label>
)}
<div className="input-group">
<input
id={inputId}
name={name}
type={inputType}
className={className?.input || 'form-control'}
{...rest}
/>
<button
type="button"
className={className?.button || 'btn btn-light border'}
onClick={toggleVisibility}
aria-label={visible ? 'Ocultar contraseña' : 'Mostrar contraseña'}
title={visible ? 'Ocultar contraseña' : 'Mostrar contraseña'}
>
<i className={`bi ${visible ? 'bi-eye-slash' : 'bi-eye'}`} />
</button>
</div>
</div>
);
}
+5 -2
View File
@@ -1,6 +1,6 @@
import React, { useId } from 'react'; import React, { useId } from 'react';
interface Option { export interface Option {
value: string | number; value: string | number;
label: string; label: string;
} }
@@ -74,7 +74,10 @@ const Select: React.FC<SelectProps> = ({
return ( return (
<div className={`mb-3 ${className?.container ?? ''}`}> <div className={`mb-3 ${className?.container ?? ''}`}>
<label htmlFor={selectId} className={`form-label ${className?.label ?? ''}`}> <label
htmlFor={selectId}
className={`form-label ${className?.label ?? ''}`}
>
{label} {label}
</label> </label>
<select <select
+1
View File
@@ -1,3 +1,4 @@
// @components/table.tsx
import React, { useState, useMemo } from "react"; import React, { useState, useMemo } from "react";
import Pagination from "./pagination"; import Pagination from "./pagination";
+120
View File
@@ -0,0 +1,120 @@
'use client';
import ImageUploader from '@/components/banner-uploader';
import Input from '@/components/input';
import Select from '@/components/select';
import { useGetApi } from '@/hooks/use-get-api';
import { CreateEventoType } from '@/types/create-evento';
import { TipoEvento } from '@/types/evento';
import { formatDateLocal } from '@/utils/date-utils';
import { commands } from '@uiw/react-md-editor';
import dynamic from 'next/dynamic';
import React from 'react';
// Carga dinámica para evitar problemas con SSR
const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false });
const MAX_LENGTH = 500;
interface CreateEventoProps {
evento: CreateEventoType;
handleChange: (field: keyof CreateEventoType, value: string | Date) => void;
handleBannerChange: (file: File | null) => void;
}
export default function CreateEvento({
evento,
handleChange,
handleBannerChange,
}: CreateEventoProps) {
const { data: tipo_eventos } = useGetApi<TipoEvento[]>('tipo-evento');
return (
<div className="mt-2">
<h4 className="mb-3">Detalles del Evento</h4>
<div className="mb-2">
<ImageUploader
label="Banner del evento"
onChange={handleBannerChange}
showPreview={false}
/>
<p className="text-muted mt-2 mb-0 small">
<strong>
<i className="bi bi-info-circle-fill me-2"></i>
Esta es la imagen que se verá en el carrusel del evento.
</strong>
<br />
<strong>
<i className="bi bi-image me-2"></i>
Dimensiones recomendadas: 1200x600 píxeles.
</strong>
<br />
<strong>
<i className="bi bi-file-earmark-image me-2"></i>
Formatos soportados: PNG, JPG, JPEG.
</strong>
</p>
</div>
<Input
label="Nombre del evento"
required
value={evento.nombre_evento}
onChange={(e) => handleChange('nombre_evento', e.target.value)}
/>
<div className="mb-4">
<label className="form-label">Descripción del evento</label>
<div data-color-mode="light">
<MDEditor
value={evento.descripcion_evento}
onChange={(value) => {
const texto = value || '';
if (texto.length <= MAX_LENGTH) {
handleChange('descripcion_evento', texto);
}
}}
height={200}
commands={[commands.bold, commands.italic, commands.hr]}
/>
</div>
</div>
{tipo_eventos && (
<Select
label="Tipo de evento"
value={evento.tipo_evento}
placeholder="Selecciona un tipo de evento"
onChange={(e) => handleChange('tipo_evento', e.target.value)}
options={tipo_eventos.map((tipo) => ({
value: tipo.id_tipo_evento,
label: tipo.tipo_evento,
}))}
/>
)}
<div className="row">
<div className="col-md-6">
<Input
label="Fecha de inicio"
type="datetime-local"
className={{ container: 'mb-3' }}
value={formatDateLocal(evento.fecha_inicio)}
onChange={(e) =>
handleChange('fecha_inicio', new Date(e.target.value))
}
/>
</div>
<div className="col-md-6">
<Input
label="Fecha de fin"
type="datetime-local"
className={{ container: 'mb-3' }}
value={formatDateLocal(evento.fecha_fin)}
onChange={(e) =>
handleChange('fecha_fin', new Date(e.target.value))
}
/>
</div>
</div>
</div>
);
}
+503
View File
@@ -0,0 +1,503 @@
import React from 'react';
import {
FormularioCreacion,
TipoPregunta,
TiposValidacion,
} from '@/types/create-formulario';
import SimpleInput from '@/components/input';
import Select, { Option } from '@/components/select';
import Button from '@/components/button';
import { GetEventoWithCuestionariosWithCupos } from '@/types/evento';
import { formatearRangoFechas } from '@/utils/date-utils';
interface Props {
evento?: GetEventoWithCuestionariosWithCupos;
formulario: FormularioCreacion;
onChange: (formularioActualizado: FormularioCreacion) => void;
tipo_eventos?: Option[];
}
const tipoPreguntaOpciones: TipoPregunta[] = [
'Abierta (Respuesta corta)',
'Abierta (Parrafo)',
'Cerrada',
'Multiple',
];
const tiposValidacion: TiposValidacion[] = [
'correo',
'correo_institucional',
'telefono',
'nombre',
'apellidos',
'genero',
'entero',
'decimal',
'comunidad_alumno',
'cuenta_alumno',
'comunidad_trabajador',
'cuenta_trabajador',
'rfc',
];
type CampoPreguntaSimple =
| 'titulo'
| 'tipo'
| 'validacion'
| 'obligatoria'
| 'limite';
type CampoPreguntaOpciones = 'opciones';
const tiposCuestionario = [
{ label: 'Registro', value: 1 },
{ label: 'Evaluación', value: 2 },
];
export default function FormularioEditor({
formulario,
onChange,
evento,
tipo_eventos,
}: Props) {
function actualizarCampo<K extends keyof FormularioCreacion>(
campo: K,
valor: FormularioCreacion[K]
): void {
const actualizado = { ...formulario, [campo]: valor };
onChange(actualizado);
}
function handleUpdatePregunta(
seccionIdx: number,
preguntaIdx: number,
campo: CampoPreguntaSimple | CampoPreguntaOpciones,
valor:
| string
| boolean
| TipoPregunta
| TiposValidacion
| number
| { valor: string }[]
| undefined
) {
const actualizado = { ...formulario };
const pregunta = actualizado.secciones[seccionIdx].preguntas[preguntaIdx];
if (campo === 'opciones') {
pregunta.opciones = valor as { valor: string }[];
} else if (campo === 'titulo' && typeof valor === 'string') {
pregunta.titulo = valor;
} else if (campo === 'tipo' && typeof valor === 'string') {
pregunta.tipo = valor as TipoPregunta;
} else if (campo === 'validacion' || valor === undefined) {
pregunta.validacion = valor as TiposValidacion | undefined;
} else if (campo === 'obligatoria' && typeof valor === 'boolean') {
pregunta.obligatoria = valor;
} else if (campo === 'limite' && typeof valor === 'number') {
pregunta.limite = valor;
}
onChange(actualizado);
}
const handleUpdateSeccion = (
index: number,
campo: 'titulo' | 'descripcion',
valor: string
) => {
const actualizado = { ...formulario };
actualizado.secciones = actualizado.secciones.map((seccion, idx) =>
idx === index ? { ...seccion, [campo]: valor } : seccion
);
onChange(actualizado);
};
const agregarSeccion = () => {
const nuevaSeccion = {
titulo: '',
descripcion: '',
preguntas: [],
};
onChange({
...formulario,
secciones: [...formulario.secciones, nuevaSeccion],
});
};
const eliminarSeccion = (index: number) => {
const nuevas = [...formulario.secciones];
nuevas.splice(index, 1);
onChange({ ...formulario, secciones: nuevas });
};
const agregarPregunta = (seccionIdx: number) => {
const nuevaPregunta = {
titulo: '',
tipo: 'Abierta (Respuesta corta)' as TipoPregunta,
obligatoria: false,
opciones: [],
};
const actualizado = { ...formulario };
actualizado.secciones[seccionIdx].preguntas.push(nuevaPregunta);
onChange(actualizado);
};
const eliminarPregunta = (seccionIdx: number, preguntaIdx: number) => {
const actualizado = { ...formulario };
actualizado.secciones[seccionIdx].preguntas.splice(preguntaIdx, 1);
onChange(actualizado);
};
// Renderizar solo información básica del formulario
const renderInformacionBasica = () => (
<div className="p-4">
<h4 className="mb-3">Información del Formulario</h4>
<div className="row">
<div className="col-md-8">
<SimpleInput
label="Nombre del Formulario"
value={formulario.nombre_form}
onChange={(e) => actualizarCampo('nombre_form', e.target.value)}
/>
</div>
<div className="col-md-4">
<SimpleInput
label="Cupo máximo"
type="number"
placeholder="Opcional"
value={formulario.cupo_maximo || ''}
onChange={(e) =>
actualizarCampo('cupo_maximo', Number(e.target.value) || 0)
}
/>
</div>
</div>
<SimpleInput
label="Descripción del Formulario"
value={formulario.descripcion}
onChange={(e) => actualizarCampo('descripcion', e.target.value)}
/>
<div className="row">
<div className="col-md-6">
<SimpleInput
label="Fecha de Inicio"
type="datetime-local"
value={formulario.fecha_inicio.slice(0, 16)}
onChange={(e) => actualizarCampo('fecha_inicio', e.target.value)}
/>
</div>
<div className="col-md-6">
<SimpleInput
label="Fecha de Fin"
type="datetime-local"
value={formulario.fecha_fin.slice(0, 16)}
onChange={(e) => actualizarCampo('fecha_fin', e.target.value)}
/>
</div>
</div>
{evento && (
<div className="alert alert-primary d-flex align-items-center">
<i className="bi bi-info-circle-fill me-2"></i>
<div>
<strong>Evento:</strong> {evento.nombre_evento}
<br />
<small>
Se realizará{' '}
{formatearRangoFechas(evento.fecha_inicio, evento.fecha_fin)}
</small>
</div>
</div>
)}
<SimpleInput
label="Mensaje de correo"
value={formulario.mensaje_correo || ''}
onChange={(e) => actualizarCampo('mensaje_correo', e.target.value)}
placeholder="Mensaje que se enviará al correo del participante al registrarse"
/>
<div className="row">
<div className="col-md-6">
<Select
label="Tipo de Cuestionario"
value={formulario.id_tipo_cuestionario}
onChange={(e) =>
actualizarCampo('id_tipo_cuestionario', Number(e.target.value))
}
options={tiposCuestionario}
placeholder="Selecciona un tipo"
/>
</div>
<div className="col-md-6">
<Select
label="Tipo de evento"
value={formulario.id_tipo_evento}
onChange={(e) =>
actualizarCampo('id_tipo_evento', Number(e.target.value))
}
options={tipo_eventos!}
placeholder="Selecciona un tipo"
/>
</div>
</div>
</div>
);
// Renderizar secciones y preguntas
const renderSeccionesYPreguntas = () => (
<div className="mt-4">
<div className="d-flex justify-content-between align-items-center mb-4">
<h4 className="mb-0">Secciones y Preguntas</h4>
<Button
className="btn btn-success"
onClick={agregarSeccion}
icon="plus"
>
Agregar sección
</Button>
</div>
{formulario.secciones.map((seccion, seccionIdx) => (
<div key={seccionIdx} className="mb-4 p-3 border rounded bg-light">
<div className="d-flex justify-content-between align-items-center mb-2">
<h5 className="mb-0">Sección {seccionIdx + 1}</h5>
<Button
variant="danger"
icon="trash"
size="sm"
outline
onClick={() => eliminarSeccion(seccionIdx)}
>
Eliminar sección
</Button>
</div>
<SimpleInput
label="Título de la sección"
value={seccion.titulo}
onChange={(e) =>
handleUpdateSeccion(seccionIdx, 'titulo', e.target.value)
}
/>
<SimpleInput
label="Descripción de la sección"
value={seccion.descripcion}
onChange={(e) =>
handleUpdateSeccion(seccionIdx, 'descripcion', e.target.value)
}
/>
{/* Preguntas */}
{seccion.preguntas.map((pregunta, preguntaIdx) => (
<div key={preguntaIdx} className="mb-3 p-3 border rounded bg-white">
<div className="d-flex justify-content-between align-items-center mb-2">
<h6 className="mb-0">Pregunta {preguntaIdx + 1}</h6>
<Button
variant="danger"
icon="trash"
size="sm"
outline
onClick={() => eliminarPregunta(seccionIdx, preguntaIdx)}
>
Eliminar
</Button>
</div>
<SimpleInput
label="Título de la pregunta"
value={pregunta.titulo}
onChange={(e) =>
handleUpdatePregunta(
seccionIdx,
preguntaIdx,
'titulo',
e.target.value
)
}
/>
<div className="row">
<div className="col-md-6">
<Select
label="Tipo de pregunta"
value={pregunta.tipo}
onChange={(e) => {
const nuevoTipo = e.target.value as TipoPregunta;
handleUpdatePregunta(
seccionIdx,
preguntaIdx,
'tipo',
nuevoTipo
);
// Si cambia a un tipo cerrado y no tiene opciones, agregar opciones por defecto
if (
(nuevoTipo === 'Cerrada' || nuevoTipo === 'Multiple') &&
(!pregunta.opciones || pregunta.opciones.length === 0)
) {
handleUpdatePregunta(
seccionIdx,
preguntaIdx,
'opciones',
[{ valor: '' }, { valor: '' }]
);
}
}}
options={tipoPreguntaOpciones.map((tipo) => ({
label: tipo,
value: tipo,
}))}
/>
</div>
<div className="col-md-6">
<Select
label="Validación"
value={pregunta.validacion || ''}
onChange={(e) =>
handleUpdatePregunta(
seccionIdx,
preguntaIdx,
'validacion',
e.target.value || undefined
)
}
options={[
{ label: 'Ninguna', value: '' },
...tiposValidacion.map((v) => ({
label: v,
value: v,
})),
]}
/>
</div>
</div>
<div className="form-check mb-2">
<input
className="form-check-input"
type="checkbox"
checked={pregunta.obligatoria}
id={`obligatoria-${seccionIdx}-${preguntaIdx}`}
onChange={(e) =>
handleUpdatePregunta(
seccionIdx,
preguntaIdx,
'obligatoria',
e.target.checked
)
}
/>
<label
className="form-check-label"
htmlFor={`obligatoria-${seccionIdx}-${preguntaIdx}`}
>
¿Es obligatoria?
</label>
</div>
{(pregunta.tipo === 'Cerrada' ||
pregunta.tipo === 'Multiple') && (
<div className="mb-2">
<div className="d-flex justify-content-between align-items-center mb-2">
<label className="form-label mb-0">Opciones</label>
<Button
variant="success"
size="sm"
icon="plus"
outline
onClick={() => {
const nuevasOpciones = [...(pregunta.opciones || [])];
nuevasOpciones.push({ valor: '' });
handleUpdatePregunta(
seccionIdx,
preguntaIdx,
'opciones',
nuevasOpciones
);
}}
>
Agregar opción
</Button>
</div>
{(pregunta.opciones || []).map((op, opIdx) => (
<div key={opIdx} className="d-flex align-items-center mb-2">
<SimpleInput
value={op.valor}
onChange={(e) => {
const nuevasOpciones = [...(pregunta.opciones || [])];
nuevasOpciones[opIdx].valor = e.target.value;
handleUpdatePregunta(
seccionIdx,
preguntaIdx,
'opciones',
nuevasOpciones
);
}}
placeholder={`Opción ${opIdx + 1}`}
className={{
container: 'me-2 flex-grow-1 mb-0',
input: 'form-control',
}}
/>
<Button
variant="danger"
size="sm"
icon="trash"
outline
onClick={() => {
const nuevasOpciones = [...(pregunta.opciones || [])];
nuevasOpciones.splice(opIdx, 1);
handleUpdatePregunta(
seccionIdx,
preguntaIdx,
'opciones',
nuevasOpciones
);
}}
disabled={pregunta.opciones?.length === 1}
title={
pregunta.opciones?.length === 1
? 'Una pregunta cerrada debe tener al menos una opción'
: 'Eliminar opción'
}
/>
</div>
))}
{(!pregunta.opciones || pregunta.opciones.length === 0) && (
<div className="text-muted small">
<i className="bi bi-info-circle me-1"></i>
Esta pregunta cerrada no tiene opciones. Agrega al menos
una opción.
</div>
)}
</div>
)}
</div>
))}
<Button
outline
className="mt-2"
size="sm"
icon="plus"
onClick={() => agregarPregunta(seccionIdx)}
>
Agregar pregunta
</Button>
</div>
))}
</div>
);
return {
informacionBasica: renderInformacionBasica,
seccionesYPreguntas: renderSeccionesYPreguntas,
};
}
+243
View File
@@ -0,0 +1,243 @@
'use client';
import ImageUploader from '@/components/banner-uploader';
import Button from '@/components/button';
import Input from '@/components/input';
import Select from '@/components/select';
import EventoCardPreview from '@/components/evento/evento-card-preview';
import { CreateEventoType } from '@/types/create-evento';
import { GetEventoWithCuestionariosWithCupos } from '@/types/evento';
import axiosInstance from '@/utils/api-config';
import { tipoEventos } from '@/utils/arrays';
import { formatDateLocal } from '@/utils/date-utils';
import { getAxiosError } from '@/utils/errors-utils';
import { commands } from '@uiw/react-md-editor';
import dynamic from 'next/dynamic';
import React, { useState } from 'react';
import toast from 'react-hot-toast';
const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false });
const MAX_LENGTH = 500;
interface EditEventoProps {
evento: GetEventoWithCuestionariosWithCupos;
handleChange: (field: keyof CreateEventoType, value: string | Date) => void;
handleOnChange: (
eventoActualizado: GetEventoWithCuestionariosWithCupos
) => void;
}
export default function EditEvento({
evento,
handleChange,
handleOnChange,
}: EditEventoProps) {
const tipo_usuario = sessionStorage.getItem('axxxd')?.replace(/"/g, '') || '';
const [loading, setLoading] = useState(false);
const [nuevoBanner, setNuevoBanner] = useState<File | null>(null);
const handleOnSave = async () => {
setLoading(true);
try {
// 1. Subir nuevo banner si fue cambiado
if (nuevoBanner) {
const formData = new FormData();
formData.append('banner', nuevoBanner);
await axiosInstance.post(
`/evento/${evento.id_evento}/banner`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
);
}
// 2. Actualizar el evento (sin incluir 'banner')
const res = await axiosInstance.patch(`/evento/${evento.id_evento}`, {
nombre_evento: evento.nombre_evento,
descripcion_evento: evento.descripcion_evento,
tipo_evento: evento.tipo_evento,
fecha_inicio: evento.fecha_inicio,
fecha_fin: evento.fecha_fin,
});
toast.success('Evento actualizado correctamente');
handleOnChange(res.data); // Actualiza en el padre
} catch (error) {
const msg = getAxiosError(error);
toast.error(`Error al actualizar el evento: ${msg.message}`);
} finally {
setLoading(false);
}
};
// Convertir evento a CreateEventoType para el preview
const eventoPreview: CreateEventoType = {
tipo_evento: evento.tipo_evento,
nombre_evento: evento.nombre_evento,
descripcion_evento: evento.descripcion_evento,
fecha_inicio: new Date(evento.fecha_inicio),
fecha_fin: new Date(evento.fecha_fin),
};
return (
<div className="container-fluid my-4">
{/* Header */}
<div className="row mb-4">
<div className="col-12">
<div className="p-4 border rounded bg-light">
<h2 className="mb-2">Editar Información del Evento</h2>
<p className="mb-0 text-muted">
Modifica los datos del evento que desees actualizar y confirmalos.
</p>
</div>
</div>
</div>
<div className="row">
{/* Columna Izquierda - Formulario */}
<div className="col-xl-7 col-lg-6">
<div className="pe-lg-4">
<div className="p-4">
{/* Banner */}
<div className="mb-4">
{tipo_usuario === 'administrador' && (
<>
<ImageUploader
label="Banner del evento"
defaultBanner={
evento.banner
? `${process.env.NEXT_PUBLIC_API_URL}/banners/${evento.banner}`
: undefined
}
onChange={(file) => {
setNuevoBanner(file);
}}
/>
<p className="text-muted mt-2 small">
<strong>
<i className="bi bi-info-circle-fill me-2"></i>
Esta es la imagen que se verá en el carrusel del evento.
</strong>
<br />
<strong>
<i className="bi bi-image me-2"></i>
Dimensiones recomendadas: 1200x600 píxeles.
</strong>
<br />
<strong>
<i className="bi bi-file-earmark-image me-2"></i>
Formatos soportados: PNG, JPG, JPEG.
</strong>
</p>
</>
)}
</div>
{/* Detalles del evento */}
<div className="col-12">
<h4 className="mb-3">Detalles del Evento</h4>
<Input
label="Nombre del evento"
required
value={evento.nombre_evento}
onChange={(e) =>
handleChange('nombre_evento', e.target.value)
}
disabled={tipo_usuario !== 'administrador'}
/>
<div className="mb-4">
<label className="form-label">Descripción del evento</label>
<div data-color-mode="light">
<MDEditor
value={evento.descripcion_evento ?? ''}
onChange={(value) => {
if (tipo_usuario !== 'administrador') return;
const texto = value || '';
if (texto.length <= MAX_LENGTH) {
handleChange('descripcion_evento', texto);
}
}}
height={200}
commands={[commands.bold, commands.italic, commands.hr]}
/>
</div>
</div>
<Select
label="Tipo de evento"
value={evento.tipo_evento}
placeholder="Selecciona un tipo de evento"
onChange={(e) => handleChange('tipo_evento', e.target.value)}
options={tipoEventos}
disabled={tipo_usuario !== 'administrador'}
/>
<div className="row">
<div className="col-md-6">
<Input
label="Fecha de inicio"
type="datetime-local"
className={{ container: 'mb-3' }}
value={formatDateLocal(new Date(evento.fecha_inicio))}
onChange={(e) =>
handleChange('fecha_inicio', new Date(e.target.value))
}
/>
</div>
<div className="col-md-6">
<Input
label="Fecha de fin"
type="datetime-local"
className={{ container: 'mb-3' }}
value={formatDateLocal(new Date(evento.fecha_fin))}
onChange={(e) =>
handleChange('fecha_fin', new Date(e.target.value))
}
/>
</div>
</div>
</div>
{/* Botón de guardar */}
{tipo_usuario === "administrador" && <div className="d-flex justify-content-end mt-4">
<Button icon="save" disabled={loading} onClick={handleOnSave}>
{loading ? 'Guardando...' : 'Guardar Cambios'}
</Button>
</div>}
</div>
</div>
</div>
{/* Columna Derecha - Preview */}
<div className="col-xl-5 col-lg-6">
<div className="ps-lg-4">
<div className="sticky-top" style={{ top: '20px' }}>
<h4 className="mb-3 d-none d-lg-block">Preview del Evento</h4>
<div className="d-lg-none mt-4">
<h4 className="mb-3">Preview del Evento</h4>
</div>
<EventoCardPreview
evento={eventoPreview}
eventoBanner={nuevoBanner}
existingBanner={evento.banner}
cuestionariosCount={evento.cuestionarios?.length || 0}
/>
<div className="alert alert-info text-center mt-3">
<strong>Vista previa:</strong> Así se verá tu evento en las
listas de eventos.
</div>
</div>
</div>
</div>
</div>
</div>
);
}
+260
View File
@@ -0,0 +1,260 @@
import ImageUploader from '@/components/banner-uploader';
import Button from '@/components/button';
import SimpleInput from '@/components/input';
import FormularioCardPreview from '@/components/formulario/formulario-card-preview';
import { GetCuestionario } from '@/types/cuestionario';
import { GetEvento } from '@/types/evento';
import axiosInstance from '@/utils/api-config';
import { formatDateLocal } from '@/utils/date-utils';
import { getAxiosError } from '@/utils/errors-utils';
import MDEditor, { commands } from '@uiw/react-md-editor';
import React, { useState } from 'react';
import toast from 'react-hot-toast';
interface EditFormularioProps {
cuestionario: GetCuestionario;
evento: GetEvento | null;
handleChange: (field: keyof GetCuestionario, value: string | Date) => void;
handleOnChange: (eventoActualizado: GetCuestionario) => void;
}
export default function EditFormulario({
cuestionario,
evento,
handleChange,
}: EditFormularioProps) {
const tipo_usuario = sessionStorage.getItem('axxxd')?.replace(/"/g, '') || '';
const [loading, setLoading] = useState(false);
const [nuevoBanner, setNuevoBanner] = useState<File | null>(null);
const handleOnSave = async () => {
setLoading(true);
try {
// 1. Subir nuevo banner si fue cambiado
if (nuevoBanner) {
const formData = new FormData();
formData.append('banner', nuevoBanner);
await axiosInstance.post(
`/cuestionario/${cuestionario.id_cuestionario}/banner`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
);
}
// 2. Actualizar el evento (sin incluir 'banner')
const res = await axiosInstance.patch(
`/cuestionario/${cuestionario.id_cuestionario}`,
{
nombre_form: cuestionario.nombre_form,
descripcion: cuestionario.descripcion,
fecha_inicio: cuestionario.fecha_inicio,
fecha_fin: cuestionario.fecha_fin,
cupo_maximo: Number(cuestionario.cupo_maximo) || null,
mensaje_correo: cuestionario.mensaje_correo || null,
}
);
console.log('Evento actualizado:', res.data);
toast.success('Evento actualizado correctamente');
} catch (error) {
const msg = getAxiosError(error);
toast.error(`Error al actualizar el evento: ${msg.message}`);
} finally {
setLoading(false);
}
};
return (
<div className="container-fluid my-4">
{/* Header */}
<div className="row mb-4">
<div className="col-12">
<div className="p-4 border rounded bg-light">
<h2 className="mb-2">Editar Información del Formulario</h2>
<p className="mb-0 text-muted">
Modifica los datos del formulario que desees actualizar y
confirmalos.
</p>
</div>
</div>
</div>
<div className="row">
{/* Columna Izquierda - Formulario */}
<div className="col-xl-7 col-lg-6">
<div className="pe-lg-4">
<div className="p-4">
<div className="row">
{/* Banner */}
<div className="col-12">
<div className="mb-4">
<ImageUploader
label="Banner del formulario"
defaultBanner={
cuestionario.banner
? `${process.env.NEXT_PUBLIC_API_URL}/banners/${cuestionario.banner}`
: undefined
}
onChange={(file) => {
setNuevoBanner(file);
}}
/>
<p className="text-muted mt-2 small">
<strong>
<i className="bi bi-info-circle-fill me-2"></i>
Esta es la imagen que se usara para la carta de
presentación del formulario.
</strong>
<br />
<strong>
<i className="bi bi-image me-2"></i>
Dimensiones recomendadas: 1200x600 píxeles.
</strong>
<br />
<strong>
<i className="bi bi-file-earmark-image me-2"></i>
Formatos soportados: PNG, JPG, JPEG.
</strong>
</p>
{/* Alert sobre imagen por defecto */}
{!cuestionario.banner && !nuevoBanner && evento?.banner && (
<div className="alert alert-info mt-3">
<i className="bi bi-info-circle me-2"></i>
<strong>Imagen por defecto:</strong> Si no seleccionas
una imagen específica para este formulario, se utilizará
automáticamente la imagen del evento.
</div>
)}
</div>
</div>
{/* Detalles del formulario */}
<div className="col-12">
<h4 className="mb-3">Detalles del Formulario</h4>
<SimpleInput
label="Nombre del formulario"
required
value={cuestionario.nombre_form}
onChange={(e) =>
handleChange('nombre_form', e.target.value)
}
/>
<SimpleInput
label="Mensaje de correo"
value={cuestionario.mensaje_correo ?? ''}
onChange={(e) =>
handleChange('mensaje_correo', e.target.value)
}
/>
<SimpleInput
label="Cupo máximo (Si no se requiere, dejar en blanco)"
type="number"
value={cuestionario.cupo_maximo ?? ''}
onChange={(e) =>
handleChange(
'cupo_maximo',
e.target.value ? e.target.value : ''
)
}
/>
<div className="mb-4">
<label className="form-label">
Descripción del formulario
</label>
<div data-color-mode="light">
<MDEditor
value={cuestionario.descripcion ?? ''}
onChange={(value) => {
const texto = value || '';
if (texto.length <= 500) {
handleChange(
'descripcion' as keyof GetCuestionario,
texto
);
}
}}
height={200}
commands={[commands.bold, commands.italic, commands.hr]}
/>
</div>
</div>
<div className="row">
<div className="col-md-6">
<SimpleInput
label="Fecha de inicio"
type="datetime-local"
className={{ container: 'mb-3' }}
value={formatDateLocal(
new Date(cuestionario.fecha_inicio)
)}
onChange={(e) =>
handleChange('fecha_inicio', new Date(e.target.value))
}
/>
</div>
<div className="col-md-6">
<SimpleInput
label="Fecha de fin"
type="datetime-local"
className={{ container: 'mb-3' }}
value={formatDateLocal(
new Date(cuestionario.fecha_fin)
)}
onChange={(e) =>
handleChange('fecha_fin', new Date(e.target.value))
}
/>
</div>
</div>
</div>
</div>
{/* Botón de guardar */}
{tipo_usuario === "administrador" && (
<div className="d-flex justify-content-end mt-4">
<Button icon="save" disabled={loading} onClick={handleOnSave}>
{loading ? 'Guardando...' : 'Guardar Cambios'}
</Button>
</div>
)}
</div>
</div>
</div>
{/* Columna Derecha - Preview */}
<div className="col-xl-5 col-lg-6">
<div className="ps-lg-4">
<div className="sticky-top" style={{ top: '20px' }}>
<h4 className="mb-3 d-none d-lg-block">Preview del Formulario</h4>
<div className="d-lg-none mt-4">
<h4 className="mb-3">Preview del Formulario</h4>
</div>
<FormularioCardPreview
cuestionario={cuestionario}
evento={evento}
nuevoBanner={nuevoBanner}
/>
<div className="alert alert-info text-center mt-3">
<strong>Vista previa:</strong> Así se verá tu formulario en las
listas de formularios.
</div>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,132 @@
'use client';
import Image from 'next/image';
import Link from 'next/link';
import { SubmitResponse } from '@/types/submit';
interface Props {
response: SubmitResponse;
qrImageUrl: string | null;
nombreEvento?: string;
nombreFormulario?: string;
}
export default function FormularioExito({
response,
qrImageUrl,
nombreEvento,
nombreFormulario,
}: Props) {
return (
<div className="container my-5">
<div className="row justify-content-center">
<div className="col-12 col-lg-10 col-xl-8">
<div className="card shadow-sm">
<div className="card-body">
<div className="row g-4">
<div className="col-md-6">
<div className="h-100 d-flex flex-column justify-content-center">
<div className="text-center mb-4">
<div className="mb-3">
<div
className="d-inline-flex align-items-center justify-content-center bg-success rounded-circle"
style={{ width: '60px', height: '60px' }}
>
<i
className="bi bi-check-lg text-white"
style={{ fontSize: '2rem' }}
></i>
</div>
</div>
<h2 className="fw-bold text-success mb-2">¡Felicidades!</h2>
<p className="text-muted mb-0">
Has completado exitosamente el formulario
</p>
</div>
{nombreEvento && (
<div className="mb-3">
<div className="d-flex align-items-center mb-2">
<i
className="bi bi-calendar-event text-primary me-3"
style={{ fontSize: '1.3rem' }}
></i>
<h6 className="mb-0 text-primary">Evento</h6>
</div>
<p className="mb-0 fs-6 fw-semibold ms-5">{nombreEvento}</p>
</div>
)}
{nombreFormulario && (
<div className="mb-3">
<div className="d-flex align-items-center mb-2">
<i
className="bi bi-file-text text-info me-3"
style={{ fontSize: '1.3rem' }}
></i>
<h6 className="mb-0 text-info">Formulario</h6>
</div>
<p className="mb-0 fs-6 fw-semibold ms-5">{nombreFormulario}</p>
</div>
)}
{response.message && (
<div className="mb-3">
<div className="alert alert-info" role="alert">
<div className="d-flex align-items-start">
<i className="bi bi-info-circle me-2 mt-1"></i>
<div>
<strong>Mensaje:</strong>
<p className="mb-0 mt-1">{response.message}</p>
</div>
</div>
</div>
</div>
)}
</div>
</div>
<div className="col-md-6">
<div className="h-100 d-flex flex-column justify-content-center align-items-center">
{qrImageUrl ? (
<div className="text-center">
<h5 className="mb-3 text-secondary">
<i className="bi bi-qr-code me-2"></i>
Código QR
</h5>
<div className="p-3 bg-white rounded shadow-sm">
<Image
src={qrImageUrl}
alt="Código QR"
width={250}
height={250}
className="rounded"
style={{ maxWidth: '100%', height: 'auto' }}
unoptimized
/>
</div>
</div>
) : (
<div className="text-center text-muted">
<i
className="bi bi-qr-code-scan"
style={{ fontSize: '4rem' }}
></i>
<p className="mt-3 mb-0">No hay código QR disponible</p>
</div>
)}
</div>
</div>
</div>
</div>
</div>
<div className="text-center mt-4">
<Link href="/" className="btn btn-primary btn-lg me-3">
Ir al inicio
</Link>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,591 @@
import React, { useState, useEffect } from 'react';
import { useGetApi } from '@/hooks/use-get-api';
import { GetCuestionario } from '@/types/evento';
import SimpleInput from '@/components/input';
import RadioOptionGroup, { RadioOption } from '@/components/radio-option-group';
import axiosInstance from '@/utils/api-config';
import PrefetchAbiertaCorta from './prefetch-abierta-corta';
import Button from '@/components/button';
import toast from 'react-hot-toast';
import { useRouter } from 'next/navigation';
import { getAxiosError } from '@/utils/errors-utils';
import { SubmitResponse } from '@/types/submit';
export type UsuarioDataResponse = {
cuenta: string | null;
nombre: string | null;
apellidos: string | null;
carrera: string | null;
genero: string | null;
rfc?: string | null; // Solo para trabajadores
};
type UsuarioData = {
nombre: string; // alumno o trabajador
apellidos: string; // alumno o trabajador
correo: string; // alumno o trabajador (en los alumnos se usa el id_ncuenta en trabajadores ellos deben escribirlo)
genero: 'M' | 'F'; // alumno o trabajador
carrera: string; // solo para alumnos
rfc?: string; // solo para trabajadores se usa para buscar información de cuenta
};
const confirmativeOption = ['Sí', 'Si', 'Si, claro', 'Claro que sí'];
const negativeOption = ['No', 'No, gracias', 'No, de momento'];
type RespuestaFormulario = Record<string, string | number>; // id_pregunta: respuesta
export default function FormularioRegistro({
id_cuestionario,
handleSubmitFormulario,
}: {
evento: string;
cuestionario: string;
id_evento: number;
id_cuestionario: number;
handleSubmitFormulario: (data: SubmitResponse) => void;
}) {
// ------------------------
// Estados
// ------------------------
const [isComunidad, setIsComunidad] = useState<RadioOption<number>>();
const [respuestas, setRespuestas] = useState<RespuestaFormulario>({});
const [cuentaInfo, setCuentaInfo] = useState<UsuarioData | null>(null);
const [isSending, setIsSending] = useState(false);
const router = useRouter();
// ------------------------
// Carga del cuestionario
// ------------------------
const { data, error } = useGetApi<GetCuestionario>(
`/cuestionario/${id_cuestionario}/formulario`
);
// ------------------------
// Extracción de preguntas clave
// ------------------------
const preguntas = data?.cuestionario.secciones.flatMap((seccion) =>
seccion.preguntas.map((pregunta) => pregunta.pregunta)
);
const preguntaComunidad = preguntas?.find(
(pregunta) =>
pregunta.validacion === 'comunidad_alumno' ||
pregunta.validacion === 'comunidad_trabajador'
);
const preguntaCuenta = preguntas?.find(
(pregunta) =>
pregunta.validacion === 'cuenta_alumno' ||
pregunta.validacion === 'cuenta_trabajador' ||
pregunta.validacion === 'rfc'
);
// Solución: Agregar estado para controlar si ya se hizo la búsqueda
const [cuentaBuscada, setCuentaBuscada] = useState<string>(''); // Nuevo estado
// ------------------------
// Efecto: buscar información de cuenta
// ------------------------
useEffect(() => {
const cuentaId = preguntaCuenta?.id_pregunta ?? '';
const cuenta = respuestas[cuentaId];
const esCuentaAlumno = preguntaCuenta?.validacion === 'cuenta_alumno';
const esCuentaTrabajador =
preguntaCuenta?.validacion === 'cuenta_trabajador' ||
preguntaCuenta?.validacion === 'rfc';
const puedeBuscar =
cuenta &&
typeof cuenta === 'string' &&
((esCuentaAlumno && cuenta.length === 9) ||
(esCuentaTrabajador && cuenta.length === 10));
const fetchCuentaInfo = async () => {
try {
const endpoint = esCuentaAlumno
? `/alumnos/${cuenta}`
: `/trabajadores/${cuenta}`;
const res = await axiosInstance.get<UsuarioDataResponse>(endpoint);
console.log('Datos de cuenta obtenidos:', res.data);
// Verificar si realmente hay datos válidos
const hayDatosValidos =
res.data.nombre ||
res.data.apellidos ||
res.data.carrera ||
res.data.genero;
if (hayDatosValidos) {
setCuentaInfo({
nombre: res.data.nombre ? res.data.nombre : '',
apellidos: res.data.apellidos ? res.data.apellidos : '',
correo: res.data.cuenta
? `${res.data.cuenta}@pcpuma.acatlan.unam.mx`
: '',
genero:
res.data.genero === 'M' || res.data.genero === 'F'
? res.data.genero
: 'M',
carrera: res.data.carrera ? res.data.carrera : '',
});
} else {
// Si no hay datos válidos, establecer como null
setCuentaInfo(null);
}
// Marcar que ya se buscó esta cuenta
setCuentaBuscada(cuenta as string);
} catch (error) {
console.error('Error al obtener datos de cuenta:', error);
setCuentaInfo(null);
setCuentaBuscada(cuenta as string); // También marcar en caso de error
}
};
// Solo buscar si puede buscar Y no se ha buscado ya esta cuenta
if (puedeBuscar && cuentaBuscada !== cuenta) {
fetchCuentaInfo();
} else if (!puedeBuscar) {
setCuentaInfo(null);
setCuentaBuscada(''); // Reset cuando no se puede buscar
}
}, [
respuestas,
preguntaCuenta?.id_pregunta,
preguntaCuenta?.validacion,
cuentaBuscada,
]);
// También resetear cuentaBuscada cuando cambie el número de cuenta
useEffect(() => {
const cuentaId = preguntaCuenta?.id_pregunta ?? '';
const cuenta = respuestas[cuentaId];
// Si la cuenta cambió, resetear el estado de búsqueda
if (cuentaBuscada && cuenta !== cuentaBuscada) {
setCuentaBuscada('');
}
}, [respuestas, preguntaCuenta?.id_pregunta, cuentaBuscada]);
// ------------------------
// Efecto: precargar respuestas si hay cuentaInfo
// ------------------------
useEffect(() => {
if (!cuentaInfo) {
if (data?.cuestionario.secciones) {
const preguntas = data.cuestionario.secciones.flatMap((seccion) =>
seccion.preguntas.map((pregunta) => pregunta.pregunta)
);
const idsPrefetch = preguntas
.filter((pregunta) =>
[
'nombre',
'apellidos',
'correo',
'institucion',
'carrera',
'genero',
].includes(pregunta.validacion)
)
.map((pregunta) => pregunta.id_pregunta);
setRespuestas((prev) => {
const nuevas = { ...prev };
idsPrefetch.forEach((id) => {
delete nuevas[id];
});
return nuevas;
});
}
return;
}
const nuevasRespuestas: RespuestaFormulario = {};
if (data?.cuestionario.secciones) {
const preguntas = data.cuestionario.secciones.flatMap((seccion) =>
seccion.preguntas.map((pregunta) => pregunta.pregunta)
);
for (const pregunta of preguntas) {
const id = pregunta.id_pregunta;
switch (pregunta.validacion) {
case 'nombre':
nuevasRespuestas[id] = cuentaInfo.nombre;
break;
case 'apellidos':
nuevasRespuestas[id] = cuentaInfo.apellidos;
break;
case 'correo':
nuevasRespuestas[id] = cuentaInfo.correo;
break;
case 'institucion':
nuevasRespuestas[id] = 'FES Acatlán';
break;
case 'carrera':
nuevasRespuestas[id] = cuentaInfo.carrera;
break;
case 'genero':
const generoTexto =
cuentaInfo.genero === 'M' ? 'Masculino' : 'Femenino';
const opcion = pregunta.opciones?.find(
(op) =>
op.opcion.opcion.toLowerCase() === generoTexto.toLowerCase()
);
if (opcion) {
nuevasRespuestas[id] = String(opcion.id_opcion);
}
break;
}
}
setRespuestas((prev) => ({
...prev,
...nuevasRespuestas,
}));
}
}, [cuentaInfo, data?.cuestionario.secciones]);
// ------------------------
// Funciones auxiliares
// ------------------------
const actualizarRespuesta = (idPregunta: string, valor: string) => {
setRespuestas((prev) => ({ ...prev, [idPregunta]: valor }));
};
const handleOnSubmit = async () => {
setIsSending(true);
if (!data?.cuestionario.id_cuestionario) {
alert('No se ha cargado correctamente el formulario');
return;
}
const preguntasObligatorias =
data?.cuestionario.secciones.flatMap((seccion) =>
seccion.preguntas
.filter((pregunta) => pregunta.pregunta.obligatoria)
.map((pregunta) => pregunta.pregunta)
) || [];
const faltantes = preguntasObligatorias.filter(
(pregunta) =>
respuestas[pregunta.id_pregunta] === undefined ||
respuestas[pregunta.id_pregunta] === '' ||
respuestas[pregunta.id_pregunta] === null
);
const preguntaCorreo = preguntasObligatorias.find(
(pregunta) =>
pregunta.validacion === 'correo' ||
pregunta.validacion === 'correo_institucional'
);
if (faltantes.length > 0) {
toast.error('Por favor responde todas las preguntas obligatorias.');
setIsSending(false);
return;
}
// Buscar un posible correo en las respuestas si no hay cuentaInfo
let correo =
cuentaInfo?.correo === respuestas[preguntaCorreo?.id_pregunta ?? '']
? cuentaInfo?.correo
: respuestas[preguntaCorreo?.id_pregunta ?? ''];
if (!correo) {
// Buscar en las respuestas una que parezca correo
const correoRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
for (const valor of Object.values(respuestas)) {
if (typeof valor === 'string' && correoRegex.test(valor)) {
correo = valor;
break;
}
}
}
const payload = {
id_cuestionario: id_cuestionario,
correo: correo || '',
fecha_envio: new Date().toISOString(),
respuestas: Object.entries(respuestas).map(([id_pregunta, valor]) => ({
id_pregunta: Number(id_pregunta),
valor: isNaN(Number(valor)) ? valor : Number(valor),
})),
};
try {
const res = await axiosInstance.post<SubmitResponse>(
'/cuestionario-respondido/submit',
payload
);
toast(
() => (
<span
className="fs-4 pc-3 py-2"
style={{
maxWidth: '300px',
wordWrap: 'break-word',
whiteSpace: 'normal',
}}
>
{res.data.message.includes('ya ha respondido') ? (
<>
<i className="bi bi-exclamation-circle-fill text-warning me-2"></i>
Respuestas Enviadas Con Éxito
</>
) : (
<>
<i className="bi bi-check-circle-fill text-success me-2"></i>
{res.data.message || '¡Formulario enviado!'}
</>
)}
</span>
),
{
duration: 6000,
position: 'bottom-center',
style: {
maxWidth: '425px',
},
}
);
handleSubmitFormulario(res.data);
} catch (error) {
console.log(getAxiosError(error));
toast.error('Hubo un error al enviar el formulario', {
duration: 5000,
});
router.push('/');
}
};
// ------------------------
// Condición para mostrar el formulario
// ------------------------
const mostrarFormularioRestante =
!preguntaComunidad || // Si no hay pregunta de comunidad, mostrar el formulario
(isComunidad &&
confirmativeOption.some((opt) =>
isComunidad.label?.toLowerCase().includes(opt.toLowerCase())
) &&
!!cuentaInfo) ||
(isComunidad &&
negativeOption.some((opt) =>
isComunidad.label?.toLowerCase().includes(opt.toLowerCase())
));
if (error) {
console.error('Error al cargar el cuestionario:', error);
return <div>Error al cargar el cuestionario</div>;
}
return (
<div>
{preguntaComunidad && (
<div className="my-4">
<label
className={`form-label ${
preguntaComunidad.obligatoria ? 'required' : ''
}`}
>
{preguntaComunidad.pregunta}
</label>
<RadioOptionGroup
name="comunidad"
options={preguntaComunidad.opciones.map((op) => ({
label: op.opcion.opcion,
value: op.id_opcion,
}))}
selectedValue={isComunidad?.value}
onChange={(opt) => {
console.log('Opción comunidad seleccionada:', opt);
setIsComunidad(opt);
actualizarRespuesta(
preguntaComunidad.id_pregunta.toString(),
String(opt.value)
);
}}
/>
</div>
)}
{isComunidad &&
confirmativeOption.some((opt) =>
isComunidad.label?.toLowerCase().includes(opt.toLowerCase())
) &&
preguntaCuenta && (
<div className="my-4">
<label
className={`form-label ${
preguntaCuenta.obligatoria ? 'required' : ''
}`}
>
{preguntaCuenta.pregunta}
</label>
<SimpleInput
type="text"
name="cuenta"
placeholder={
preguntaCuenta.validacion === 'cuenta_alumno'
? 'Ingrese su cuenta'
: preguntaCuenta?.validacion === 'rfc'
? 'Ingrese su RFC sin homoclave'
: preguntaCuenta?.validacion === 'cuenta_trabajador'
? 'Ingrese su numero de trabajador'
: 'Ingrese su ' + preguntaCuenta.pregunta
}
maxLength={10}
value={respuestas[preguntaCuenta.id_pregunta] || ''}
onChange={(e) =>
setRespuestas((prev) => ({
...prev,
[preguntaCuenta.id_pregunta]: e.target.value,
}))
}
/>
</div>
)}
{mostrarFormularioRestante &&
data?.cuestionario.secciones.map((seccion, i) => (
<div key={i}>
{seccion.preguntas.map((pregunta) => {
// Evitar renderizar las preguntas ya manejadas
if (
pregunta.pregunta.id_pregunta ===
preguntaComunidad?.id_pregunta ||
pregunta.pregunta.id_pregunta === preguntaCuenta?.id_pregunta
)
return null;
if (
pregunta.pregunta.tipo_pregunta.tipo_pregunta ===
'Abierta (Respuesta corta)'
) {
return (
<PrefetchAbiertaCorta
key={pregunta.pregunta.id_pregunta}
obligatorio={pregunta.pregunta.obligatoria}
idPregunta={pregunta.pregunta.id_pregunta}
enunciado={pregunta.pregunta.pregunta}
validacion={pregunta.pregunta.validacion}
respuestas={respuestas}
cuentaInfo={cuentaInfo}
isComunidad={isComunidad?.label}
onChange={actualizarRespuesta}
preguntaComunidad={preguntaComunidad?.validacion ?? ''}
/>
);
}
if (
pregunta.pregunta.tipo_pregunta.tipo_pregunta ===
'Abierta (Parrafo)'
) {
return (
<div key={pregunta.pregunta.id_pregunta} className="my-4">
<label
className={`form-label ${
pregunta.pregunta.obligatoria ? 'required' : ''
}`}
>
{pregunta.pregunta.pregunta}
</label>
<SimpleInput
type="textarea"
name={`respuesta_${pregunta.pregunta.id_pregunta}`}
placeholder="Ingrese una respuesta"
value={respuestas[pregunta.pregunta.id_pregunta] || ''}
onChange={(e) =>
setRespuestas((prev) => ({
...prev,
[pregunta.pregunta.id_pregunta]: e.target.value,
}))
}
/>
</div>
);
}
if (pregunta.pregunta.tipo_pregunta.tipo_pregunta === 'Cerrada') {
const opciones: RadioOption<number>[] =
pregunta.pregunta.opciones.map((op) => ({
label: op.opcion.opcion,
value: op.id_opcion,
}));
let respuestaActual =
respuestas[`${pregunta.pregunta.id_pregunta}`];
if (
(!preguntaComunidad ||
(confirmativeOption.includes(isComunidad?.label || '') &&
cuentaInfo)) &&
respuestaActual === undefined // solo si no ha respondido
) {
if (pregunta.pregunta.validacion === 'genero' && cuentaInfo) {
const generoTexto =
cuentaInfo.genero === 'M' ? 'Masculino' : 'Femenino';
const opcionGenero = pregunta.pregunta.opciones.find(
(op) =>
op.opcion.opcion.toLowerCase() ===
generoTexto.toLowerCase()
);
if (opcionGenero) {
respuestaActual = String(opcionGenero.id_opcion);
}
}
}
return (
<div key={pregunta.pregunta.id_pregunta}>
<label
className={`form-label ${
pregunta.pregunta.obligatoria ? 'required' : ''
}`}
>
{pregunta.pregunta.pregunta}
</label>
<RadioOptionGroup
name={`pregunta_${pregunta.pregunta.id_pregunta}`}
options={opciones}
selectedValue={
respuestaActual ? Number(respuestaActual) : undefined
}
onChange={(opt) => {
actualizarRespuesta(
`${pregunta.pregunta.id_pregunta}`,
String(opt.value)
);
}}
/>
</div>
);
}
return (
<div key={pregunta.pregunta.id_pregunta} className="my-4">
<label
className={`form-label ${
pregunta.pregunta.obligatoria ? 'required' : ''
}`}
>
{pregunta.pregunta.pregunta}
</label>
</div>
);
})}
</div>
))}
<Button
className="btn btn-primary"
onClick={handleOnSubmit}
disabled={!mostrarFormularioRestante || isSending}
>
{isSending ? 'Enviando...' : 'Enviar Formulario'}
</Button>
</div>
);
}
@@ -0,0 +1,247 @@
import React from 'react';
import { SubmitResponse } from '@/types/submit';
import SimpleInput from '@/components/input';
import RadioOptionGroup, { RadioOption } from '@/components/radio-option-group';
import Button from '@/components/button';
import toast from 'react-hot-toast';
import { useFormulario } from '@/context/formulario';
export type UsuarioDataResponse = {
cuenta: string | null;
nombre: string | null;
apellidos: string | null;
carrera: string | null;
genero: string | null;
rfc?: string | null;
};
export default function FormularioRegistro({
handleSubmitFormulario,
}: {
evento: string;
cuestionario: string;
id_evento: number;
id_cuestionario: number;
handleSubmitFormulario: (data: SubmitResponse) => void;
}) {
const {
state,
preguntaComunidad,
preguntaCuenta,
esComunidadSi,
esExclusivo,
mostrarFormularioCompleto,
setRespuesta,
submitFormulario,
} = useFormulario();
const { data, loadingData, errorData, respuestas, isSending, errorBusqueda, loadingBusqueda } = state;
const comunidadSeleccionada = preguntaComunidad
? respuestas[preguntaComunidad.id_pregunta]
: null;
const handleOnSubmit = async () => {
if (!data?.cuestionario.id_cuestionario) {
toast.error('No se ha cargado correctamente el formulario');
return;
}
const preguntasObligatorias = data.cuestionario.secciones.flatMap((seccion) =>
seccion.preguntas
.filter((p) => p.pregunta.obligatoria)
.map((p) => p.pregunta)
);
const faltantes = preguntasObligatorias.filter(
(p) =>
respuestas[p.id_pregunta] === undefined ||
respuestas[p.id_pregunta] === '' ||
respuestas[p.id_pregunta] === null
);
if (faltantes.length > 0) {
toast.error('Por favor responde todas las preguntas obligatorias.');
return;
}
try {
const res = await submitFormulario(data.cuestionario.id_cuestionario);
toast.success(res.message || '¡Formulario enviado exitosamente!');
handleSubmitFormulario(res);
} catch {
toast.error('Hubo un error al enviar el formulario');
}
};
if (errorData) {
return <div>Error al cargar el cuestionario</div>;
}
if (loadingData || !data) {
return <div>Cargando...</div>;
}
return (
<div>
{/* Pregunta de comunidad */}
{preguntaComunidad && (
<div className="my-4">
<label className={`form-label ${preguntaComunidad.obligatoria ? 'required' : ''}`}>
{preguntaComunidad.pregunta}
</label>
<RadioOptionGroup
name="comunidad"
options={preguntaComunidad.opciones.map((op) => ({
label: op.opcion.opcion,
value: op.id_opcion,
}))}
selectedValue={comunidadSeleccionada ? Number(comunidadSeleccionada) : undefined}
onChange={(opt) =>
setRespuesta(preguntaComunidad.id_pregunta.toString(), String(opt.value))
}
/>
</div>
)}
{/* Pregunta de cuenta (si respondió "Sí" a comunidad, o en cuestionario exclusivo) */}
{(esComunidadSi || esExclusivo) && preguntaCuenta && (
<div className="my-4">
<label className={`form-label ${preguntaCuenta.obligatoria ? 'required' : ''}`}>
{preguntaCuenta.pregunta}
</label>
<SimpleInput
type="text"
name="cuenta"
placeholder={
preguntaCuenta.validacion === 'cuenta_alumno'
? 'Ingrese su número de cuenta (9 dígitos)'
: preguntaCuenta.validacion === 'cuenta_trabajador'
? 'Ingrese su número de trabajador (6 dígitos)'
: preguntaCuenta.validacion === 'rfc'
? 'Ingrese su RFC sin homoclave (10 caracteres)'
: `Ingrese su ${preguntaCuenta.pregunta}`
}
maxLength={
preguntaCuenta.validacion === 'rfc'
? 10
: preguntaCuenta.validacion === 'cuenta_alumno'
? 9
: 6
}
value={respuestas[preguntaCuenta.id_pregunta] || ''}
onChange={(e) =>
setRespuesta(preguntaCuenta.id_pregunta.toString(), e.target.value)
}
/>
{loadingBusqueda && (
<div className="text-muted small mt-1">
<span className="spinner-border spinner-border-sm me-1" />
Buscando...
</div>
)}
{errorBusqueda && (
<div className="alert alert-danger py-2 mt-2" role="alert">
<i className="bi bi-exclamation-circle me-2"></i>
{errorBusqueda}
</div>
)}
</div>
)}
{/* Resto del formulario */}
{mostrarFormularioCompleto &&
data.cuestionario.secciones.map((seccion, i) => (
<div key={i}>
{seccion.preguntas.map((pregunta) => {
if (
pregunta.pregunta.id_pregunta === preguntaComunidad?.id_pregunta ||
pregunta.pregunta.id_pregunta === preguntaCuenta?.id_pregunta
) {
return null;
}
const valor = respuestas[pregunta.pregunta.id_pregunta] || '';
if (pregunta.pregunta.tipo_pregunta.tipo_pregunta === 'Abierta (Respuesta corta)') {
return (
<div key={pregunta.pregunta.id_pregunta} className="my-4">
<label className={`form-label ${pregunta.pregunta.obligatoria ? 'required' : ''}`}>
{pregunta.pregunta.pregunta}
</label>
<SimpleInput
type="text"
name={`respuesta_${pregunta.pregunta.id_pregunta}`}
placeholder="Ingrese una respuesta"
value={valor}
onChange={(e) =>
setRespuesta(pregunta.pregunta.id_pregunta.toString(), e.target.value)
}
/>
</div>
);
}
if (pregunta.pregunta.tipo_pregunta.tipo_pregunta === 'Abierta (Parrafo)') {
return (
<div key={pregunta.pregunta.id_pregunta} className="my-4">
<label className={`form-label ${pregunta.pregunta.obligatoria ? 'required' : ''}`}>
{pregunta.pregunta.pregunta}
</label>
<SimpleInput
type="textarea"
name={`respuesta_${pregunta.pregunta.id_pregunta}`}
placeholder="Ingrese una respuesta"
value={valor}
onChange={(e) =>
setRespuesta(pregunta.pregunta.id_pregunta.toString(), e.target.value)
}
/>
</div>
);
}
if (
pregunta.pregunta.tipo_pregunta.tipo_pregunta === 'Cerrada' ||
pregunta.pregunta.tipo_pregunta.tipo_pregunta === 'Multiple'
) {
const opciones: RadioOption<number>[] = pregunta.pregunta.opciones.map((op) => ({
label: op.opcion.opcion,
value: op.id_opcion,
}));
return (
<div key={pregunta.pregunta.id_pregunta} className="my-4">
<label className={`form-label ${pregunta.pregunta.obligatoria ? 'required' : ''}`}>
{pregunta.pregunta.pregunta}
</label>
<RadioOptionGroup
name={`pregunta_${pregunta.pregunta.id_pregunta}`}
options={opciones}
selectedValue={valor ? Number(valor) : undefined}
onChange={(opt) =>
setRespuesta(pregunta.pregunta.id_pregunta.toString(), String(opt.value))
}
/>
</div>
);
}
return null;
})}
</div>
))}
{/* Botón de envío */}
{mostrarFormularioCompleto && (
<Button
className="btn btn-primary"
onClick={handleOnSubmit}
disabled={isSending}
>
{isSending ? 'Enviando...' : 'Enviar Formulario'}
</Button>
)}
</div>
);
}
@@ -0,0 +1,80 @@
import React from 'react';
import SimpleInput from '@/components/input';
type Props = {
idPregunta: number;
enunciado: string;
obligatorio?: boolean;
validacion?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
respuestas: Record<string, any>;
cuentaInfo: {
nombre: string;
apellidos: string;
correo: string;
genero: 'M' | 'F';
carrera: string;
} | null;
isComunidad: string | undefined;
onChange: (id: string, valor: string) => void;
preguntaComunidad: string;
};
const confirmativeOption = ['Sí', 'Si', 'Si, claro', 'Claro que sí'];
export default function PrefetchAbiertaCorta({
idPregunta,
enunciado,
validacion,
respuestas,
cuentaInfo,
isComunidad,
obligatorio,
onChange,
preguntaComunidad,
}: Props) {
console.log(preguntaComunidad);
let defaultValue = '';
let disabled = false;
if (confirmativeOption.includes(isComunidad ?? '') && cuentaInfo) {
if (validacion === 'nombre' && cuentaInfo.nombre) {
defaultValue = cuentaInfo.nombre;
disabled = true;
}
if (validacion === 'apellidos' && cuentaInfo.apellidos) {
defaultValue = cuentaInfo.apellidos;
disabled = true;
}
if (validacion === 'correo' && cuentaInfo.correo) {
defaultValue = cuentaInfo.correo;
if (preguntaComunidad !== 'comunidad_trabajador') {
disabled = true;
}
}
if (validacion === 'institucion') {
defaultValue = 'FES Acatlán';
disabled = true;
}
if (validacion === 'carrera' && cuentaInfo.carrera) {
defaultValue = cuentaInfo.carrera;
disabled = true;
}
}
return (
<div className="my-4">
<label className={`form-label ${obligatorio ? 'required' : ''}`}>
{enunciado}
</label>
<SimpleInput
type="text"
name={`respuesta_${idPregunta}`}
placeholder="Ingrese una respuesta"
value={respuestas[idPregunta] ?? defaultValue}
disabled={disabled}
onChange={(e) => onChange(idPregunta.toString(), e.target.value)}
/>
</div>
);
}
+268
View File
@@ -0,0 +1,268 @@
'use client';
import MarkdownRenderer from '@/components/markdown-render';
import { useGetApi } from '@/hooks/use-get-api';
import Image from 'next/image';
import React, { useState, useEffect } from 'react';
import FormularioRegistro from '../formulario/formulario-registro';
import FormularioExito from '../formulario/formulario-exito';
import { GetEventoWithCuestionario } from '@/types/evento';
import { formatearRangoFechas } from '@/utils/date-utils';
import { SubmitResponse } from '@/types/submit';
import { FormularioProvider } from '@/context/formulario';
export default function FormularioPage({
id_evento,
id_cuestionario,
}: {
id_evento: string;
id_cuestionario: string;
}) {
const [response, setResponse] = useState<SubmitResponse | null>(null);
const [qrImageUrl, setQrImageUrl] = useState<string | null>(null);
const { data, error } = useGetApi<GetEventoWithCuestionario>(
`/evento/${id_evento}/cuestionario/${id_cuestionario}`
);
// Procesar el QR buffer cuando se reciba la respuesta
useEffect(() => {
if (!response?.qr_buffer) {
setQrImageUrl(null);
return;
}
try {
const binaryString = atob(response.qr_buffer);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
const blob = new Blob([bytes], { type: 'image/png' });
const url = URL.createObjectURL(blob);
setQrImageUrl(url);
} catch (error) {
console.error('Error al procesar el QR buffer:', error);
setQrImageUrl(null);
}
}, [response?.qr_buffer]);
// Cleanup del QR cuando el componente se desmonte o cambie la respuesta
useEffect(() => {
return () => {
if (qrImageUrl) {
URL.revokeObjectURL(qrImageUrl);
}
};
}, [qrImageUrl]);
if (error) {
return <div>Error al cargar el cuestionario: {error.message}</div>;
}
const onSubmitFormulario = async (res: SubmitResponse) => {
setResponse(res);
};
// Si hay una respuesta exitosa, mostrar la vista de éxito
if (response && response.success) {
return (
<FormularioExito
response={response}
qrImageUrl={qrImageUrl}
nombreEvento={data?.nombre_evento}
nombreFormulario={data?.cuestionario.nombre_form}
/>
);
}
return (
<div className="container my-5">
{/* Sección de banners mejorada */}
<div className="text-center mb-4">
{data?.banner && data?.cuestionario.banner ? (
/* Ambos banners disponibles */
<div className="d-flex align-items-center justify-content-center gap-3 flex-wrap">
<div className="position-relative">
<Image
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${data.banner}`}
alt={`Banner del evento: ${data.nombre_evento}`}
width={350}
height={200}
className="img-fluid rounded shadow-sm"
style={{ objectFit: 'cover' }}
/>
<div className="position-absolute bottom-0 start-0 m-2">
<span className="badge bg-primary bg-opacity-90">
<i className="bi bi-calendar-event me-1"></i>
Evento
</span>
</div>
</div>
<div className="d-flex align-items-center justify-content-center">
<div
className="rounded-circle bg-light border d-flex align-items-center justify-content-center"
style={{ width: '50px', height: '50px' }}
>
<i className="bi bi-x-lg fs-4 text-muted"></i>
</div>
</div>
<div className="position-relative">
<Image
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${data.cuestionario.banner}`}
alt={`Banner del formulario: ${data.cuestionario.nombre_form}`}
width={350}
height={200}
className="img-fluid rounded shadow-sm"
style={{ objectFit: 'cover' }}
/>
<div className="position-absolute bottom-0 start-0 m-2">
<span className="badge bg-success bg-opacity-90">
<i className="bi bi-file-earmark-text me-1"></i>
Formulario
</span>
</div>
</div>
</div>
) : data?.banner ? (
/* Solo banner del evento */
<div>
<Image
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${data.banner}`}
alt={data?.nombre_evento || ''}
width={800}
height={400}
className="img-fluid rounded shadow"
/>
<p className="text-muted mt-2 small">
<i className="bi bi-calendar-event me-1"></i>
Banner del evento
</p>
</div>
) : data?.cuestionario.banner ? (
/* Solo banner del formulario */
<div>
<Image
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${data.cuestionario.banner}`}
alt={data?.cuestionario.nombre_form || ''}
width={800}
height={400}
className="img-fluid rounded shadow"
/>
<p className="text-muted mt-2 small">
<i className="bi bi-file-earmark-text me-1"></i>
Banner del formulario
</p>
</div>
) : null}
</div>
{/* Layout de dos columnas */}
<div className="row">
{/* Columna izquierda - Información */}
<div className="col-lg-5">
{/* Tarjeta: Evento principal */}
<section aria-labelledby="evento-heading" className="mb-1">
<div className="card border-0 shadow-sm">
<div className="card-body">
<h2
id="evento-heading"
className="h4 mb-2 d-flex align-items-center gap-2"
>
<i className="bi bi-calendar-event text-primary"></i>
Evento
</h2>
<h3 className="h3 mb-2">{data?.nombre_evento}</h3>
{data?.descripcion_evento && (
<div className="text-muted mb-3">
<MarkdownRenderer markdown={data?.descripcion_evento} />
</div>
)}
{data?.fecha_inicio && data?.fecha_fin && (
<p className="text-muted mb-0">
<i
className="bi bi-calendar-date me-1 text-primary"
aria-hidden="true"
></i>
{formatearRangoFechas(data.fecha_inicio, data.fecha_fin)}{' '}
horas.
</p>
)}
</div>
</div>
</section>
{/* Tarjeta: Sub-evento (Cuestionario) */}
{data?.cuestionario && (
<section aria-labelledby="cuestionario-heading">
<div className="card border-0 shadow-sm">
<div className="card-body">
<div className="d-flex justify-content-between align-items-start mb-2">
<h2
id="cuestionario-heading"
className="h4 mb-0 d-flex align-items-center gap-2"
>
<i className="bi bi-ui-checks-grid text-success"></i>
Registro a cuestionario
</h2>
<span className="badge bg-success-subtle text-success border border-success-subtle">
Sub-evento
</span>
</div>
<h3 className="h3 mb-1">{data.cuestionario.nombre_form}</h3>
{data.cuestionario.descripcion && (
<div className="text-muted mb-3">
<MarkdownRenderer
markdown={data.cuestionario.descripcion}
/>
</div>
)}
{data.cuestionario.fecha_inicio &&
data.cuestionario.fecha_fin && (
<p className="text-muted mb-0 fs-5 fw-bold text-decoration-underline">
<i
className="bi bi-clock me-1 text-primary"
aria-hidden="true"
></i>
{formatearRangoFechas(
data.cuestionario.fecha_inicio,
data.cuestionario.fecha_fin
)}{' '}
horas.
</p>
)}
</div>
</div>
</section>
)}
</div>
{/* Columna derecha - Formulario */}
<div className="col-lg-7">
<div className="ps-lg-4">
{data && (
<FormularioProvider
id_evento={data.id_evento}
id_cuestionario={data.cuestionario.id_cuestionario}
>
<FormularioRegistro
evento={data.nombre_evento}
cuestionario={data.cuestionario.nombre_form}
id_evento={data.id_evento}
id_cuestionario={data.cuestionario.id_cuestionario}
handleSubmitFormulario={onSubmitFormulario}
/>
</FormularioProvider>
)}
</div>
</div>
</div>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
'use client';
import { GetEventoWithCuestionariosWithCupos } from '@/types/evento';
import axiosInstance from '@/utils/api-config';
import {
createContext,
useContext,
useState,
useEffect,
useCallback,
ReactNode,
} from 'react';
interface EventoContextType {
evento: GetEventoWithCuestionariosWithCupos | null;
loading: boolean;
error: string | null;
refetch: () => void;
updateEvento: (
eventoData: Partial<GetEventoWithCuestionariosWithCupos>
) => void;
setEventoData: (evento: GetEventoWithCuestionariosWithCupos) => void;
}
const EventoContext = createContext<EventoContextType | undefined>(undefined);
interface EventoProviderProps {
children: ReactNode;
id_evento: string;
}
export function EventoProvider({ children, id_evento }: EventoProviderProps) {
const [evento, setEvento] =
useState<GetEventoWithCuestionariosWithCupos | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchEvento = useCallback(async () => {
console.log('Fetching evento with ID:', id_evento);
try {
setLoading(true);
setError(null);
const response =
await axiosInstance.get<GetEventoWithCuestionariosWithCupos>(
`/evento/${id_evento}/cuestionarios`
);
setEvento(response.data);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Error al cargar el evento';
setError(errorMessage);
console.error('Error fetching evento:', error);
} finally {
setLoading(false);
}
}, [id_evento]);
useEffect(() => {
if (id_evento) {
fetchEvento();
}
}, [id_evento, fetchEvento]);
const refetch = () => {
fetchEvento();
};
const updateEvento = useCallback(
(eventoData: Partial<GetEventoWithCuestionariosWithCupos>) => {
setEvento((prev) => (prev ? { ...prev, ...eventoData } : null));
},
[]
);
const setEventoData = useCallback(
(newEvento: GetEventoWithCuestionariosWithCupos) => {
setEvento(newEvento);
},
[]
);
const value: EventoContextType = {
evento,
loading,
error,
refetch,
updateEvento,
setEventoData,
};
return (
<EventoContext.Provider value={value}>{children}</EventoContext.Provider>
);
}
export function useEvento() {
const context = useContext(EventoContext);
if (context === undefined) {
throw new Error('useEvento must be used within an EventoProvider');
}
return context;
}
+1
View File
@@ -0,0 +1 @@
export * from './evento-context';
@@ -0,0 +1,330 @@
'use client';
import React, {
createContext,
useContext,
useReducer,
useEffect,
useRef,
useMemo,
useCallback,
ReactNode,
} from 'react';
import axiosInstance from '@/utils/api-config';
import { getAxiosError } from '@/utils/errors-utils';
import { GetCuestionario } from '@/types/evento';
import { UsuarioDataResponse } from '@/containers/formulario/formulario-registro';
import {
formularioReducer,
initialState,
FormularioState,
RespuestaFormulario,
} from './formulario-reducer';
// ── Longitudes requeridas por tipo de validación ─────────────────────────────
const LONGITUDES: Record<string, number> = {
cuenta_alumno: 9,
cuenta_trabajador: 6,
rfc: 10,
};
// ── Tipos del contexto ────────────────────────────────────────────────────────
interface FormularioContextType {
state: FormularioState;
// Datos derivados estables
preguntas: ReturnType<typeof getPreguntasFlat>;
preguntaComunidad: PreguntaFlat | undefined;
preguntaCuenta: PreguntaFlat | undefined;
esComunidadSi: boolean;
esExclusivo: boolean;
mostrarFormularioCompleto: boolean;
// Acciones
setRespuesta: (id: string, valor: string) => void;
submitFormulario: (id_cuestionario: number) => Promise<import('@/types/submit').SubmitResponse>;
}
// Helper para aplanar preguntas
type PreguntaFlat = GetCuestionario['cuestionario']['secciones'][0]['preguntas'][0]['pregunta'];
function getPreguntasFlat(data: GetCuestionario | null): PreguntaFlat[] {
if (!data) return [];
return data.cuestionario.secciones.flatMap((s) =>
s.preguntas.map((p) => p.pregunta)
);
}
// ── Context ───────────────────────────────────────────────────────────────────
const FormularioContext = createContext<FormularioContextType | undefined>(undefined);
interface FormularioProviderProps {
children: ReactNode;
id_evento: number;
id_cuestionario: number;
}
export function FormularioProvider({
children,
id_evento,
id_cuestionario,
}: FormularioProviderProps) {
const [state, dispatch] = useReducer(formularioReducer, initialState);
// Rastrea si ya se limpiaron los campos para el usuarioEncontrado actual
const yaLimpioRef = useRef(false);
// ── Carga del cuestionario ─────────────────────────────────────────────────
useEffect(() => {
dispatch({ type: 'FETCH_DATA_START' });
axiosInstance
.get<GetCuestionario>(`/cuestionario/${id_cuestionario}/formulario`)
.then((res) => dispatch({ type: 'FETCH_DATA_SUCCESS', payload: res.data }))
.catch((err) => {
const { message } = getAxiosError(err);
dispatch({ type: 'FETCH_DATA_ERROR', payload: message });
});
}, [id_cuestionario]);
// ── Derivados estables ─────────────────────────────────────────────────────
const preguntas = useMemo(() => getPreguntasFlat(state.data), [state.data]);
const preguntaComunidad = useMemo(
() =>
preguntas.find(
(p) =>
p.validacion === 'comunidad_alumno' ||
p.validacion === 'comunidad_trabajador'
),
[preguntas]
);
const preguntaCuenta = useMemo(
() =>
preguntas.find(
(p) =>
p.validacion === 'cuenta_alumno' ||
p.validacion === 'cuenta_trabajador' ||
p.validacion === 'rfc'
),
[preguntas]
);
const comunidadSeleccionada = preguntaComunidad
? state.respuestas[preguntaComunidad.id_pregunta]
: null;
const esComunidadSi = useMemo(
() =>
!!(
comunidadSeleccionada &&
preguntaComunidad?.opciones.find(
(op) =>
op.id_opcion === Number(comunidadSeleccionada) &&
op.opcion.opcion.toLowerCase().includes('si')
)
),
[comunidadSeleccionada, preguntaComunidad]
);
const esExclusivo = useMemo(
() => !preguntaComunidad && !!preguntaCuenta,
[preguntaComunidad, preguntaCuenta]
);
const mostrarFormularioCompleto =
(esExclusivo && state.busquedaCompletada) ||
(!preguntaComunidad && !esExclusivo) ||
(esComunidadSi && state.busquedaCompletada) ||
(!!(esComunidadSi === false) && !!comunidadSeleccionada);
// ── Búsqueda de cuenta ─────────────────────────────────────────────────────
useEffect(() => {
const debeIniciarBusqueda = preguntaCuenta && (esExclusivo || esComunidadSi);
if (!debeIniciarBusqueda) {
if (state.cuentaBuscada || state.busquedaCompletada || state.errorBusqueda) {
dispatch({ type: 'BUSQUEDA_RESET' });
}
return;
}
const cuenta = state.respuestas[preguntaCuenta.id_pregunta];
if (!cuenta || typeof cuenta !== 'string') {
if (state.cuentaBuscada || state.busquedaCompletada || state.errorBusqueda) {
dispatch({ type: 'BUSQUEDA_RESET' });
}
return;
}
const longitudRequerida = LONGITUDES[preguntaCuenta.validacion ?? ''];
if (!longitudRequerida || cuenta.length !== longitudRequerida) {
if (state.cuentaBuscada) dispatch({ type: 'BUSQUEDA_RESET' });
return;
}
// No re-buscar la misma cuenta
if (cuenta === state.cuentaBuscada) return;
let endpoint =
preguntaCuenta.validacion === 'cuenta_alumno'
? `/alumnos/${cuenta}`
: `/trabajadores/${cuenta}`;
if (esExclusivo) endpoint += `?id_evento=${id_evento}`;
dispatch({ type: 'BUSQUEDA_START', payload: cuenta });
axiosInstance
.get<UsuarioDataResponse>(endpoint)
.then((res) => {
const d = res.data;
if (d.nombre || d.apellidos || d.carrera || d.genero) {
dispatch({ type: 'BUSQUEDA_SUCCESS', payload: d });
} else {
dispatch({ type: 'BUSQUEDA_ERROR', payload: 'No se encontraron datos para este usuario' });
}
})
.catch((err) => {
const { message } = getAxiosError(err);
dispatch({ type: 'BUSQUEDA_ERROR', payload: message });
});
}, [
state.respuestas,
state.cuentaBuscada,
state.busquedaCompletada,
state.errorBusqueda,
preguntaCuenta,
esComunidadSi,
esExclusivo,
id_evento,
]);
// ── Autorelleno cuando llega usuarioEncontrado ─────────────────────────────
useEffect(() => {
if (!state.data) return;
if (!state.usuarioEncontrado) {
// Solo limpiar si aún no lo hicimos (evita loop infinito)
if (!yaLimpioRef.current) return;
yaLimpioRef.current = false;
const idsCampos = preguntas
.filter((p) =>
['nombre', 'apellidos', 'correo', 'carrera', 'genero'].includes(p.validacion ?? '')
)
.map((p) => p.id_pregunta);
if (idsCampos.length) {
dispatch({ type: 'LIMPIAR_CAMPOS_AUTORELLENO', payload: idsCampos });
}
return;
}
const u = state.usuarioEncontrado;
const bulk: RespuestaFormulario = {};
preguntas.forEach((pregunta) => {
const id = pregunta.id_pregunta;
switch (pregunta.validacion) {
case 'nombre':
bulk[id] = u.nombre ?? '';
break;
case 'apellidos':
bulk[id] = u.apellidos ?? '';
break;
case 'correo':
bulk[id] = u.cuenta ? `${u.cuenta}@pcpuma.acatlan.unam.mx` : '';
break;
case 'carrera':
bulk[id] = u.carrera ?? '';
break;
case 'genero': {
const texto = u.genero === 'M' ? 'Masculino' : 'Femenino';
const opcion = pregunta.opciones?.find(
(op) => op.opcion.opcion.toLowerCase() === texto.toLowerCase()
);
if (opcion) bulk[id] = String(opcion.id_opcion);
break;
}
}
});
if (Object.keys(bulk).length) {
yaLimpioRef.current = true;
dispatch({ type: 'SET_RESPUESTAS_BULK', payload: bulk });
}
}, [state.usuarioEncontrado, state.data, preguntas]);
// ── Acciones expuestas ─────────────────────────────────────────────────────
const setRespuesta = useCallback((id: string, valor: string) => {
dispatch({ type: 'SET_RESPUESTA', payload: { id, valor } });
}, []);
const submitFormulario = useCallback(
async (id_cuestionario: number) => {
dispatch({ type: 'SEND_START' });
try {
const preguntaCorreo = preguntas.find(
(p) => p.validacion === 'correo' || p.validacion === 'correo_institucional'
);
let correo = preguntaCorreo
? String(state.respuestas[preguntaCorreo.id_pregunta] ?? '')
: '';
if (!correo) {
const rx = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
for (const v of Object.values(state.respuestas)) {
if (typeof v === 'string' && rx.test(v)) { correo = v; break; }
}
}
const res = await axiosInstance.post<import('@/types/submit').SubmitResponse>(
'/cuestionario-respondido/submit',
{
id_cuestionario,
correo,
fecha_envio: new Date().toISOString(),
respuestas: Object.entries(state.respuestas).map(([id, valor]) => ({
id_pregunta: Number(id),
valor: isNaN(Number(valor)) ? valor : Number(valor),
})),
}
);
return res.data;
} finally {
dispatch({ type: 'SEND_END' });
}
},
[state.respuestas, preguntas]
);
// ── Valor del contexto ─────────────────────────────────────────────────────
const value: FormularioContextType = {
state,
preguntas,
preguntaComunidad,
preguntaCuenta,
esComunidadSi,
esExclusivo,
mostrarFormularioCompleto,
setRespuesta,
submitFormulario,
};
return (
<FormularioContext.Provider value={value}>
{children}
</FormularioContext.Provider>
);
}
export function useFormulario() {
const ctx = useContext(FormularioContext);
if (!ctx) throw new Error('useFormulario debe usarse dentro de FormularioProvider');
return ctx;
}
@@ -0,0 +1,152 @@
import { GetCuestionario } from '@/types/evento';
import { UsuarioDataResponse } from '@/containers/formulario/formulario-registro';
export type RespuestaFormulario = Record<string, string | number>;
// ── Estado ──────────────────────────────────────────────────────────────────
export interface FormularioState {
// Datos del cuestionario cargado desde la API
data: GetCuestionario | null;
loadingData: boolean;
errorData: string | null;
// Respuestas del usuario
respuestas: RespuestaFormulario;
// Búsqueda de cuenta/rfc
cuentaBuscada: string;
busquedaCompletada: boolean;
loadingBusqueda: boolean;
errorBusqueda: string | null;
usuarioEncontrado: UsuarioDataResponse | null;
// Envío
isSending: boolean;
}
export const initialState: FormularioState = {
data: null,
loadingData: true,
errorData: null,
respuestas: {},
cuentaBuscada: '',
busquedaCompletada: false,
loadingBusqueda: false,
errorBusqueda: null,
usuarioEncontrado: null,
isSending: false,
};
// ── Acciones ─────────────────────────────────────────────────────────────────
export type FormularioAction =
// Carga del cuestionario
| { type: 'FETCH_DATA_START' }
| { type: 'FETCH_DATA_SUCCESS'; payload: GetCuestionario }
| { type: 'FETCH_DATA_ERROR'; payload: string }
// Respuestas
| { type: 'SET_RESPUESTA'; payload: { id: string; valor: string } }
| { type: 'SET_RESPUESTAS_BULK'; payload: RespuestaFormulario }
| { type: 'LIMPIAR_CAMPOS_AUTORELLENO'; payload: number[] } // ids de pregunta
// Búsqueda de cuenta
| { type: 'BUSQUEDA_START'; payload: string } // cuenta buscada
| { type: 'BUSQUEDA_SUCCESS'; payload: UsuarioDataResponse }
| { type: 'BUSQUEDA_ERROR'; payload: string }
| { type: 'BUSQUEDA_RESET' }
// Envío
| { type: 'SEND_START' }
| { type: 'SEND_END' };
// ── Reducer ──────────────────────────────────────────────────────────────────
export function formularioReducer(
state: FormularioState,
action: FormularioAction
): FormularioState {
switch (action.type) {
// Carga del cuestionario
case 'FETCH_DATA_START':
return { ...state, loadingData: true, errorData: null };
case 'FETCH_DATA_SUCCESS':
return { ...state, loadingData: false, data: action.payload };
case 'FETCH_DATA_ERROR':
return { ...state, loadingData: false, errorData: action.payload };
// Respuestas
case 'SET_RESPUESTA':
return {
...state,
respuestas: { ...state.respuestas, [action.payload.id]: action.payload.valor },
};
case 'SET_RESPUESTAS_BULK':
return {
...state,
respuestas: { ...state.respuestas, ...action.payload },
};
case 'LIMPIAR_CAMPOS_AUTORELLENO': {
const limpio = { ...state.respuestas };
action.payload.forEach((id) => delete limpio[id]);
return { ...state, respuestas: limpio };
}
// Búsqueda
case 'BUSQUEDA_START':
return {
...state,
loadingBusqueda: true,
errorBusqueda: null,
busquedaCompletada: false,
usuarioEncontrado: null,
cuentaBuscada: action.payload,
};
case 'BUSQUEDA_SUCCESS':
return {
...state,
loadingBusqueda: false,
busquedaCompletada: true,
errorBusqueda: null,
usuarioEncontrado: action.payload,
};
case 'BUSQUEDA_ERROR':
return {
...state,
loadingBusqueda: false,
busquedaCompletada: false,
errorBusqueda: action.payload,
usuarioEncontrado: null,
};
case 'BUSQUEDA_RESET':
return {
...state,
cuentaBuscada: '',
busquedaCompletada: false,
loadingBusqueda: false,
errorBusqueda: null,
usuarioEncontrado: null,
};
// Envío
case 'SEND_START':
return { ...state, isSending: true };
case 'SEND_END':
return { ...state, isSending: false };
default:
return state;
}
}
+2
View File
@@ -0,0 +1,2 @@
export { FormularioProvider, useFormulario } from './formulario-context';
export type { FormularioState, FormularioAction, RespuestaFormulario } from './formulario-reducer';
+1
View File
@@ -0,0 +1 @@
export * from './evento';
+286 -17
View File
@@ -1,16 +1,22 @@
export const plantillasDisponibles = [ import { FormularioCreacion } from '@/types/create-formulario';
interface Plantilla {
nombre: string;
id: string;
datos: FormularioCreacion;
}
export const plantillasDisponibles: Plantilla[] = [
{ {
imagen: '/banner1.png', nombre: 'Registro General (con prellenado para Trabajadores)',
nombre: 'Registro para la Feria', id: 'registro-comunidad-estudiantil',
id: 'feria-sexualidad',
datos: { datos: {
nombre_form: 'Registro para la Feria de la Sexualidad - FES Acatlán', nombre_form: 'Registro',
descripcion: descripcion:
'La Feria de la Sexualidad es un espacio seguro e informativo dirigido a toda la comunidad universitaria. Este registro nos ayudará a organizar mejor las actividades, talleres y charlas, así como conocer tus intereses. Por favor, completa el siguiente formulario para confirmar tu participación.', 'Con este formulario estarás reservando tu lugar en el evento. Por favor, completa todos los campos obligatorios.',
fecha_inicio: '2025-03-07T00:00:01', fecha_inicio: '2025-08-01T08:00:00',
fecha_fin: '2025-03-18T23:59:59', fecha_fin: '2025-08-10T23:59:59',
id_tipo_cuestionario: 1, id_tipo_cuestionario: 2,
evento: 'Feria de la Sexualidad',
secciones: [ secciones: [
{ {
titulo: 'Información Personal', titulo: 'Información Personal',
@@ -22,26 +28,106 @@ export const plantillasDisponibles = [
tipo: 'Cerrada', tipo: 'Cerrada',
opciones: [{ valor: 'Si' }, { valor: 'No' }], opciones: [{ valor: 'Si' }, { valor: 'No' }],
obligatoria: true, obligatoria: true,
validacion: 'comunidad_trabajador',
}, },
{ {
titulo: 'Numero de cuenta', titulo: 'RFC',
tipo: 'Abierta', tipo: 'Abierta (Respuesta corta)',
obligatoria: false,
validacion: 'rfc',
},
{
titulo: 'Correo electrónico',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true, obligatoria: true,
validacion: 'correo',
}, },
{ {
titulo: 'Nombre(s)', titulo: 'Nombre(s)',
tipo: 'Abierta', tipo: 'Abierta (Respuesta corta)',
obligatoria: true, obligatoria: true,
validacion: 'nombre',
}, },
{ {
titulo: 'Apellidos', titulo: 'Apellidos',
tipo: 'Abierta', tipo: 'Abierta (Respuesta corta)',
obligatoria: true, obligatoria: true,
validacion: 'apellidos',
}, },
{ {
titulo: 'Género', titulo: 'Género',
tipo: 'Cerrada', tipo: 'Cerrada',
obligatoria: true, obligatoria: true,
validacion: 'genero',
opciones: [
{ valor: 'Masculino' },
{ valor: 'Femenino' },
{ valor: 'Prefiero no decirlo' },
],
},
{
titulo: 'Carrera',
tipo: 'Abierta (Respuesta corta)',
validacion: 'carrera',
obligatoria: true,
},
],
},
],
},
},
{
nombre: 'Registro General (con prellenado para Alumnos)',
id: 'registro-comunidad-alumnos',
datos: {
nombre_form: 'Registro',
descripcion:
'Con este formulario estarás reservando tu lugar en el evento. Por favor, completa todos los campos obligatorios.',
fecha_inicio: '2025-08-01T08:00:00',
fecha_fin: '2025-08-10T23:59:59',
id_tipo_cuestionario: 1,
secciones: [
{
titulo: 'Información Personal',
descripcion:
'Proporciona tus datos personales para poder contactarte y enviarte información relevante sobre la Feria de la Sexualidad.',
preguntas: [
{
titulo: '¿Eres parte de la comunidad de la FES Acatlán?',
tipo: 'Cerrada',
opciones: [{ valor: 'Si' }, { valor: 'No' }],
obligatoria: true,
validacion: 'comunidad_alumno',
},
{
titulo: 'Numero de cuenta',
tipo: 'Abierta (Respuesta corta)',
obligatoria: false,
validacion: 'cuenta_alumno',
},
{
titulo: 'Correo electrónico',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: 'correo',
},
{
titulo: 'Nombre(s)',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: 'nombre',
},
{
titulo: 'Apellidos',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: 'apellidos',
},
{
titulo: 'Género',
tipo: 'Cerrada',
obligatoria: true,
validacion: 'genero',
opciones: [ opciones: [
{ valor: 'Masculino' }, { valor: 'Masculino' },
{ valor: 'Femenino' }, { valor: 'Femenino' },
@@ -50,12 +136,14 @@ export const plantillasDisponibles = [
}, },
{ {
titulo: 'Institución de procedencia', titulo: 'Institución de procedencia',
tipo: 'Abierta', tipo: 'Abierta (Respuesta corta)',
validacion: 'institucion',
obligatoria: true, obligatoria: true,
}, },
{ {
titulo: 'Correo electrónico', titulo: 'Carrera',
tipo: 'Abierta', tipo: 'Abierta (Respuesta corta)',
validacion: 'carrera',
obligatoria: true, obligatoria: true,
}, },
], ],
@@ -63,4 +151,185 @@ export const plantillasDisponibles = [
], ],
}, },
}, },
{
nombre: 'Registro General',
id: 'registro-general',
datos: {
nombre_form: 'Registro Para Asistencia General',
descripcion:
'Con este formulario estarás reservando tu lugar en el evento. Por favor, completa todos los campos obligatorios.',
fecha_inicio: '2025-08-01T08:00:00',
fecha_fin: '2025-08-10T23:59:59',
id_tipo_cuestionario: 1,
secciones: [
{
titulo: 'Información Personal',
descripcion:
'Proporciona tus datos personales para poder contactarte y enviarte información relevante sobre la Feria de la Sexualidad.',
preguntas: [
{
titulo: 'Número de cuenta / trabajador',
tipo: 'Abierta (Respuesta corta)',
obligatoria: false,
},
{
titulo: 'Correo electrónico',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: 'correo',
},
{
titulo: 'Nombre(s)',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: 'nombre',
},
{
titulo: 'Apellidos',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: 'apellidos',
},
{
titulo: 'Tipo de Asistente',
tipo: 'Cerrada',
obligatoria: true,
opciones: [
{ valor: 'Alumno' },
{ valor: 'Profesor' },
{ valor: 'Trabajador' },
],
},
],
},
],
},
},
{
nombre: 'Formulario Exclusivo Alumnos',
id: 'formulario-exclusivo-alumnos',
datos: {
nombre_form: 'Formulario Exclusivo Alumnos',
descripcion:
'Este formulario está destinado exclusivamente para alumnos. Por favor, completa todos los campos obligatorios.',
fecha_inicio: '2025-08-01T08:00:00',
fecha_fin: '2025-08-10T23:59:59',
id_tipo_cuestionario: 1,
secciones: [
{
titulo: 'Información Personal',
descripcion: 'Proporciona tus datos personales.',
preguntas: [
{
titulo: 'Número de cuenta',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: 'cuenta_alumno',
},
{
titulo: 'Correo electrónico',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: 'correo',
},
{
titulo: 'Nombre(s)',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: 'nombre',
},
{
titulo: 'Apellidos',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: 'apellidos',
},
{
titulo: 'Carrera',
tipo: 'Abierta (Respuesta corta)',
validacion: 'carrera',
obligatoria: true,
},
{
titulo: 'Género',
tipo: 'Cerrada',
obligatoria: true,
validacion: 'genero',
opciones: [
{ valor: 'Masculino' },
{ valor: 'Femenino' },
{ valor: 'Prefiero no decirlo' },
],
},
],
},
],
},
},
{
nombre: 'Formulario Exclusivo Profesores',
id: 'formulario-exclusivo-profesores',
datos: {
nombre_form: 'Formulario Exclusivo Profesores',
descripcion:
'Este formulario está destinado exclusivamente para profesores. Por favor, completa todos los campos obligatorios.',
fecha_inicio: '2025-08-01T08:00:00',
fecha_fin: '2025-08-10T23:59:59',
id_tipo_cuestionario: 2,
secciones: [
{
titulo: 'Información Personal',
descripcion: 'Proporciona tus datos personales.',
preguntas: [
{
titulo: 'RFC',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: 'rfc',
},
{
titulo: 'Correo electrónico',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: 'correo',
},
{
titulo: 'Nombre(s)',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: 'nombre',
},
{
titulo: 'Apellidos',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: 'apellidos',
},
{
titulo: 'Género',
tipo: 'Cerrada',
obligatoria: true,
validacion: 'genero',
opciones: [
{ valor: 'Masculino' },
{ valor: 'Femenino' },
{ valor: 'Prefiero no decirlo' },
],
},
],
},
],
},
},
]; ];
/*
¡Bienvenidas y bienvenidos a la Facultad!
Inicia esta nueva etapa con un recorrido especial por nuestras instalaciones, pensado para que conozcas tu nueva casa universitaria. Te acompañaremos por aulas, laboratorios, bibliotecas, áreas comunes y servicios que estarán a tu disposición durante tu formación. Además, podrás resolver dudas y conocer a quienes serán parte de tu camino académico.
¡No faltes, esta actividad marcará el inicio de grandes experiencias!
*/
/*
Recorrido de bienvenida para nuevo personal
Damos la más cordial bienvenida al personal de nuevo ingreso a nuestra Facultad. Este recorrido está diseñado para familiarizarlos con las principales áreas administrativas, académicas y de servicios. También tendrán la oportunidad de conocer al equipo de trabajo y obtener información clave para integrarse con éxito a la comunidad universitaria.
*/
+31
View File
@@ -0,0 +1,31 @@
import axiosInstance from '@/utils/api-config';
import { getAxiosError } from '@/utils/errors-utils';
import { useEffect, useState } from 'react';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const useGetApi = <T,>(url: string, params?: Record<string, any>) => {
const [data, setData] = useState<T>();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<ErrorState | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
const res = await axiosInstance.get<T>(url, { params });
setData(res.data);
setError(null);
} catch (err) {
console.error(err);
const message = getAxiosError(err);
setError(message);
} finally {
setLoading(false);
}
};
fetchData();
}, [url, params]);
return { data, setData, loading, error };
};
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const token = request.cookies.get('token');
if (!token) {
console.log('No hay token, redirigiendo a la página de inicio de sesión');
return NextResponse.redirect(new URL('/', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/staff/:path*', '/administrador/:path*'],
};
+161
View File
@@ -0,0 +1,161 @@
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInScale {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes slideInLeft {
from {
opacity: 0;
transform: translateX(-30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes fadeInRotate {
from {
opacity: 0;
transform: rotate(-5deg) scale(0.95);
}
to {
opacity: 1;
transform: rotate(0) scale(1);
}
}
@keyframes fadeInUpBounce {
0% {
opacity: 0;
transform: translateY(20px);
}
60% {
opacity: 1;
transform: translateY(-5px); // se pasa ligeramente hacia arriba
}
80% {
transform: translateY(3px); // rebota hacia abajo
}
100% {
transform: translateY(0); // posición final
}
}
@keyframes fadeInDownBounce {
0% {
opacity: 0;
transform: translateY(-20px);
}
60% {
opacity: 1;
transform: translateY(5px); // se pasa un poco hacia abajo
}
80% {
transform: translateY(-3px); // rebota hacia arriba
}
100% {
transform: translateY(0); // se estabiliza
}
}
// Clase base para aplicar la animación
.fade-in {
animation: fadeIn 1s ease-in-out;
animation-fill-mode: both;
}
.fade-in-up {
animation: fadeInUp 0.8s ease-out;
animation-fill-mode: both;
}
.fade-in-down {
animation: fadeInDown 0.8s ease-out;
animation-fill-mode: both;
}
.fade-in-scale {
animation: fadeInScale 0.8s ease-out;
animation-fill-mode: both;
}
.slide-in-left {
animation: slideInLeft 0.8s ease-out;
animation-fill-mode: both;
}
.slide-in-right {
animation: slideInRight 0.8s ease-out;
animation-fill-mode: both;
}
.fade-in-rotate {
animation: fadeInRotate 0.8s ease-out;
animation-fill-mode: both;
}
.fade-in-up-bounce {
animation: fadeInUpBounce 0.8s ease-out;
animation-fill-mode: both;
}
.fade-in-down-bounce {
animation: fadeInDownBounce 0.8s ease-out;
animation-fill-mode: both;
}
// Variantes con delays más suaves
@for $i from 1 through 5 {
.delay-#{$i} {
animation-delay: #{($i * 0.3)}s;
}
}
+1 -3
View File
@@ -11,7 +11,6 @@
thead { thead {
tr { tr {
box-shadow: var(--bs-box-shadow-sm);
th { th {
background-color: var(--bg-header-table); background-color: var(--bg-header-table);
position: sticky; position: sticky;
@@ -39,8 +38,7 @@
tr { tr {
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
box-shadow: var(--bs-box-shadow-sm);
&.clickable-row { &.clickable-row {
cursor: pointer; cursor: pointer;
&:hover { &:hover {
+5 -5
View File
@@ -1,11 +1,11 @@
// Archivo: _utilities.scss // Archivo: _utilities.scss
$hover-scales: ( $hover-scales: (
1: 1.05, 1: 1.025,
2: 1.1, 2: 1.05,
3: 1.15, 3: 1.075,
4: 1.2, 4: 1.1,
5: 1.25, 5: 1.125,
); );
@each $key, $value in $hover-scales { @each $key, $value in $hover-scales {
+60 -3
View File
@@ -27,6 +27,14 @@ $theme-colors: map-merge($theme-colors, $custom-colors);
@import 'sidebar'; @import 'sidebar';
@import 'hover'; @import 'hover';
@import 'tabs'; @import 'tabs';
@import 'animation';
@import './global.scss';
.carousel-control-prev-icon,
.carousel-control-next-icon {
filter: invert(100%) sepia(100%) saturate(0%) hue-rotate(0deg)
brightness(200%);
}
//Add additional custom code here //Add additional custom code here
@@ -71,10 +79,22 @@ ul.list-unstyled li {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
margin: -1.5rem 1rem 0; margin: -1.5rem 1rem 0;
height: 60%; cursor: pointer;
@extend .rounded; @extend .rounded;
@extend .shadow; @extend .shadow;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 80%; // ajusta según el tamaño deseado
background: linear-gradient(to top, rgba(0, 0, 0, 0.5), transparent);
pointer-events: none;
z-index: 1;
}
img { img {
@extend .img-fluid, .rounded; @extend .img-fluid, .rounded;
width: 100%; width: 100%;
@@ -85,11 +105,19 @@ ul.list-unstyled li {
.card-caption { .card-caption {
position: absolute; position: absolute;
bottom: 15px; bottom: 5px;
left: 15px; left: 15px;
color: #fff; color: #fff;
font-weight: bold;
font-size: 1.25rem; font-size: 1.25rem;
text-shadow: 0 2px 5px rgba(33, 33, 33, 0.5); text-shadow: 0 2px 5px rgba(33, 33, 33, 0.5);
z-index: 2;
}
.card-description {
> p {
margin: 0;
}
} }
} }
@@ -120,4 +148,33 @@ ul.list-unstyled li {
.cursor-pointer { .cursor-pointer {
cursor: pointer; cursor: pointer;
} }
input {
--bs-body-bg: #fff;
}
.required {
position: relative;
&::after {
content: '*';
position: absolute;
color: red;
font-weight: bold;
margin-left: 10px;
}
}
.preguntas {
@include media-breakpoint-up(md) {
margin-inline: 5rem;
}
@include media-breakpoint-up(lg) {
margin-inline: 14rem;
}
}
.table-white {
--bs-table-bg: #fff;
}
+91
View File
@@ -0,0 +1,91 @@
.form-control,
.form-select {
--bs-body-bg: #ffffff;
}
.w-md-auto {
width: 100% !important;
@include media-breakpoint-up(md) {
width: auto !important;
}
}
.scroll-container {
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
scrollbar-width: thin;
scrollbar-color: rgba($azul, 0.75) $white;
}
.nav.nav-tabs {
border-bottom: 0cap;
gap: 1rem;
}
.nav-tabs.style-box {
.nav-item .nav-link {
@extend .box;
@extend .py-2;
@extend .text-black;
margin: 0 !important;
border: 0;
&.active {
@extend .text-primary;
border: 0;
}
&.disabled {
@extend .text-muted;
cursor: not-allowed;
}
}
}
/* HTML: <div class="loader"></div> */
.loader {
margin-top: 100px;
height: 20px;
aspect-ratio: 5;
-webkit-mask: linear-gradient(90deg, #0000, $azul 20% 80%, #0000);
background: radial-gradient(closest-side at 37.5% 50%, $azul 94%, #0000) 0 /
calc(80% / 3) 100%;
animation: l48 0.75s infinite linear;
}
@keyframes l48 {
100% {
background-position: 36.36%;
}
}
hr {
@extend .text-muted;
}
.content-dashboard {
::-webkit-scrollbar {
width: 12px;
}
::-webkit-scrollbar-track {
background: red;
}
::-webkit-scrollbar-thumb {
background-color: $primary;
border-radius: 10px;
border: 3px solid red;
}
@include color-mode(dark) {
::-webkit-scrollbar-track {
background: blue;
}
::-webkit-scrollbar-thumb {
background-color: $primary;
border: 3px solid blue;
}
}
}
+9
View File
@@ -0,0 +1,9 @@
export interface Administrador {
id_administrador: number;
nombre_usuario: string;
correo: string;
tipoUser: {
id: number;
tipo: string;
};
}
-26
View File
@@ -1,26 +0,0 @@
export interface FormularioCreacion {
nombre_form: string;
descripcion: string;
fecha_inicio: string; // ISO 8601 date string
fecha_fin: string;
id_tipo_cuestionario: number;
evento: string;
secciones: SeccionFormulario[];
}
export interface SeccionFormulario {
titulo: string;
descripcion: string;
preguntas: PreguntaFormulario[];
}
export interface PreguntaFormulario {
titulo: string;
tipo: 'Cerrada' | 'Abierta' | 'Multiple';
opciones?: OpcionFormulario[];
obligatoria: boolean;
}
export interface OpcionFormulario {
valor: string;
}
+7
View File
@@ -0,0 +1,7 @@
export interface CreateEventoType {
tipo_evento: string;
nombre_evento: string;
descripcion_evento?: string; // Es opciona
fecha_inicio: Date;
fecha_fin: Date;
}
+53
View File
@@ -0,0 +1,53 @@
export type TiposValidacion =
| 'correo'
| 'correo_institucional'
| 'telefono'
| 'nombre'
| 'entero'
| 'apellidos'
| 'decimal'
| 'comunidad_alumno'
| 'cuenta_alumno'
| 'comunidad_trabajador'
| 'cuenta_trabajador'
| 'genero'
| 'carrera'
| 'institucion'
| 'rfc';
export type TipoPregunta =
| 'Abierta (Respuesta corta)'
| 'Abierta (Parrafo)'
| 'Cerrada'
| 'Multiple';
export interface FormularioCreacion {
nombre_form: string;
descripcion: string;
fecha_inicio: string; // ISO 8601 date string
fecha_fin: string;
id_tipo_cuestionario: number;
id_tipo_evento?: number;
cupo_maximo?: number; // Opcional, si no se requiere un cupo máximo
mensaje_correo?: string;
secciones: SeccionFormulario[];
}
export interface SeccionFormulario {
titulo: string;
descripcion: string;
preguntas: PreguntaFormulario[];
}
export interface PreguntaFormulario {
titulo: string;
tipo: TipoPregunta;
validacion?: TiposValidacion;
obligatoria: boolean;
opciones?: { valor: string }[]; // Opciones para preguntas cerradas o múltiples
limite?: number;
}
export interface OpcionFormulario {
valor: string;
}
+10
View File
@@ -1,15 +1,25 @@
import { GetEvento } from './evento';
/** /**
* Respuesta del endpoint GET /cuestionario * Respuesta del endpoint GET /cuestionario
*/ */
export interface GetCuestionario { export interface GetCuestionario {
id_cuestionario: number; id_cuestionario: number;
id_evento: number;
nombre_form: string; nombre_form: string;
banner?: string;
descripcion: string; descripcion: string;
contador_secciones: number; contador_secciones: number;
cupo_maximo?: number | null; // Puede ser null si no hay límite
editable: boolean; editable: boolean;
fecha_fin: string; // Formato ISO: 'YYYY-MM-DDTHH:mm:ss' fecha_fin: string; // Formato ISO: 'YYYY-MM-DDTHH:mm:ss'
fecha_inicio: string; // Formato ISO: 'YYYY-MM-DDTHH:mm:ss' fecha_inicio: string; // Formato ISO: 'YYYY-MM-DDTHH:mm:ss'
id_tipo_cuestionario: number; id_tipo_cuestionario: number;
mensaje_correo?: string | null;
}
export interface GetCuestionarioWithEvento extends GetCuestionario {
evento: GetEvento;
} }
/* */ /* */

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