61 Commits

Author SHA1 Message Date
miguel b399821439 Refactor code structure for improved readability and maintainability 2025-09-09 15:16:40 -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
miguel 99b444ccf4 fix: add recent events section to the administrator page 2025-08-11 10:22:25 -06:00
miguel 3493d99038 fix: update confirmarAsistencia function to use cuestionario ID instead of evento ID 2025-08-08 09:29:07 -06:00
miguel f624724521 fix: update cuestionario types to include evento details 2025-08-07 08:55:44 -06:00
miguel b2205b3f98 send 2025-06-30 14:20:45 -06:00
miguel 4d124a7857 fix: simplify toast notification rendering function 2025-06-30 13:25:25 -06:00
miguel 3fc7dfcfda fix: enhance toast notifications for form submission feedback 2025-06-30 13:21:32 -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
miguel 670d8558f4 feat: update placeholder text for cuenta input based on validation type 2025-06-30 11:13:56 -06:00
jorgemike 2cb42cd4fc Implement code changes to enhance functionality and improve performance 2025-06-27 18:17:49 -06:00
jorgemike a107233007 refactor: improve email validation logic and enhance community response checks in FormularioRegistro 2025-06-27 16:38:02 -06:00
jorgemike 83c50f088e fix: correct URL structure in EventoCard component for cuestionario navigation 2025-06-27 15:57:23 -06:00
miguel 3723f5771d feat: include cupo_maximo in event update payload for edit formulario 2025-06-24 20:34:04 -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 20cc8bc4e7 Reporte respuestas 2025-04-25 11:41:19 -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
jorgemike b04dc94eec banner aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 2025-04-09 18:33:53 -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
102 changed files with 10421 additions and 1899 deletions
+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 = {
/* config options here */
images: {
remotePatterns: [
{
protocol: 'http',
hostname: 'localhost',
port: '4200',
},
{
protocol: 'https',
hostname: 'venus.acatlan.unam.mx'
}
],
},
};
export default nextConfig;
+2598 -513
View File
File diff suppressed because it is too large Load Diff
+11 -4
View File
@@ -5,28 +5,35 @@
"scripts": {
"dev": "next dev --turbopack",
"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"
},
"dependencies": {
"@uiw/react-md-editor": "^4.0.7",
"@yudiel/react-qr-scanner": "^2.2.1",
"axios": "^1.8.4",
"bootstrap": "^5.3.3",
"bootstrap-icons": "^1.11.3",
"next": "15.2.4",
"js-cookie": "^3.0.5",
"next": "15.3.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hot-toast": "^2.5.2"
"react-hot-toast": "^2.5.2",
"react-markdown": "^10.1.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/bootstrap": "^5.2.10",
"@types/js-cookie": "^3.0.6",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.2.4",
"sass": "^1.86.1",
"sass": "^1.32.13",
"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

@@ -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,193 @@
'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';
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 { 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 {
await axiosInstance.post(
`/participante-evento/asistencia/${id_participante}/${id_evento}`
);
toast.success('Asistencia confirmada');
// Actualiza lista
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>
</>
)}
</div>
);
}
@@ -0,0 +1,297 @@
'use client';
import { plantillasDisponibles } from '@/data/plantillas';
import { FormularioCreacion } from '@/types/create-formulario';
import Image from 'next/image';
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-4 flex-wrap">
{plantillasDisponibles.map((plantilla) => (
<div
className="mb-3 cursor-pointer"
key={plantilla.id}
onClick={() => handleSeleccionarPlantilla(plantilla.id)}
>
<div className="text-center">
<Image
src={plantilla.imagen}
width={200}
height={200}
alt={`Plantilla de formulario ${plantilla.nombre}`}
className="img-fluid rounded shadow-sm"
/>
</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,132 @@
'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 [loadingStates, setLoadingStates] = useState<Record<number, boolean>>(
{}
);
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}
/>
<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,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>
);
}
+15
View File
@@ -0,0 +1,15 @@
import Footer from '@/components/layout/footer';
import Header from '@/components/layout/header';
import Navbar from '@/components/navbar';
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>
);
}
+277
View File
@@ -0,0 +1,277 @@
'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 [scannedData, setScannedData] = useState<{
id_participante: number;
id_cuestionario: number;
} | null>(null);
const [showModal, setShowModal] = useState(false);
const [enableScan, setEnableScan] = useState(true);
const [statusMessage, setStatusMessage] = useState('');
const [participante, setParticipante] = useState('');
const handleScan = async (rawValue: string) => {
console.log('QR Escaneado:', rawValue);
if (!enableScan) return;
try {
const data = JSON.parse(rawValue);
if (data.id_participante && data.id_cuestionario) {
try {
const response = await axiosInstance.get(
`/participante-evento/${data.id_participante}/${data.id_cuestionario}`
);
setParticipante(response.data.participante.correo);
setScannedData({
id_participante: data.id_participante,
id_cuestionario: data.id_cuestionario,
});
setShowModal(true);
setEnableScan(false);
} catch (err) {
console.warn('Participante no registrado en el cuestionario:', err);
setStatusMessage(
'❌ El participante no está registrado en este evento.'
);
}
} 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_cuestionario}`
);
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);
setScannedData(null);
};
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>
<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);
setScannedData(null);
}}
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">
{scannedData ? (
<>
<div className="mb-4">
<div className="mb-3">
<i
className="bi bi-person-check-fill text-success"
style={{ fontSize: '3rem' }}
></i>
</div>
<h5 className="mb-3 fw-semibold">
Información del participante
</h5>
<div className="card bg-light border-0 rounded-3">
<div className="card-body py-3">
<div className="d-flex align-items-center justify-content-center">
<i className="bi bi-envelope-fill text-primary me-2"></i>
<strong className="me-2">Correo:</strong>
<span className="text-primary fw-medium">
{participante}
</span>
</div>
</div>
</div>
</div>
<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);
setScannedData(null);
}}
className="px-4 py-2 rounded-pill"
>
<i className="bi bi-x-lg me-2"></i>
Cancelar
</Button>
</div>
</>
) : (
<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">QR inválido o malformado</h6>
</div>
)}
</div>
</Modal>
</main>
);
}
+45
View File
@@ -0,0 +1,45 @@
'use client';
import Button from '@/components/button';
import Table, { Header } from '@/components/table';
import { useGetApi } from '@/hooks/use-get-api';
import { Administrador } from '@/types/administradores';
import React from 'react';
export default function Page() {
const { data } = useGetApi<Administrador[]>('/administrador');
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: () => (
<div className="d-flex gap-2">
<Button className="w-100" disabled>
Editar
</Button>
<Button variant="danger" className="w-100" disabled>
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">Agregar Administrador</Button>
</div>
{data && <Table headers={headers} data={data} />}
</div>
);
}
@@ -1,5 +0,0 @@
import React from 'react';
export default function Page() {
return <div>Page</div>;
}
+166
View File
@@ -0,0 +1,166 @@
'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,
});
// 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.user) {
sessionStorage.setItem('user', JSON.stringify(response.data.user));
}
// 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)}
/>
);
}
+77 -55
View File
@@ -1,64 +1,86 @@
'use client';
import ClientCarousel from '@/client-components/client-carousel';
import FormularioCard from '@/components/formulario-card';
import { GetCuestionario } from '@/types/cuestionario';
import FormularioCardUser from '@/components/formulario/formulario-card-user';
import EmptyEventsState from '@/components/empty-events-state';
import { useGetApi } from '@/hooks/use-get-api';
import { GetEventoWithCuestionariosWithCupos } from '@/types/evento';
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>;
}
export default function Page() {
const { loading, data } = useGetApi<GetEventoWithCuestionariosWithCupos[]>(
'/evento/activos/cuestionarios'
);
return (
<>
<div className="mx-auto my-5">
<ClientCarousel />
</div>
<div className="cards-1 mt-5">
<div className="container">
<div className="row">
{eventos.map((cuestionario) => (
<FormularioCard
key={cuestionario.id_cuestionario}
cuestionario={cuestionario}
link={`/registro/${cuestionario.id_cuestionario}`}
/>
))}
{eventos.length === 0 && (
<p className="text-center text-muted">
No hay formularios disponibles.
</p>
)}
<div>
{data && data.length > 0 && (
<div className="mx-auto mb-5 mt-3 fade-in-down-bounce">
<ClientCarousel
images={
data && data.length > 0
? data.map((evento) => ({
src: `${process.env.NEXT_PUBLIC_API_URL}/banners/${evento.banner}`,
alt: `Banner de ${evento.nombre_evento}`,
}))
: [
{
src: '/default-banner.png',
alt: 'Banner de eventos activos',
},
]
}
/>
</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>
</>
)}
{!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.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 = {
title: "Sistema de formularios",
description: "",
title: "Registros - Eventos FES Acatlán",
description: "Plataforma para consultar y registrarse a eventos organizados dentro de la FES Acatlán. Completa un formulario para confirmar tu asistencia.",
icons: {
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({
children,
}: 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';
import { CarouselProps } from '@/components/carousel';
import dynamic from 'next/dynamic';
import React from 'react';
const Carousel = dynamic(() => import('@/components/carousel'), {
ssr: false,
});
export default function ClientCarousel() {
return <Carousel />;
export default function ClientCarousel(props: CarouselProps) {
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 React from 'react';
interface CarouselProps {
export interface CarouselProps {
id?: string;
images?: {
src: string;
@@ -10,20 +10,11 @@ interface CarouselProps {
}
export default function Carousel({
id = 'carouselExample',
images = [
{
src: '/banner1.png',
alt: 'Imagen de ejemplo',
},
{
src: '/banner2.png',
alt: 'Imagen de ejemplo',
},
],
id = 'carousel-banners-eventos',
images,
}: CarouselProps) {
if (!images || images.length === 0) {
return <div>No hay imágenes para mostrar</div>;
return <div></div>;
}
return (
@@ -35,14 +26,16 @@ export default function Carousel({
className={`carousel-item ${index === 0 ? 'active' : ''}`}
>
<Image
src={img.src}
width={1000}
src={img.src ? img.src : '/default-banner.png'}
width={1200}
height={400}
alt="Ejemplo de banner"
className="d-block w-100 rounded-3"
className="d-block w-100 rounded-3 img-fluid"
style={{
height: '400px',
objectFit: 'cover',
objectPosition: 'center',
maxHeight: '400px',
}}
/>
</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 React, { useState } from 'react';
import Image from 'next/image';
import Button from './button';
import { useRouter } from 'next/navigation';
interface Props {
cuestionario: GetCuestionario;
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 descripcion = cuestionario.descripcion;
const limite = 100;
@@ -21,34 +29,48 @@ export default function FormularioCard({ cuestionario, link }: Props) {
: descripcion;
return (
<div className="col-md-4 my-4">
<div className="card card-blog d-flex flex-column justify-content-between">
<div>
<div className="card-image">
<Link href={link}>
<Image width={400} height={300} className="img" src="/banner1.png" alt="Banner formulario" />
<div className="card-caption">{cuestionario.nombre_form}</div>
</Link>
<div className="ripple-cont"></div>
</div>
<div className="card card-blog d-flex flex-column justify-content-between">
<div>
<div className="card-image scale-hover-1">
<Link href={link}>
<Image
width={400}
height={300}
className="img"
src="/feriasex.png"
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">
<h6 className="category text-info">
{cuestionario.id_tipo_cuestionario === 1 ? 'Feria' : 'Evento'}
</h6>
<p className="card-description mb-0">
{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>
)}
</p>
</div>
<div className="table px-3 pt-3 mb-0">
<h6 className="category text-info">
{cuestionario.id_tipo_cuestionario === 1 ? 'Feria' : 'Evento'}
</h6>
<p className="card-description mb-0">
{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>
)}
</p>
{button_message && (
<Button
onClick={() => router.push(link)}
outline
className="mt-3 w-100 py-1 rounded"
variant="azul"
>
{button_message}
</Button>
)}
</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-6">
<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}
</span>
</div>
</div>
</div>
</div>
)}
{/* Columna de cupos - siempre visible */}
<div className={esElMismoDia ? 'col-6' : '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">Cupos</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'> {
/** Etiqueta visible asociada al campo de entrada */
label?: string;
/** Clases CSS personalizables para distintos elementos */
className?: {
/** Clases para el contenedor del input */
container?: string;
/** Clases para la etiqueta */
label?: string;
/** Clases para el input */
input?: string;
};
}
/**
* Componente de entrada (`Input`) reutilizable en React.
*
* 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>`.
* Componente de entrada (`Input`) sin lógica de mostrar/ocultar contraseña.
*/
export default function Input(props: InputProps) {
const { className, label, type, name, id, ...rest } = props;
const [showPassword, setShowPassword] = useState(false);
export default function SimpleInput(props: SimpleInputProps) {
const { label, name, id, className, ...rest } = props;
const isPassword = type === 'password';
const inputType = isPassword && showPassword ? 'text' : type;
const inputId =
id || name || (label ? `input-${label.replace(/\s+/g, '-')}` : undefined);
const togglePasswordVisibility = () => setShowPassword(!showPassword);
return (
<div className={className?.container || 'mb-3'}>
{label && inputId && (
<label
htmlFor={inputId}
className={`form-label ${className?.label || ''}`}
>
<label htmlFor={inputId} className={className?.label || 'form-label'}>
{label}
</label>
)}
<div className='input-group'>
<input
id={inputId}
name={name}
type={inputType}
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>
<input
id={inputId}
name={name}
className={className?.input || 'form-control'}
{...rest}
/>
</div>
);
}
+7 -7
View File
@@ -5,15 +5,15 @@ export default function Footer() {
const year = date.getFullYear();
return (
<footer className='bg-azul p-3 text-center text-white small'>
<p className='m-0'>
<footer className="bg-azul p-3 text-center text-white small">
<p className="m-0">
Hecho en México. Todos los derechos reservados {year}.
</p>
<p className='m-0'>
Esta página puede ser reproducida con fines no lucrativos,
siempre y cuando no se mutile, se cite la fuente completa y su
dirección electrónica. De otra forma, requiere permiso previo
por escrito de la institución.
<p className="m-0">
Esta página puede ser reproducida con fines no lucrativos, siempre y
cuando no se mutile, se cite la fuente completa y su dirección
electrónica. De otra forma, requiere permiso previo por escrito de la
institución.
</p>
</footer>
);
+26 -26
View File
@@ -3,30 +3,30 @@ import React from 'react';
import Link from 'next/link';
export default function Header({ small = false }: { small?: boolean }) {
return (
<header className='bg-azul p-3'>
<div className='container flex-center justify-content-sm-between'>
<Link
href={'https://www.unam.mx/'}
target='_blank'
className='d-none d-sm-block'
>
<Image
src='/logo-unam.png'
alt='Logo de la UNAM'
width={small ? 200 : 280}
height={small ? 60 : 84}
/>
</Link>
<Link href={'https://acatlan.unam.mx/'} target='_blank'>
<Image
src='/logo-fes.png'
alt='Logo de la fes Acatlàn'
width={small ? 200 : 250}
height={small ? 48 : 60}
/>
</Link>
</div>
</header>
);
return (
<header className="bg-azul p-3">
<div className="container flex-center justify-content-sm-between">
<Link
href={'https://www.unam.mx/'}
target="_blank"
className="d-none d-sm-block"
>
<Image
src="/logo-unam.png"
alt="Logo de la UNAM"
width={small ? 200 : 280}
height={small ? 60 : 84}
/>
</Link>
<Link href={'https://acatlan.unam.mx/'} target="_blank">
<Image
src="/logo-fes.png"
alt="Logo de la fes Acatlàn"
width={small ? 200 : 250}
height={small ? 48 : 60}
/>
</Link>
</div>
</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;
+36 -34
View File
@@ -1,9 +1,22 @@
import React from "react";
import Link from "next/link";
'use client';
import React from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
const Navbar: React.FC = () => {
const pathname = usePathname();
const navItems = [
{ href: '/', label: 'Inicio' },
{ href: '/user/eventos', label: 'Eventos' },
{ href: '/user/qr', label: 'Lector de QRs' },
{ href: '/user/eventos/crear', label: 'Crear Evento' },
{ href: '/user/usuarios', label: 'Usuarios' },
{ href: '/signout', label: 'Cerrar sesión' },
];
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">
<button
className="navbar-toggler border-0 ms-auto"
@@ -31,38 +44,27 @@ const Navbar: React.FC = () => {
aria-label="Close"
></button>
</div>
<div className="offcanvas-body justify-content-center">
<div className="offcanvas-body align-items-center justify-content-between">
<h2 className="h5 mb-0 text-white">
Sistema de Registro de Eventos
</h2>
<ul className="navbar-nav gap-lg-5">
<li className="nav-item" data-bs-dismiss="offcanvas">
<Link className="nav-link" href="/">
Inicio
</Link>
</li>
<li className="nav-item" data-bs-dismiss="offcanvas">
<Link className="nav-link" href="/table">
Table
</Link>
</li>
<li className="nav-item" data-bs-dismiss="offcanvas">
<Link className="nav-link" href="/input">
Input
</Link>
</li>
<li className="nav-item" data-bs-dismiss="offcanvas">
<Link className="nav-link" href="/pagination">
Pagination
</Link>
</li>
<li className="nav-item" data-bs-dismiss="offcanvas">
<Link className="nav-link" href="/options">
Options
</Link>
</li>
<li className="nav-item" data-bs-dismiss="offcanvas">
<Link className="nav-link" href="/styles">
Styles
</Link>
</li>
{navItems.map((item, index) => (
<li
key={index}
className={`nav-item`}
data-bs-dismiss="offcanvas"
>
<Link
className={`nav-link ${
pathname === item.href ? 'text-white' : ''
}`}
href={item.href}
>
{item.label}
</Link>
</li>
))}
</ul>
</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';
interface Option {
export interface Option {
value: string | number;
label: string;
}
@@ -74,7 +74,10 @@ const Select: React.FC<SelectProps> = ({
return (
<div className={`mb-3 ${className?.container ?? ''}`}>
<label htmlFor={selectId} className={`form-label ${className?.label ?? ''}`}>
<label
htmlFor={selectId}
className={`form-label ${className?.label ?? ''}`}
>
{label}
</label>
<select
+1
View File
@@ -1,3 +1,4 @@
// @components/table.tsx
import React, { useState, useMemo } from "react";
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>
);
}
+496
View File
@@ -0,0 +1,496 @@
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>
)}
<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,
};
}
+233
View File
@@ -0,0 +1,233 @@
'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 [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">
<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)
}
/>
<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>
<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}
/>
<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 */}
<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>
);
}
+248
View File
@@ -0,0 +1,248 @@
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 [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,
}
);
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="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 */}
<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,561 @@
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'
);
// ------------------------
// 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);
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 : '',
});
// Usar datos fake para pruebas
/* setCuentaInfo({
nombre: 'Juan',
apellidos: 'Pérez',
correo: 'juan.perez@pcpuma.acatlan.unam.mx',
genero: 'M',
carrera: 'Ingeniería en Computación',
}); */
} catch (error) {
console.error('Error al obtener datos de cuenta:', error);
setCuentaInfo(null);
}
};
if (puedeBuscar) {
fetchCuentaInfo();
} else {
setCuentaInfo(null);
}
}, [respuestas, preguntaCuenta?.id_pregunta, preguntaCuenta?.validacion]);
// ------------------------
// 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,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>
);
}
+369
View File
@@ -0,0 +1,369 @@
'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 { GetEventoWithCuestionario } from '@/types/evento';
import { formatearRangoFechas } from '@/utils/date-utils';
import { SubmitResponse } from '@/types/submit';
import Link from 'next/link';
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 (
<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>
<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">
{data?.nombre_evento}
</p>
</div>
<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">
{data?.cuestionario.nombre_form}
</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>
);
}
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)}
</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
)}
</p>
)}
</div>
</div>
</section>
)}
</div>
{/* Columna derecha - Formulario */}
<div className="col-lg-7">
<div className="ps-lg-4">
{data && (
<FormularioRegistro
evento={data.nombre_evento}
cuestionario={data?.cuestionario.nombre_form}
id_evento={data?.id_evento}
id_cuestionario={data?.cuestionario.id_cuestionario}
handleSubmitFormulario={onSubmitFormulario}
/>
)}
</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';
+1
View File
@@ -0,0 +1 @@
export * from './evento';
+170 -17
View File
@@ -1,16 +1,24 @@
export const plantillasDisponibles = [
import { FormularioCreacion } from '@/types/create-formulario';
interface Plantilla {
imagen: string;
nombre: string;
id: string;
datos: FormularioCreacion;
}
export const plantillasDisponibles: Plantilla[] = [
{
imagen: '/banner1.png',
nombre: 'Registro para la Feria',
id: 'feria-sexualidad',
imagen: '/plantilla_trabajadores.png',
nombre: 'Registro Comunidad Trabajador',
id: 'registro-comunidad-estudiantil',
datos: {
nombre_form: 'Registro para la Feria de la Sexualidad - FES Acatlán',
nombre_form: 'Registro',
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.',
fecha_inicio: '2025-03-07T00:00:01',
fecha_fin: '2025-03-18T23:59:59',
id_tipo_cuestionario: 1,
evento: 'Feria de la Sexualidad',
'Con este formulario estaras 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: 2,
secciones: [
{
titulo: 'Información Personal',
@@ -22,26 +30,107 @@ export const plantillasDisponibles = [
tipo: 'Cerrada',
opciones: [{ valor: 'Si' }, { valor: 'No' }],
obligatoria: true,
validacion: 'comunidad_trabajador',
},
{
titulo: 'Numero de cuenta',
tipo: 'Abierta',
titulo: 'RFC',
tipo: 'Abierta (Respuesta corta)',
obligatoria: false,
validacion: 'rfc',
},
{
titulo: 'Correo electrónico',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: 'correo',
},
{
titulo: 'Nombre(s)',
tipo: 'Abierta',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: 'nombre',
},
{
titulo: 'Apellidos',
tipo: 'Abierta',
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' },
],
},
{
titulo: 'Carrera',
tipo: 'Abierta (Respuesta corta)',
validacion: 'carrera',
obligatoria: true,
},
],
},
],
},
},
{
imagen: '/plantilla_alumnos.png',
nombre: 'Registro Comunidad Alumnos',
id: 'registro-comunidad-alumnos',
datos: {
nombre_form: 'Registro',
descripcion:
'Con este formulario estaras 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: [
{ valor: 'Masculino' },
{ valor: 'Femenino' },
@@ -50,12 +139,14 @@ export const plantillasDisponibles = [
},
{
titulo: 'Institución de procedencia',
tipo: 'Abierta',
tipo: 'Abierta (Respuesta corta)',
validacion: 'institucion',
obligatoria: true,
},
{
titulo: 'Correo electrónico',
tipo: 'Abierta',
titulo: 'Carrera',
tipo: 'Abierta (Respuesta corta)',
validacion: 'carrera',
obligatoria: true,
},
],
@@ -63,4 +154,66 @@ export const plantillasDisponibles = [
],
},
},
{
imagen: '/plantilla_general.png',
nombre: 'Registro General',
id: 'registro-general',
datos: {
nombre_form: 'Registro Para Asistencia General',
descripcion:
'Con este formulario estaras 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: 'Numero de cuenta',
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' }],
},
],
},
],
},
},
];
/*
¡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 {
tr {
box-shadow: var(--bs-box-shadow-sm);
th {
background-color: var(--bg-header-table);
position: sticky;
@@ -39,8 +38,7 @@
tr {
border-radius: 8px;
overflow: hidden;
box-shadow: var(--bs-box-shadow-sm);
&.clickable-row {
cursor: pointer;
&:hover {
+5 -5
View File
@@ -1,11 +1,11 @@
// Archivo: _utilities.scss
$hover-scales: (
1: 1.05,
2: 1.1,
3: 1.15,
4: 1.2,
5: 1.25,
1: 1.025,
2: 1.05,
3: 1.075,
4: 1.1,
5: 1.125,
);
@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 'hover';
@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
@@ -71,10 +79,22 @@ ul.list-unstyled li {
position: relative;
overflow: hidden;
margin: -1.5rem 1rem 0;
height: 60%;
cursor: pointer;
@extend .rounded;
@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 {
@extend .img-fluid, .rounded;
width: 100%;
@@ -85,11 +105,19 @@ ul.list-unstyled li {
.card-caption {
position: absolute;
bottom: 15px;
bottom: 5px;
left: 15px;
color: #fff;
font-weight: bold;
font-size: 1.25rem;
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;
}
}
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;
}
+52
View File
@@ -0,0 +1,52 @@
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
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;
}
+9
View File
@@ -1,17 +1,26 @@
import { GetEvento } from './evento';
/**
* Respuesta del endpoint GET /cuestionario
*/
export interface GetCuestionario {
id_cuestionario: number;
id_evento: number;
nombre_form: string;
banner?: string;
descripcion: string;
contador_secciones: number;
cupo_maximo?: number | null; // Puede ser null si no hay límite
editable: boolean;
fecha_fin: string; // Formato ISO: 'YYYY-MM-DDTHH:mm:ss'
fecha_inicio: string; // Formato ISO: 'YYYY-MM-DDTHH:mm:ss'
id_tipo_cuestionario: number;
}
export interface GetCuestionarioWithEvento extends GetCuestionario {
evento: GetEvento;
}
/* */
/* */
/* */
+104
View File
@@ -0,0 +1,104 @@
import { TipoPregunta, TiposValidacion } from './create-formulario';
export interface GetEvento {
id_evento: number;
nombre_evento: string;
descripcion_evento: string;
tipo_evento: string;
fecha_inicio: string; // formato ISO 8601
fecha_fin: string; // formato ISO 8601
// eslint-disable-next-line @typescript-eslint/no-explicit-any
asistencias: any | null;
banner: string | null; // banner-1755043351827-446638467.jpeg
}
export interface Cuestionario {
id_cuestionario: number;
nombre_form: string;
banner: string | null;
contador_secciones: number;
descripcion: string;
editable: boolean;
fecha_inicio: string; // formato ISO 8601
fecha_fin: string; // formato ISO 8601
id_tipo_cuestionario: number;
id_evento: number;
cupo_maximo: number | null;
tipoCuestionario: {
id_tipo_cuestionario: number;
tipo_cuestionario: string;
};
tipoEvento: {
id_tipo_evento: number;
tipo_evento: string;
} | null;
}
export interface CuestionarioWithCupo extends Cuestionario {
cupos_usados: number;
cupos_disponibles: number;
}
export interface GetCuestionario {
tipo_cuestionario: {
id_tipo_cuestionario: number;
tipo_cuestionario: string;
};
evento: GetEvento;
cuestionario: CuestionarioWithSecciones;
}
export interface CuestionarioWithSecciones extends Cuestionario {
secciones: {
id_cuestionario_seccion: number;
posicion: number;
seccion: {
id_seccion: number;
titulo: string;
contador_pregunta: number;
descripcion: string;
};
preguntas: {
id_seccion_pregunta: number;
posicion: number;
pregunta: {
id_pregunta: number;
pregunta: string;
contador_opcion: number;
obligatoria: boolean;
id_tipo_pregunta: number;
validacion: TiposValidacion;
tipo_pregunta: {
id_tipo: number;
tipo_pregunta: TipoPregunta;
};
opciones: {
id_pregunta_opcion: number;
posicion: number;
id_opcion: number;
opcion: {
id_opcion: number;
opcion: string;
};
}[];
};
}[];
}[];
}
export interface GetEventoWithCuestionariosWithCupos extends GetEvento {
cuestionarios: CuestionarioWithCupo[];
}
export interface GetEventoWithCuestionario extends GetEvento {
cuestionario: Cuestionario;
}
export interface GetEventoWithCuestionarios extends GetEvento {
cuestionarios: Cuestionario[];
total_cuestionarios: number;
}
export interface TipoEvento {
id_tipo_evento: number;
tipo_evento: string;
}
+15
View File
@@ -0,0 +1,15 @@
export interface ParticipacionEvento {
id_participante: number;
id_evento: number;
id_cuestionario: number;
fecha_registro: string; // formato ISO
estatus: boolean;
fecha_asistencia: string | null;
participante: Participante;
}
export interface Participante {
id_participante: number;
correo: string;
id_tipo_user: number;
}
+1
View File
@@ -50,6 +50,7 @@ export interface CuestionarioResponse {
obligatoria: boolean;
id_tipo_pregunta: number;
id_opcion_dependiente: number | null;
validacion: string | null;
tipo_pregunta: TipoPregunta;
opciones: PreguntaOpcion[];
}
+7
View File
@@ -0,0 +1,7 @@
export interface SubmitResponse {
success: boolean;
registrado: boolean;
message: string;
qr_token: string;
qr_buffer: string;
}
+15
View File
@@ -0,0 +1,15 @@
interface User {
id_usuario: number;
id_tipo_usuario: number;
tipo_usuario: string;
nombre: string;
cuenta?: string;
carrera?: string;
}
interface ErrorState {
status: boolean;
message: string;
code: number;
}
+46
View File
@@ -2,6 +2,10 @@ import axios from 'axios';
const API = process.env.NEXT_PUBLIC_API_URL;
if (!API) {
throw new Error('ERROR is not defined');
}
const axiosInstance = axios.create({
baseURL: API, // Replace with your API base URL
timeout: 100000, // Request timeout in milliseconds
@@ -10,4 +14,46 @@ const axiosInstance = axios.create({
},
});
// Request interceptor para agregar el token de autenticación
axiosInstance.interceptors.request.use(
(config) => {
// Obtener el token del sessionStorage
const token = sessionStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor para manejar errores de autenticación
axiosInstance.interceptors.response.use(
(response) => {
return response;
},
(error) => {
// Si el token ha expirado o es inválido (401)
if (error.response?.status === 401) {
// Limpiar el sessionStorage
sessionStorage.removeItem('token');
sessionStorage.removeItem('user');
// Redireccionar al login solo si no estamos ya en la página de login
if (
typeof window !== 'undefined' &&
!window.location.pathname.includes('/login')
) {
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
export default axiosInstance;
+14
View File
@@ -0,0 +1,14 @@
export const tipoEventos = [
{ label: 'Conferencia', value: 'conferencia' },
{ label: 'Taller', value: 'taller' },
{ label: 'Seminario', value: 'seminario' },
{ label: 'Curso', value: 'curso' },
{ label: 'Webinar', value: 'webinar' },
{ label: 'Reunión', value: 'reunion' },
{ label: 'Feria', value: 'feria' },
{ label: 'Concierto', value: 'concierto' },
{ label: 'Exposición', value: 'exposicion' },
{ label: 'Deportivo', value: 'deportivo' },
{ label: 'Cultural', value: 'cultural' },
{ label: 'Otro', value: 'otro' },
];
+326
View File
@@ -1,4 +1,330 @@
/**
* Formatea una fecha en formato legible en español, con opción a incluir hora, minutos y día de la semana.
*
* @param {string} fechaStr - Cadena de texto representando una fecha válida (ISO 8601 o similar).
* @param {Object} [config] - Configuración opcional para mostrar hora, minutos y día de la semana.
* @param {boolean} [config.horas=false] - Indica si se debe incluir la hora.
* @param {boolean} [config.minutos=false] - Indica si se deben incluir los minutos (requiere config.horas).
* @param {boolean} [config.diaSemana=false] - Indica si se debe incluir el día de la semana.
* @returns {string} Fecha formateada en español.
* @throws {Error} Lanza un error si la fecha proporcionada no es válida.
*
* @example
* formatFecha('2025-03-25T14:30:00Z');
* // Retorna: "25 de marzo del 2025"
*
* @example
* formatFecha('2025-03-25T14:30:00Z', { horas: true });
* // Retorna: "25 de marzo del 2025 - 14"
*
* @example
* formatFecha('2025-03-25T14:30:00Z', { horas: true, minutos: true });
* // Retorna: "25 de marzo del 2025 - 14:30"
*
* @example
* formatFecha('2025-03-25T14:30:00Z', { diaSemana: true });
* // Retorna: "martes, 25 de marzo del 2025"
*/
export function formatFecha(
fechaStr: string,
config: { horas?: boolean; minutos?: boolean; diaSemana?: boolean } = {
horas: false,
minutos: false,
diaSemana: false,
}
): string {
const fecha = new Date(fechaStr);
if (isNaN(fecha.getTime())) {
throw new Error(`Fecha inválida: ${fechaStr}`);
}
const diasSemana = [
'domingo',
'lunes',
'martes',
'miércoles',
'jueves',
'viernes',
'sábado',
];
const meses = [
'enero',
'febrero',
'marzo',
'abril',
'mayo',
'junio',
'julio',
'agosto',
'septiembre',
'octubre',
'noviembre',
'diciembre',
];
const dia = fecha.getUTCDate();
const mes = meses[fecha.getUTCMonth()];
const año = fecha.getUTCFullYear();
const hora = (fecha.getUTCHours() - 6 + 24) % 24;
const minutos = fecha.getUTCMinutes().toString().padStart(2, '0');
const diaSemana = diasSemana[fecha.getUTCDay()];
let fechaFormateada = '';
if (config.diaSemana) {
fechaFormateada += `${diaSemana}, `;
}
fechaFormateada += `${dia} de ${mes} del ${año}`;
if (config.horas) {
fechaFormateada += ` - ${hora}`;
if (config.minutos) {
fechaFormateada += `:${minutos}`;
}
}
return fechaFormateada;
}
export function formatearFecha(fecha: string, tipo: 'inicio' | 'fin'): string {
const sufijo = tipo === 'inicio' ? 'T00:00:01' : 'T23:59:59';
return `${fecha}${sufijo}`;
}
export function formatDateLocal(date: Date): string {
const pad = (n: number) => n.toString().padStart(2, '0');
const yyyy = date.getFullYear();
const MM = pad(date.getMonth() + 1);
const dd = pad(date.getDate());
const hh = pad(date.getHours());
const mm = pad(date.getMinutes());
return `${yyyy}-${MM}-${dd}T${hh}:${mm}`;
}
/**
* Formatea un rango de fechas en español, optimizando la salida según si coinciden año, mes y día.
*
* @param {string} fechaInicio - Fecha de inicio en formato ISO 8601 (ej: '2025-08-13T18:44:00.000Z').
* @param {string} fechaFin - Fecha de fin en formato ISO 8601 (ej: '2025-08-15T18:44:00.000Z').
* @returns {string} Rango de fechas formateado en español.
* @throws {Error} Lanza un error si alguna de las fechas proporcionadas no es válida.
*
* @example
* formatearRangoFechas('2025-08-13T12:00:00.000Z', '2025-08-13T18:00:00.000Z');
* // Retorna: "el 13 de agosto del 2025 de 12:00 a 18:00"
*
* @example
* formatearRangoFechas('2025-08-13T18:44:00.000Z', '2025-08-15T18:44:00.000Z');
* // Retorna: "del 13 al 15 de agosto del 2025"
*
* @example
* formatearRangoFechas('2025-08-13T18:44:00.000Z', '2025-09-13T18:44:00.000Z');
* // Retorna: "del 13 de agosto al 13 de septiembre del 2025"
*
* @example
* formatearRangoFechas('2025-08-13T18:44:00.000Z', '2026-01-13T18:44:00.000Z');
* // Retorna: "del 13 de agosto del 2025 al 13 de enero del 2026"
*/
export function formatearRangoFechas(
fechaInicio: string,
fechaFin: string
): string {
const inicio = new Date(fechaInicio);
const fin = new Date(fechaFin);
if (isNaN(inicio.getTime())) {
throw new Error(`Fecha de inicio inválida: ${fechaInicio}`);
}
if (isNaN(fin.getTime())) {
throw new Error(`Fecha de fin inválida: ${fechaFin}`);
}
const meses = [
'enero',
'febrero',
'marzo',
'abril',
'mayo',
'junio',
'julio',
'agosto',
'septiembre',
'octubre',
'noviembre',
'diciembre',
];
const diaInicio = inicio.getUTCDate();
const mesInicio = meses[inicio.getUTCMonth()];
const añoInicio = inicio.getUTCFullYear();
const diaFin = fin.getUTCDate();
const mesFin = meses[fin.getUTCMonth()];
const añoFin = fin.getUTCFullYear();
// Si es el mismo día (año, mes y día iguales)
if (
añoInicio === añoFin &&
inicio.getUTCMonth() === fin.getUTCMonth() &&
diaInicio === diaFin
) {
const horaInicio = (inicio.getUTCHours() - 6 + 24) % 24;
const minutosInicio = inicio.getUTCMinutes().toString().padStart(2, '0');
const horaFin = (fin.getUTCHours() - 6 + 24) % 24;
const minutosFin = fin.getUTCMinutes().toString().padStart(2, '0');
return `el ${diaInicio} de ${mesInicio} del ${añoInicio} de ${horaInicio}:${minutosInicio} a ${horaFin}:${minutosFin}`;
}
// Si el año y mes son iguales pero días diferentes
if (añoInicio === añoFin && inicio.getUTCMonth() === fin.getUTCMonth()) {
return `del ${diaInicio} al ${diaFin} de ${mesInicio} del ${añoInicio}`;
}
// Si el año es igual pero el mes es diferente
if (añoInicio === añoFin) {
return `del ${diaInicio} de ${mesInicio} al ${diaFin} de ${mesFin} del ${añoInicio}`;
}
// Si tanto el año como el mes son diferentes
return `del ${diaInicio} de ${mesInicio} del ${añoInicio} al ${diaFin} de ${mesFin} del ${añoFin}`;
}
/**
* Formatea fechas para mostrar en las cartas de eventos, optimizando el espacio disponible.
* Retorna un objeto con el texto del mes y el texto del día para mostrar en la interfaz.
*
* @param {Date | string} fechaInicio - Fecha de inicio del evento.
* @param {Date | string} fechaFin - Fecha de fin del evento.
* @returns {{ mesTexto: string, diaTexto: string }} Objeto con texto del mes y día formateados.
* @throws {Error} Lanza un error si alguna de las fechas proporcionadas no es válida.
*
* @example
* formatearFechaCard(new Date('2025-08-15'), new Date('2025-08-15'));
* // Retorna: { mesTexto: 'AGO', diaTexto: '15' }
*
* @example
* formatearFechaCard(new Date('2025-08-15'), new Date('2025-08-20'));
* // Retorna: { mesTexto: 'AGO', diaTexto: '15 - 20' }
*
* @example
* formatearFechaCard(new Date('2025-08-28'), new Date('2025-09-05'));
* // Retorna: { mesTexto: 'AGO - SEP', diaTexto: '28 - 5' }
*/
export function formatearFechaCard(
fechaInicio: Date | string,
fechaFin: Date | string
): { mesTexto: string; diaTexto: string } {
// Convertir a Date si son strings
const inicio =
typeof fechaInicio === 'string' ? new Date(fechaInicio) : fechaInicio;
const fin = typeof fechaFin === 'string' ? new Date(fechaFin) : fechaFin;
if (isNaN(inicio.getTime())) {
throw new Error(`Fecha de inicio inválida: ${fechaInicio}`);
}
if (isNaN(fin.getTime())) {
throw new Error(`Fecha de fin inválida: ${fechaFin}`);
}
const mesInicioCorto = inicio
.toLocaleDateString('es-ES', { month: 'short' })
.toUpperCase();
const mesFinCorto = fin
.toLocaleDateString('es-ES', { month: 'short' })
.toUpperCase();
const diaInicio = inicio.getDate();
const diaFin = fin.getDate();
// Si es el mismo día
const esElMismoDia = inicio.toDateString() === fin.toDateString();
// Si es el mismo mes pero diferentes días
const esElMismoMes =
inicio.getMonth() === fin.getMonth() &&
inicio.getFullYear() === fin.getFullYear();
if (esElMismoDia) {
return {
mesTexto: mesInicioCorto,
diaTexto: diaInicio.toString(),
};
} else if (esElMismoMes) {
return {
mesTexto: mesInicioCorto,
diaTexto: `${diaInicio} - ${diaFin}`,
};
} else {
// Diferentes meses
return {
mesTexto: `${mesInicioCorto} - ${mesFinCorto}`,
diaTexto: `${diaInicio} - ${diaFin}`,
};
}
}
/**
* Extrae y formatea el horario entre dos fechas.
* Retorna el rango de horas en formato "HH:MM a HH:MM Hrs".
*
* @param {string | Date} fechaInicio - Fecha de inicio en formato ISO 8601 o objeto Date.
* @param {string | Date} fechaFin - Fecha de fin en formato ISO 8601 o objeto Date.
* @returns {string} Horario formateado en formato "HH:MM a HH:MM Hrs".
* @throws {Error} Lanza un error si alguna de las fechas proporcionadas no es válida.
*
* @example
* formatearHorario('2025-08-13T13:00:00.000Z', '2025-08-13T14:00:00.000Z');
* // Retorna: "07:00 a 08:00 Hrs" (ajustado a UTC-6)
*
* @example
* formatearHorario(new Date('2025-08-13T19:30:00.000Z'), new Date('2025-08-13T21:45:00.000Z'));
* // Retorna: "13:30 a 15:45 Hrs"
*
* @example
* formatearHorario('2025-08-13T13:00:00.000Z', '2025-08-13T13:00:00.000Z');
* // Retorna: "07:00 Hrs" (misma hora)
*/
export function formatearHorario(
fechaInicio: string | Date,
fechaFin: string | Date
): string {
// Convertir a Date si son strings
const inicio =
typeof fechaInicio === 'string' ? new Date(fechaInicio) : fechaInicio;
const fin = typeof fechaFin === 'string' ? new Date(fechaFin) : fechaFin;
if (isNaN(inicio.getTime())) {
throw new Error(`Fecha de inicio inválida: ${fechaInicio}`);
}
if (isNaN(fin.getTime())) {
throw new Error(`Fecha de fin inválida: ${fechaFin}`);
}
// Ajustar a zona horaria UTC-6 (México)
const horaInicio = (inicio.getUTCHours() - 6 + 24) % 24;
const minutosInicio = inicio.getUTCMinutes().toString().padStart(2, '0');
const horaFin = (fin.getUTCHours() - 6 + 24) % 24;
const minutosFin = fin.getUTCMinutes().toString().padStart(2, '0');
const horaInicioFormateada = `${horaInicio
.toString()
.padStart(2, '0')}:${minutosInicio}`;
const horaFinFormateada = `${horaFin
.toString()
.padStart(2, '0')}:${minutosFin}`;
// Si es la misma hora, solo mostrar una vez
if (horaInicioFormateada === horaFinFormateada) {
return `${horaInicioFormateada} Hrs`;
}
return `${horaInicioFormateada} a ${horaFinFormateada}`;
}
+24
View File
@@ -0,0 +1,24 @@
export const downloadFile = (
blob: Blob,
fileName: string,
extension: 'xlsx' | 'pdf' | 'csv' | 'txt'
) => {
// Asegurar la extensión correcta
const finalFileName = fileName.endsWith(`.${extension}`)
? fileName
: `${fileName}.${extension}`;
// Crear una URL para el blob
const url = window.URL.createObjectURL(blob);
// Crear un enlace invisible
const a = document.createElement('a');
a.href = url;
a.download = finalFileName;
document.body.appendChild(a);
a.click();
// Limpiar la URL creada
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
};
+38
View File
@@ -0,0 +1,38 @@
import { AxiosError } from 'axios';
export const getAxiosError = (error: unknown): ErrorState => {
console.error('AXIOS ERROR', error);
if (error instanceof AxiosError) {
if (error.response?.data?.message) {
if (error.response.data.message === 'Internal server error') {
return {
code: 500,
message:
'Ocurrió un error inesperado, por favor intenta de nuevo más tarde',
status: true,
};
}
return {
code: error.response.status,
message: error.response.data.message,
status: true,
};
}
if (error.message === 'Network Error') {
return {
code: 0,
message: 'Error de conexión, por favor verifica tu conexión a internet',
status: true,
};
}
}
return {
code: 0,
message: 'Ocurrió un error inesperado',
status: true,
};
};
+57
View File
@@ -0,0 +1,57 @@
import { CuestionarioResponse } from "@/types/responder-formulario";
import toast from "react-hot-toast";
import { validarRespuesta } from "./validador";
export const validarRespuestasObligatorias = (
cuestionario: CuestionarioResponse | null,
respuestas: Record<number, string | number | Array<string | number>>
): boolean => {
const faltantes: number[] = [];
const errores: { id: number; mensaje: string }[] = [];
cuestionario?.cuestionario.secciones.forEach((seccion) => {
seccion.preguntas.forEach(({ pregunta }) => {
const id = pregunta.id_pregunta;
const valor = respuestas[id];
const tipo = pregunta.tipo_pregunta.tipo_pregunta;
const validacion = pregunta.validacion;
const respondida =
(tipo === 'Abierta' &&
typeof valor === 'string' &&
valor.trim() !== '') ||
(tipo === 'Cerrada' && valor !== undefined) ||
(tipo === 'Multiple' && Array.isArray(valor) && valor.length > 0);
if (!respondida) {
if (pregunta.obligatoria) {
faltantes.push(id);
}
return; // no hay nada más que validar si está vacía
}
if (tipo === 'Abierta' && validacion) {
const resultado = validarRespuesta(valor as string, validacion);
if (!resultado.valido) {
errores.push({ id, mensaje: resultado.mensaje });
}
}
});
});
if (faltantes.length > 0) {
toast.error('Responde todas las preguntas obligatorias antes de enviar.');
return false;
}
if (errores.length > 0) {
toast.error(
`Corrige las respuestas con error de formato:\n${errores
.map((e) => `${e.mensaje}`)
.join('\n')}`
);
return false;
}
return true;
};

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