Merge pull request 'develop' (#1) from develop into master

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2025-04-07 14:38:22 -06:00
50 changed files with 3690 additions and 81 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"useTabs": false,
"trailingComma": "es5",
"printWidth": 80,
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf"
}
+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": "" }
}
}
}
}
}
+214 -17
View File
@@ -8,14 +8,18 @@
"name": "formularios_front",
"version": "0.1.0",
"dependencies": {
"@yudiel/react-qr-scanner": "^2.2.1",
"axios": "^1.8.4",
"bootstrap": "^5.3.3",
"bootstrap-icons": "^1.11.3",
"next": "15.2.4",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"react-hot-toast": "^2.5.2"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/bootstrap": "^5.2.10",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -1157,7 +1161,6 @@
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
@@ -1203,6 +1206,22 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/bootstrap": {
"version": "5.2.10",
"resolved": "https://registry.npmjs.org/@types/bootstrap/-/bootstrap-5.2.10.tgz",
"integrity": "sha512-F2X+cd6551tep0MvVZ6nM8v7XgGN/twpdNDjqS1TUM7YFNEtQYWk+dKAnH+T1gr6QgCoGMPl487xw/9hXooa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@popperjs/core": "^2.9.2"
}
},
"node_modules/@types/emscripten": {
"version": "1.40.1",
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.40.1.tgz",
"integrity": "sha512-sr53lnYkQNhjHNN0oJDdUm5564biioI5DuOpycufDVK7D3y+GR3oUswe2rlwY1nPNyusHbrJ9WoTyIHl4/Bpwg==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
@@ -1703,6 +1722,20 @@
"win32"
]
},
"node_modules/@yudiel/react-qr-scanner": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@yudiel/react-qr-scanner/-/react-qr-scanner-2.2.1.tgz",
"integrity": "sha512-5tmzoi9d8mqqaxKTxfI9kulS3N3Kph75AOqwU2lnl4IMwjmLcTXS8IyecDUvzFNuoMNhCpofrqqGueXm8IXkjA==",
"license": "MIT",
"dependencies": {
"barcode-detector": "^3.0.0",
"webrtc-adapter": "9.0.1"
},
"peerDependencies": {
"react": "^17 || ^18 || ^19",
"react-dom": "^17 || ^18 || ^19"
}
},
"node_modules/acorn": {
"version": "8.14.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
@@ -1951,6 +1984,12 @@
"node": ">= 0.4"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -1977,6 +2016,17 @@
"node": ">=4"
}
},
"node_modules/axios": {
"version": "1.8.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz",
"integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -1994,6 +2044,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/barcode-detector": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-3.0.1.tgz",
"integrity": "sha512-3fCzG/Py4SVgZJhubD1mt7rVprtHEVWrxQN4FUOG0oulPE4193evbgyptxcOYsfTNEtMlWc+Ec9tdxhjlR4/Ww==",
"license": "MIT",
"dependencies": {
"zxing-wasm": "^2.1.0"
}
},
"node_modules/bootstrap": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz",
@@ -2087,7 +2146,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -2228,6 +2286,18 @@
"simple-swizzle": "^0.2.2"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -2254,7 +2324,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true,
"license": "MIT"
},
"node_modules/damerau-levenshtein": {
@@ -2379,6 +2448,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/detect-libc": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz",
@@ -2406,7 +2484,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
@@ -2494,7 +2571,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -2504,7 +2580,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -2542,7 +2617,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -2555,7 +2629,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -3162,6 +3235,26 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -3178,11 +3271,25 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/form-data": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -3223,7 +3330,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -3248,7 +3354,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
@@ -3332,11 +3437,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/goober": {
"version": "2.1.16",
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz",
"integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==",
"license": "MIT",
"peerDependencies": {
"csstype": "^3.0.10"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -3408,7 +3521,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -3421,7 +3533,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@@ -3437,7 +3548,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -4086,7 +4196,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -4116,6 +4225,27 @@
"node": ">=8.6"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -4543,6 +4673,12 @@
"react-is": "^16.13.1"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -4595,6 +4731,23 @@
"react": "^19.1.0"
}
},
"node_modules/react-hot-toast": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.5.2.tgz",
"integrity": "sha512-Tun3BbCxzmXXM7C+NI4qiv6lT0uwGh4oAfeJyNOjYUejTsm35mK9iCaYLGv8cBz9L5YxZLx/2ii7zsIwPtPUdw==",
"license": "MIT",
"dependencies": {
"csstype": "^3.1.3",
"goober": "^2.1.16"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">=16",
"react-dom": ">=16"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -4818,6 +4971,12 @@
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
"license": "MIT"
},
"node_modules/sdp": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.0.tgz",
"integrity": "sha512-d7wDPgDV3DDiqulJjKiV2865wKsJ34YI+NDREbm+FySq6WuKOikwyNQcm+doLAZ1O6ltdO0SeKle2xMpN3Brgw==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
@@ -5341,6 +5500,18 @@
"node": ">= 0.8.0"
}
},
"node_modules/type-fest": {
"version": "4.39.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.39.1.tgz",
"integrity": "sha512-uW9qzd66uyHYxwyVBYiwS4Oi0qZyUqwjU+Oevr6ZogYiXt99EOYtwvzMSLw1c3lYo2HzJsep/NB23iEVEgjG/w==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typed-array-buffer": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
@@ -5496,6 +5667,19 @@
"punycode": "^2.1.0"
}
},
"node_modules/webrtc-adapter": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.1.tgz",
"integrity": "sha512-1AQO+d4ElfVSXyzNVTOewgGT/tAomwwztX/6e3totvyyzXPvXIIuUUjAmyZGbKBKbZOXauuJooZm3g6IuFuiNQ==",
"license": "BSD-3-Clause",
"dependencies": {
"sdp": "^3.2.0"
},
"engines": {
"node": ">=6.0.0",
"npm": ">=3.10.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -5623,6 +5807,19 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zxing-wasm": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-2.1.0.tgz",
"integrity": "sha512-CvuwDZHRHwg6PeCARaCDIp3dauD4cin0mbHrQQZtMDrr5mblPzCimAjdw/XMhD/Au10q/f5+SAupvYqYvUOg1Q==",
"license": "MIT",
"dependencies": {
"@types/emscripten": "^1.40.0",
"type-fest": "^4.35.0"
},
"peerDependencies": {
"@types/emscripten": ">=1.39.6"
}
}
}
}
+5 -1
View File
@@ -9,14 +9,18 @@
"lint": "next lint"
},
"dependencies": {
"@yudiel/react-qr-scanner": "^2.2.1",
"axios": "^1.8.4",
"bootstrap": "^5.3.3",
"bootstrap-icons": "^1.11.3",
"next": "15.2.4",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"react-hot-toast": "^2.5.2"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/bootstrap": "^5.2.10",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

@@ -0,0 +1,585 @@
'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,
limite: 250,
validacion: undefined,
});
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']
)
}
/>
{pregunta.tipo === 'Abierta' && (
<div className="row">
<div className="col-md-6">
<Input
label="Límite de caracteres"
type="number"
value={pregunta.limite?.toString() || ''}
onChange={(e) =>
actualizarPregunta(
paginaActual - 1,
pIdx,
'limite',
parseInt(e.target.value, 10)
)
}
placeholder="Máximo de caracteres permitidos"
/>
</div>
<div className="col-md-6">
<Select
label="Validación"
value={pregunta.validacion || ''}
options={[
{ label: 'Sin validación', value: '' },
{ label: 'Correo', value: 'correo' },
{ label: 'Número de cuenta', value: 'cuenta_alumno' },
{ label: 'Nombre/Texto', value: 'nombre' },
]}
onChange={(e) =>
actualizarPregunta(
paginaActual - 1,
pIdx,
'validacion',
e.target.value as PreguntaFormulario['validacion']
)
}
/>
</div>
</div>
)}
<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" disabled={loading}>
Guardar formulario
</Button>
</>
);
}
@@ -0,0 +1,25 @@
'use client';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import React from 'react';
export default function Layout({ children }: { children: React.ReactNode }) {
const params = useParams<{ id_formulario: string }>();
return (
<div>
<nav className='d-flex gap-2 my-4'>
<Link href={`/administrador/formulario/${params.id_formulario}`} 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>
);
}
@@ -0,0 +1,143 @@
'use client';
import Input from '@/components/input';
import Table, { Header } from '@/components/table';
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';
export default function Page() {
const params = useParams<{ id_formulario: string }>();
const id_formulario = Number(params?.id_formulario);
const [participantes, setParticipantes] = React.useState<
ParticipacionEvento[]
>([]);
const [loadingAsistencia, setLoadingAsistencia] = React.useState<
Record<number, boolean>
>({});
const [busquedaCorreo, setBusquedaCorreo] = React.useState('');
useEffect(() => {
if (!id_formulario) return;
const fetchParticipantes = async () => {
try {
const { data } = await axiosInstance.get<ParticipacionEvento[]>(
`/participante-evento/evento/${id_formulario}`
);
setParticipantes(data);
} catch (error) {
console.error('Error al obtener participantes:', error);
}
};
fetchParticipantes();
}, [id_formulario]);
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}`
);
setParticipantes(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_inscripcion',
label: 'Fecha inscripción',
render: (val) => new Date(val as string).toLocaleString(),
},
{
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_evento)
}
disabled={isLoading}
>
{isLoading ? (
<>
<span
className="spinner-border spinner-border-sm me-2"
role="status"
aria-hidden="true"
></span>
Confirmando...
</>
) : (
'Confirmar asistencia'
)}
</button>
);
},
},
];
const participantesFiltrados = participantes.filter((p) =>
p.participante.correo.toLowerCase().includes(busquedaCorreo.toLowerCase())
);
return (
<div>
<h2 className="mb-4">Participantes del formulario #{id_formulario}</h2>
{participantes.length > 0 && (
<>
<Input
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>
);
}
+27
View File
@@ -0,0 +1,27 @@
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
@@ -0,0 +1,60 @@
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>
</>
);
}
+27
View File
@@ -0,0 +1,27 @@
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={'/staff/'} className='text-decoration-none'>
<div className='box'>Escaner</div>
</Link>
<Link
href={'/staff/lista-manual'}
className='text-decoration-none'
>
<div className='box'>Lista manual</div>
</Link>
</nav>
{children}
</main>
<Footer />
</div>
);
}
@@ -0,0 +1,160 @@
'use client';
import Button from '@/components/button';
import Input from '@/components/input';
import Select from '@/components/select';
import Table, { Header } from '@/components/table';
import { GetCuestionario } from '@/types/cuestionario';
import { ParticipacionEvento } from '@/types/participante-evento';
import axiosInstance from '@/utils/api-config';
import React, { useEffect } from 'react';
import toast from 'react-hot-toast';
export default function Page() {
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_inscripcion',
label: 'Fecha inscripción',
render: (val) => new Date(val as string).toLocaleString(),
},
{
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
size="sm"
outline
onClick={() =>
confirmarAsistencia(row.id_participante, row.id_evento)
}
disabled={isLoading}
>
{isLoading ? (
<>
<span
className="spinner-border spinner-border-sm me-2"
role="status"
aria-hidden="true"
></span>
Confirmando...
</>
) : (
'Confirmar asistencia'
)}
</Button>
);
},
},
];
const [eventos, setEventos] = React.useState<GetCuestionario[]>([]);
const [participantes, setParticipantes] = React.useState<
ParticipacionEvento[]
>([]);
const [loadingAsistencia, setLoadingAsistencia] = React.useState<
Record<number, boolean>
>({});
const [busquedaCorreo, setBusquedaCorreo] = React.useState('');
useEffect(() => {
const getEventos = async () => {
try {
const { data } = await axiosInstance.get(`/cuestionario`);
if (!data) throw new Error('No se encontraron eventos');
setEventos(data);
} catch (error) {
console.error('Error en getEventos:', error);
}
};
getEventos();
}, []);
const handleOnSelect = async (idSeleccionado: number) => {
try {
const { data } = await axiosInstance.get<ParticipacionEvento[]>(
`/participante-evento/evento/${idSeleccionado}`
);
setParticipantes(data);
} catch (error) {
console.error('Error al obtener participante-evento:', error);
}
};
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
await handleOnSelect(id_evento);
} catch (error) {
toast.error('Error al confirmar asistencia');
console.error(error);
} finally {
setLoadingAsistencia((prev) => ({ ...prev, [id_participante]: false }));
}
};
const participantesFiltrados = participantes.filter((p) =>
p.participante.correo.toLowerCase().includes(busquedaCorreo.toLowerCase())
);
return (
<div>
<Select
label="Selecciona un evento"
options={eventos.map((evento) => ({
value: evento.id_cuestionario,
label: evento.nombre_form,
}))}
onChange={(e) => {
const id = Number(e?.target?.value ?? e); // Por si el componente Select devuelve directamente el value
if (!isNaN(id)) handleOnSelect(id);
}}
placeholder="Selecciona un evento"
/>
{participantes.length > 0 && (
<>
<Input
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>
);
}
+144
View File
@@ -0,0 +1,144 @@
'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_evento: 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_evento) {
try {
const response = await axiosInstance.get(
`/participante-evento/${data.id_participante}/${data.id_evento}`
);
setParticipante(response.data.participante.correo);
setScannedData({
id_participante: data.id_participante,
id_evento: data.id_evento,
});
setShowModal(true);
setEnableScan(false);
} catch (err) {
console.warn('Participante no registrado en el evento:', 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_evento}`
);
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 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>Participante:</strong> {participante}
</p>
<Button variant="success" onClick={handleConfirm}>
Confirmar lectura
</Button>
</>
) : (
<p>QR inválido</p>
)}
</Modal>
</main>
);
}
+15
View File
@@ -0,0 +1,15 @@
import Footer from '@/components/layout/footer';
import Header from '@/components/layout/header';
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='flex-grow-1 d-flex flex-column justify-content-center align-items-center'>
<div className='container'>{children}</div>
</main>
<Footer />
</div>
);
}
@@ -0,0 +1,5 @@
import React from 'react';
export default function Page() {
return <div>Page</div>;
}
+33
View File
@@ -0,0 +1,33 @@
'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>
);
}
+13
View File
@@ -0,0 +1,13 @@
import Footer from '@/components/layout/footer';
import Header from '@/components/layout/header';
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">{children}</main>
<Footer />
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
import ClientCarousel from '@/client-components/client-carousel';
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>;
}
function slugify(text: string): string {
return text
.toString()
.normalize('NFD') // Quita tildes
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-') // Reemplaza cualquier cosa que no sea alfanumérica por guiones
.replace(/^-+|-+$/g, ''); // Quita guiones al inicio o final
}
return (
<>
<div className="mx-auto my-5">
<ClientCarousel />
</div>
<div className="cards-1 mt-5">
<div className="container">
<div className="row">
{eventos.map((cuestionario) => {
const slug = slugify(cuestionario.nombre_form); // Asumiendo que el nombre del evento está en `nombre`
const url = `/registro/${slug}-${cuestionario.id_cuestionario}`;
return (
<FormularioCard
key={cuestionario.id_cuestionario}
cuestionario={cuestionario}
link={url}
button_message="Registrarse"
/>
);
})}
</div>
</div>
</div>
</>
);
}
+80
View File
@@ -0,0 +1,80 @@
'use client';
import Button from '@/components/button';
import Formulario from '@/containers/formulario';
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';
export default function Page() {
const router = useRouter();
const params = useParams<{ slug: string }>();
const slug = params?.slug;
const id_formulario = slug?.split('-').pop(); // Toma el último fragmento como ID
const [cuestionario, setCuestionario] = useState<CuestionarioResponse | null>(
null
);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
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]);
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>
<Formulario
id_formulario={cuestionario.cuestionario.id_cuestionario}
secciones={cuestionario.cuestionario.secciones}
/>
</div>
</div>
);
}
+2
View File
@@ -4,6 +4,7 @@ import { Roboto } from "next/font/google";
import "bootstrap-icons/font/bootstrap-icons.css";
import "@/styles/sass/bootstrap.scss";
import BootstrapClient from "@/components/bootstrap-client";
import { Toaster } from "react-hot-toast";
const roboto = Roboto({
weight: ["100", "300", "400", "500", "700", "900"],
@@ -27,6 +28,7 @@ export default function RootLayout({
<html lang="en">
<body className={roboto.className}>
<BootstrapClient />
<Toaster />
{children}
</body>
</html>
-14
View File
@@ -1,14 +0,0 @@
import Footer from "@/components/layout/footer";
import Header from "@/components/layout/header";
import React from "react";
export default function Page() {
return (
<div className="d-flex flex-column min-vh-100">
<Header />
<div className="container flex-grow-1">
</div>
<Footer />
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
'use client';
import dynamic from 'next/dynamic';
import React from 'react';
const Carousel = dynamic(() => import('@/components/carousel'), {
ssr: false,
});
export default function ClientCarousel() {
return <Carousel />;
}
+82
View File
@@ -0,0 +1,82 @@
import Image from 'next/image';
import React from 'react';
interface CarouselProps {
id?: string;
images?: {
src: string;
alt?: string;
}[];
}
export default function Carousel({
id = 'carouselExample',
images = [
{
src: '/banner1.png',
alt: 'Imagen de ejemplo',
},
{
src: '/banner2.png',
alt: 'Imagen de ejemplo',
},
],
}: CarouselProps) {
if (!images || images.length === 0) {
return <div>No hay imágenes para mostrar</div>;
}
return (
<div id={id} className="carousel slide" data-bs-ride="carousel">
<div className="carousel-inner">
{images.map((img, index) => (
<div
key={index}
className={`carousel-item ${index === 0 ? 'active' : ''}`}
>
<Image
src={img.src}
width={1000}
height={400}
alt="Ejemplo de banner"
className="d-block w-100 rounded-3"
style={{
objectFit: 'cover',
objectPosition: 'center',
}}
/>
</div>
))}
</div>
{images.length > 1 && (
<>
<button
className="carousel-control-prev"
type="button"
data-bs-target={`#${id}`}
data-bs-slide="prev"
>
<span
className="carousel-control-prev-icon"
aria-hidden="true"
></span>
<span className="visually-hidden">Anterior</span>
</button>
<button
className="carousel-control-next"
type="button"
data-bs-target={`#${id}`}
data-bs-slide="next"
>
<span
className="carousel-control-next-icon"
aria-hidden="true"
></span>
<span className="visually-hidden">Siguiente</span>
</button>
</>
)}
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import React, { useState } from 'react';
export interface FloatingInputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'className'> {
label?: string;
className?: {
container?: string;
label?: string;
input?: string;
};
}
/**
* Componente `FloatingInput` reutilizable con soporte para estilo flotante de Bootstrap.
*
* Similar al componente `Input`, pero adaptado al sistema `form-floating` de Bootstrap.
* Soporta personalización de clases, tipo `password` con visibilidad opcional y accesibilidad.
*/
export default function FloatingInput(props: FloatingInputProps) {
const { className, label, type, name, id, ...rest } = props;
const [showPassword, setShowPassword] = useState(false);
const isPassword = type === 'password';
const inputType = isPassword && showPassword ? 'text' : type;
const inputId =
id ||
name ||
(label ? `floating-${label.replace(/\s+/g, '-')}` : undefined);
const togglePasswordVisibility = () => setShowPassword(!showPassword);
return (
<div className={`form-floating bg-white ${className?.container || 'mb-3'}`}>
<input
id={inputId}
name={name}
type={inputType}
className={`form-control ${className?.input || ''}`}
placeholder={label || ''}
{...rest}
/>
{label && (
<label htmlFor={inputId} className={className?.label || ''}>
{label}
</label>
)}
{/* Botón para mostrar/ocultar contraseña */}
{isPassword && (
<button
type='button'
className='btn btn-light border position-absolute top-50 end-0 translate-middle-y me-2'
style={{ zIndex: 5 }}
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>
);
}
+75
View File
@@ -0,0 +1,75 @@
'use client';
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,
button_message,
}: Props) {
const router = useRouter();
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="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="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='primary'>
{button_message}
</Button>
)}
</div>
</div>
</div>
</div>
);
}
+12 -11
View File
@@ -1,7 +1,8 @@
import React, { useState } from "react";
// @components/input.tsx
import React, { useState } from 'react';
export interface InputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "className"> {
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'className'> {
label?: string;
className?: {
container?: string;
@@ -35,19 +36,19 @@ export default function Input(props: InputProps) {
const { className, label, type, name, id, ...rest } = props;
const [showPassword, setShowPassword] = useState(false);
const isPassword = type === "password";
const inputType = isPassword && showPassword ? "text" : type;
const isPassword = type === 'password';
const inputType = isPassword && showPassword ? 'text' : type;
const inputId =
id || name || (label ? `input-${label.replace(/\s+/g, "-")}` : undefined);
id || name || (label ? `input-${label.replace(/\s+/g, '-')}` : undefined);
const togglePasswordVisibility = () => setShowPassword(!showPassword);
return (
<div className={className?.container || "mb-3"}>
<div className={className?.container || 'mb-3'}>
{label && inputId && (
<label
htmlFor={inputId}
className={`form-label ${className?.label || ""}`}
className={`form-label ${className?.label || ''}`}
>
{label}
</label>
@@ -57,7 +58,7 @@ export default function Input(props: InputProps) {
id={inputId}
name={name}
type={inputType}
className={`form-control bg-white ${className?.input || ""}`}
className={`form-control ${className?.input || ''}`}
{...rest}
/>
{isPassword && (
@@ -66,11 +67,11 @@ export default function Input(props: InputProps) {
className="btn btn-light border"
onClick={togglePasswordVisibility}
aria-label={
showPassword ? "Ocultar contraseña" : "Mostrar contraseña"
showPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'
}
title={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>
<i className={`bi ${showPassword ? 'bi-eye-slash' : 'bi-eye'}`}></i>
</button>
)}
</div>
+98
View File
@@ -0,0 +1,98 @@
'use client';
import React, { useEffect, useRef } from 'react';
import { Modal as BootstrapModal } from 'bootstrap';
interface ModalProps {
children?: React.ReactNode;
isVisible: boolean;
closeButton?: boolean;
size: 'sm' | 'md' | 'lg' | 'xl';
className?: {
dialog?: string;
content?: string;
body?: string;
};
onClose: () => void;
}
export default function Modal({
children,
isVisible,
size,
className,
closeButton = true,
onClose,
}: ModalProps) {
const ref = useRef<HTMLDivElement | null>(null);
const modalInstance = useRef<BootstrapModal | null>(null);
const modalSizeClass = size ? `modal-${size}` : '';
useEffect(() => {
const node = ref.current;
if (node) {
modalInstance.current = new BootstrapModal(node);
const handleClose = () => {
const elements = document.querySelectorAll<HTMLDivElement>(
'.modal-backdrop.fade.show'
);
if (elements.length > 0) {
elements.forEach((element) => {
element.remove();
});
document.body.removeAttribute('style');
}
onClose();
};
node.addEventListener('hidden.bs.modal', handleClose);
return () => {
if (node) {
node.removeEventListener('hidden.bs.modal', handleClose);
}
};
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (isVisible) {
modalInstance.current?.show();
} else {
modalInstance.current?.hide();
}
}, [isVisible]);
return (
<div
ref={ref}
className="modal fade"
tabIndex={-1}
aria-labelledby="modal"
aria-hidden="true"
>
<div
className={`modal-dialog modal-dialog-centered ${className?.dialog} ${modalSizeClass}`}
>
<div className={`modal-content ${className?.content}`}>
{closeButton && (
<div className="position-relative">
<button
type="button"
className="btn-close position-absolute top-0 end-0 m-2"
data-bs-dismiss="modal"
aria-label="Close"
style={{
zIndex: 999,
}}
></button>
</div>
)}
<div className={`modal-body ${className?.body}`}>{children}</div>
</div>
</div>
</div>
);
}
+14 -18
View File
@@ -1,4 +1,4 @@
import React, { useMemo } from "react";
import React, { useMemo } from 'react';
interface PaginationProps {
totalPages: number;
@@ -43,15 +43,15 @@ export default function Pagination({
}, [currentPage, totalPages, delta]);
return (
<nav aria-label="Page navigation">
<ul className="pagination gap-1">
<nav aria-label='Page navigation'>
<ul className='pagination gap-1'>
{/* Botón Anterior */}
<li className={`page-item ${currentPage === 1 ? "disabled" : ""}`}>
<li className={`page-item ${currentPage === 1 ? 'disabled' : ''}`}>
<button
className="page-link shadow-sm rounded border-0 bg-white"
className='page-link box p-0'
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
aria-label="Previous"
aria-label='Previous'
>
&laquo;
</button>
@@ -60,21 +60,17 @@ export default function Pagination({
{/* Números de Página */}
{visiblePages.map((page, index) =>
page === -1 ? (
<li key={`ellipsis-${index}`} className="page-item disabled">
<span className="page-link shadow-sm rounded border-0 bg-white">
...
</span>
<li key={`ellipsis-${index}`} className='page-item disabled'>
<span className='page-link box p-0'>...</span>
</li>
) : (
<li
key={page}
className={`page-item ${currentPage === page ? "active" : ""}`}
className={`page-item ${currentPage === page ? 'active' : ''}`}
>
<button
className={`page-link shadow-sm rounded border-0 ${
currentPage === page
? ""
: "bg-white text-primary"
className={`page-link box p-0 ${
currentPage === page ? 'active' : ''
}`}
onClick={() => handlePageChange(page)}
>
@@ -87,14 +83,14 @@ export default function Pagination({
{/* Botón Siguiente */}
<li
className={`page-item ${
currentPage === totalPages ? "disabled" : ""
currentPage === totalPages ? 'disabled' : ''
}`}
>
<button
className="page-link shadow-sm rounded border-0 bg-white"
className='page-link box p-0'
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
aria-label="Next"
aria-label='Next'
>
&raquo;
</button>
+9
View File
@@ -0,0 +1,9 @@
// components/QrReader.tsx
'use client';
import { Scanner } from '@yudiel/react-qr-scanner';
export default function QrReader() {
return <Scanner onScan={(result) => console.log(result)} />;
}
+1
View File
@@ -1,3 +1,4 @@
// @components/table.tsx
import React, { useState, useMemo } from "react";
import Pagination from "./pagination";
+407
View File
@@ -0,0 +1,407 @@
'use client';
import React, { useEffect, useState } from 'react';
import { CuestionarioSeccion } from '@/types/responder-formulario';
import RadioOptionGroup, { RadioOption } from '@/components/radio-option-group';
import Input from '@/components/input';
import Button from '@/components/button';
import { validarRespuesta } from '@/utils/validador';
import toast from 'react-hot-toast';
import axiosInstance from '@/utils/api-config';
import { useRouter } from 'next/navigation';
import { getAxiosError } from '@/utils/errors-utils';
interface DatosAlumno {
id_ncuenta: number;
nombre: string;
apellidos: string;
carrera: string;
genero: 'M' | 'F';
}
export default function Formulario({
id_formulario,
secciones,
}: {
id_formulario: number;
secciones: CuestionarioSeccion[];
}) {
const router = useRouter();
const [esDeFES, setEsDeFES] = useState<boolean | null>(null);
const [cuenta, setCuenta] = useState('');
const [datosAuto, setDatosAuto] = useState<null | {
nombre: string;
apellidos: string;
correo: string;
genero: 'M' | 'F';
}>(null);
const [respuestas, setRespuestas] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false); // New state for submission
const actualizarRespuesta = (idPregunta: string, valor: string) => {
setRespuestas((prev) => ({ ...prev, [idPregunta]: valor }));
};
useEffect(() => {
if (esDeFES && cuenta.length === 9) {
(async () => {
try {
setDatosAuto(null);
const { data } = await axiosInstance.get<DatosAlumno>(
`/alumnos/${cuenta}`
);
console.log('Datos del alumno:', data);
if (data) {
setDatosAuto({
nombre: data.nombre.toString(),
apellidos: data.apellidos.toString(),
correo: data.id_ncuenta + '@pcpuma.acatlan.unam.mx',
genero: data.genero,
});
}
} catch (err) {
const msg = getAxiosError(err);
toast.error(msg.message);
setDatosAuto(null);
}
})();
}
}, [cuenta, esDeFES]);
useEffect(() => {
if (esDeFES && datosAuto) {
const nuevasRespuestas: Record<string, string> = {};
secciones.forEach((seccion) => {
seccion.preguntas.forEach(({ pregunta }) => {
const id = pregunta.id_pregunta;
if (pregunta.validacion === 'nombre') {
nuevasRespuestas[`pregunta_${id}`] = pregunta.pregunta
.toLowerCase()
.includes('apellido')
? datosAuto.apellidos.toString()
: datosAuto.nombre.toString();
}
if (pregunta.validacion === 'correo') {
nuevasRespuestas[`pregunta_${id}`] = datosAuto.correo;
}
if (pregunta.validacion === 'cuenta_alumno') {
nuevasRespuestas[`pregunta_${id}`] = cuenta;
}
if (
pregunta.pregunta
.toLowerCase()
.includes('institución de procedencia')
) {
nuevasRespuestas[`pregunta_${id}`] = 'FES Acatlán';
}
});
});
setRespuestas((prev) => ({
...prev,
...nuevasRespuestas,
}));
}
}, [datosAuto, esDeFES, cuenta, secciones]);
// Encuentra preguntas por texto o validación
const todasPreguntas = secciones.flatMap((s) =>
s.preguntas.map((p) => p.pregunta)
);
const preguntaFES = todasPreguntas.find(
(p) => p.pregunta === '¿Eres parte de la comunidad de la FES Acatlán?'
);
const preguntaCuenta = todasPreguntas.find(
(p) => p.validacion === 'cuenta_alumno'
);
const preguntasRestantes = todasPreguntas.filter(
(p) =>
![preguntaFES?.id_pregunta, preguntaCuenta?.id_pregunta].includes(
p.id_pregunta
)
);
const respuestaFES = respuestas[`pregunta_${preguntaFES?.id_pregunta}`];
const formatearRespuestas = async () => {
if (!validarRespuestasObligatorias()) return;
const respuestasArray = Object.entries(respuestas).flatMap(
([key, valor]) => {
const idNumerico = Number(key.replace('pregunta_', ''));
const pregunta = secciones
.flatMap((s) => s.preguntas)
.find((p) => p.pregunta.id_pregunta === idNumerico);
const tipo = pregunta?.pregunta.tipo_pregunta.tipo_pregunta;
// Si es múltiple, convertir cada valor individual si corresponde
if (Array.isArray(valor)) {
return valor.map((v) => ({
id_pregunta: idNumerico,
valor: tipo === 'Cerrada' || tipo === 'Multiple' ? Number(v) : v,
}));
}
return [
{
id_pregunta: idNumerico,
valor: tipo === 'Cerrada' ? Number(valor) : 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(),
};
console.log('Resultado a enviar:', resultado);
setIsSubmitting(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('/'))
.finally(() => setIsSubmitting(false));
};
const validarRespuestasObligatorias = (): boolean => {
const faltantes: number[] = [];
const errores: { id: number; mensaje: string }[] = [];
console.log(secciones);
console.log('Validando respuestas:', respuestas);
secciones.forEach((seccion) => {
seccion.preguntas.forEach(({ pregunta }) => {
const id = pregunta.id_pregunta;
const valor = respuestas[`pregunta_${id}`];
const tipo = pregunta.tipo_pregunta.tipo_pregunta;
const validacion = pregunta.validacion;
console.log('Validando pregunta:', pregunta.pregunta);
console.table({
id,
valor,
tipo,
validacion,
});
const respondida =
(tipo === 'Abierta' &&
typeof valor === 'string' &&
valor.trim() !== '') ||
(tipo === 'Cerrada' && valor !== undefined) ||
(tipo === 'Multiple' && Array.isArray(valor) && valor.length > 0);
if (!respondida && pregunta.obligatoria) {
faltantes.push(id);
}
if (tipo === 'Abierta' && validacion && typeof valor === 'string') {
const resultado = validarRespuesta(valor, validacion);
if (!resultado.valido) {
errores.push({ id, mensaje: resultado.mensaje });
}
}
});
});
if (faltantes.length > 0) {
console.log('Faltan preguntas obligatorias:', faltantes);
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;
};
return (
<form className="container py-4">
{/* Pregunta inicial */}
{preguntaFES && (
<div className="mb-4">
<label className="form-label">{preguntaFES.pregunta}</label>
<RadioOptionGroup
name="comunidad_fes"
options={preguntaFES.opciones.map((op) => ({
label: op.opcion.opcion,
value: op.id_opcion,
}))}
selectedValue={respuestaFES ? Number(respuestaFES) : undefined}
onChange={(opt) => {
const id = opt.value;
const value = opt?.label ?? '';
setEsDeFES(value === 'Si'); // Se actualiza esDeFES con el texto
setCuenta('');
setDatosAuto(null);
actualizarRespuesta(
`pregunta_${preguntaFES.id_pregunta}`,
String(id)
);
}}
/>
</div>
)}
{/* Si dijo que sí, pedir número de cuenta */}
{esDeFES && preguntaCuenta && (
<Input
key={preguntaCuenta.id_pregunta}
label={preguntaCuenta.pregunta}
name={`pregunta_${preguntaCuenta.id_pregunta}`}
value={cuenta}
maxLength={10}
onChange={(e) => {
const value = e.target.value;
setCuenta(value);
actualizarRespuesta(
`pregunta_${preguntaCuenta.id_pregunta}`,
value
); // ✅ Guarda la respuesta
}}
/>
)}
{/* Si ya tiene datos o dijo que no es de la FES, mostrar el resto */}
{(esDeFES === false || (esDeFES && datosAuto)) &&
preguntasRestantes.map((p) => {
if (p.tipo_pregunta.tipo_pregunta === 'Abierta') {
let defaultValue = '';
let disabled = false;
if (esDeFES && datosAuto) {
if (p.validacion === 'nombre') {
defaultValue = p.pregunta.toLowerCase().includes('apellido')
? datosAuto.apellidos
: datosAuto.nombre;
disabled = true;
}
if (p.validacion === 'correo') {
defaultValue = datosAuto.correo;
disabled = true;
}
if (p.validacion === 'cuenta_alumno') {
defaultValue = cuenta;
disabled = true;
}
if (
p.pregunta.toLowerCase().includes('institución de procedencia')
) {
defaultValue = 'FES Acatlán';
disabled = true;
}
}
return (
<React.Fragment key={p.id_pregunta}>
<Input
label={p.pregunta}
name={`pregunta_${p.id_pregunta}`}
defaultValue={defaultValue}
disabled={disabled}
onChange={(e) =>
actualizarRespuesta(
`pregunta_${p.id_pregunta}`,
e.target.value
)
}
/>
{p.validacion === 'correo' && (
<div className="alert alert-info">
Este es el correo donde enviaremos la validación de registro{' '}
</div>
)}
</React.Fragment>
);
}
if (p.tipo_pregunta.tipo_pregunta === 'Cerrada') {
const opciones: RadioOption<number>[] = p.opciones.map((op) => ({
label: op.opcion.opcion,
value: op.id_opcion,
}));
let respuestaActual = respuestas[`pregunta_${p.id_pregunta}`];
// Si es de FES y hay datos automáticos, intentar preseleccionar género
if (
esDeFES &&
datosAuto &&
!respuestaActual &&
p.pregunta.toLowerCase().includes('género')
) {
const generoTexto =
datosAuto.genero === 'M' ? 'Masculino' : 'Femenino';
const opcionGenero = p.opciones.find(
(op) =>
op.opcion.opcion.toLowerCase() === generoTexto.toLowerCase()
);
if (opcionGenero) {
respuestaActual = String(opcionGenero.id_opcion);
actualizarRespuesta(
`pregunta_${p.id_pregunta}`,
String(respuestaActual)
);
}
}
return (
<div key={p.id_pregunta}>
<label className="form-label">{p.pregunta}</label>
<RadioOptionGroup
name={`pregunta_${p.id_pregunta}`}
options={opciones}
selectedValue={
respuestaActual ? Number(respuestaActual) : undefined
}
onChange={(opt) =>
actualizarRespuesta(
`pregunta_${p.id_pregunta}`,
String(opt.value)
)
}
/>
</div>
);
}
return null;
})}
{/* Botón de enviar */}
<Button onClick={formatearRespuestas} disabled={isSubmitting}>
{isSubmitting ? 'Enviando...' : 'Enviar'}
</Button>
</form>
);
}
+53
View File
@@ -0,0 +1,53 @@
export const feriaSexualidad = {
nombre_form: 'Registro para la Feria de la Sexualidad - FES Acatlán',
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,
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: 'Multiple',
opciones: [{ valor: 'Si' }, { valor: 'No' }],
obligatoria: true,
},
{
titulo: 'Numero de cuenta',
obligatoria: true,
tipo: 'Numero',
},
{
titulo: 'Nombre(s)',
obligatoria: true,
tipo: 'Texto',
},
{
titulo: 'Apellidos',
obligatoria: true,
tipo: 'Texto',
},
{
titulo: 'genero',
obligatoria: true,
tipo: 'Radio',
opciones: [
{ valor: 'Masculino' },
{ valor: 'Femenino' },
{ valor: 'Prefiero no decirlo' },
],
},
{
titulo: 'Institución de procedencia',
tipo: 'Abierto',
obligatoria: true,
},
],
},
],
};
+197
View File
@@ -0,0 +1,197 @@
export const res = {
tipo_cuestionario: {
id_tipo_cuestionario: 1,
tipo_cuestionario: 'Encuesta',
},
cuestionario: {
id_cuestionario: 10,
nombre_form:
'Registro para la Feria de la Sexualidad - Comunidad FES Acatlán',
contador_secciones: 2,
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.',
editable: true,
fecha_inicio: '2025-03-26T00:00:00',
fecha_fin: '2025-03-31T23:59:59',
id_cuestionario_original: null,
id_tipo_cuestionario: 1,
secciones: [
{
id_cuestionario_seccion: 1,
posicion: 1,
seccion: {
id_seccion: 100,
contador_pregunta: 2,
descripcion: 'Preguntas generales',
titulo: 'General',
},
preguntas: [
{
id_seccion_pregunta: 1000,
posicion: 1,
pregunta: {
id_pregunta: 10000,
pregunta: '¿Cómo calificaría nuestro servicio?',
contador_opcion: 4,
obligatoria: true,
id_tipo_pregunta: 1,
id_opcion_dependiente: null,
tipo_pregunta: {
id_tipo: 1,
tipo_pregunta: 'Cerrada',
},
opciones: [
{
id_pregunta_opcion: 50000,
posicion: 1,
id_opcion: 90000,
opcion: {
id_opcion: 90000,
opcion: 'Excelente',
},
},
{
id_pregunta_opcion: 50001,
posicion: 2,
id_opcion: 90001,
opcion: {
id_opcion: 90001,
opcion: 'Bueno',
},
},
{
id_pregunta_opcion: 50002,
posicion: 3,
id_opcion: 90002,
opcion: {
id_opcion: 90002,
opcion: 'Regular',
},
},
{
id_pregunta_opcion: 50003,
posicion: 4,
id_opcion: 90003,
opcion: {
id_opcion: 90003,
opcion: 'Malo',
},
},
],
},
},
{
id_seccion_pregunta: 1001,
posicion: 2,
pregunta: {
id_pregunta: 10001,
pregunta: '¿Recomendaría nuestro servicio?',
contador_opcion: 2,
obligatoria: true,
id_tipo_pregunta: 1,
id_opcion_dependiente: null,
tipo_pregunta: {
id_tipo: 1,
tipo_pregunta: 'Cerrada',
},
opciones: [
{
id_pregunta_opcion: 50004,
posicion: 1,
id_opcion: 90004,
opcion: {
id_opcion: 90004,
opcion: 'Sí',
},
},
{
id_pregunta_opcion: 50005,
posicion: 2,
id_opcion: 90005,
opcion: {
id_opcion: 90005,
opcion: 'No',
},
},
],
},
},
],
},
{
id_cuestionario_seccion: 2,
posicion: 2,
seccion: {
id_seccion: 101,
contador_pregunta: 1,
descripcion: 'Preguntas adicionales para conocer más detalles',
titulo: 'Adicionales',
},
preguntas: [
{
id_seccion_pregunta: 1002,
posicion: 1,
pregunta: {
id_pregunta: 10002,
pregunta: '¿Qué mejorarías en nuestro servicio?',
contador_opcion: 0,
obligatoria: false,
id_tipo_pregunta: 2,
id_opcion_dependiente: null,
tipo_pregunta: {
id_tipo: 2,
tipo_pregunta: 'Abierta',
},
opciones: [],
},
},
{
id_seccion_pregunta: 1003,
posicion: 3,
pregunta: {
id_pregunta: 10003,
pregunta: '¿Qué aspectos del servicio fueron de tu agrado?',
contador_opcion: 3,
obligatoria: false,
id_tipo_pregunta: 3, // Puedes usar este ID para distinguir múltiples
id_opcion_dependiente: null,
tipo_pregunta: {
id_tipo: 3,
tipo_pregunta: 'Multiple',
},
opciones: [
{
id_pregunta_opcion: 50006,
posicion: 1,
id_opcion: 90006,
opcion: {
id_opcion: 90006,
opcion: 'Rapidez',
},
},
{
id_pregunta_opcion: 50007,
posicion: 2,
id_opcion: 90007,
opcion: {
id_opcion: 90007,
opcion: 'Atención al cliente',
},
},
{
id_pregunta_opcion: 50008,
posicion: 3,
id_opcion: 90008,
opcion: {
id_opcion: 90008,
opcion: 'Facilidad de uso',
},
},
],
},
},
],
},
],
},
};
+84
View File
@@ -0,0 +1,84 @@
import { FormularioCreacion } from '@/types/crear-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',
datos: {
nombre_form: 'Registro para la Feria de la Sexualidad - FES Acatlán',
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',
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,
},
{
titulo: 'Numero de cuenta',
tipo: 'Abierta',
obligatoria: false,
limite: 250,
validacion: 'cuenta_alumno',
},
{
titulo: 'Correo electrónico',
tipo: 'Abierta',
obligatoria: true,
limite: 250,
validacion: 'correo',
},
{
titulo: 'Nombre(s)',
tipo: 'Abierta',
obligatoria: true,
limite: 250,
validacion: 'nombre',
},
{
titulo: 'Apellidos',
tipo: 'Abierta',
obligatoria: true,
limite: 250,
validacion: 'nombre',
},
{
titulo: 'Género',
tipo: 'Cerrada',
obligatoria: true,
opciones: [
{ valor: 'Masculino' },
{ valor: 'Femenino' },
{ valor: 'Prefiero no decirlo' },
],
},
{
titulo: 'Institución de procedencia',
tipo: 'Abierta',
obligatoria: true,
limite: 250,
},
],
},
],
},
},
];
+22
View File
@@ -0,0 +1,22 @@
/* const comunidad = {
id_formulario: 1,
respuestas: [
{ id_pregunta: 101, valor: 'Si' }, // ¿Eres parte de la comunidad de la FES Acatlán?
{ id_pregunta: 102, valor: '123456789' }, // Numero de cuenta
{ id_pregunta: 106, valor: 'Prefiero no decirlo' },
],
fecha_envio: '2025-04-02T13:45:00',
};
const externos = {
id_formulario: 1,
respuestas: [
{ id_pregunta: 101, valor: 'No' }, // ¿Eres parte de la comunidad de la FES Acatlán?
{ id_pregunta: 103, valor: 'Juan Carlos' }, // Nombre(s)
{ id_pregunta: 104, valor: 'Ramírez López' }, // Apellidos
{ id_pregunta: 106, valor: 'Prefiero no decirlo' }, // genero
{ id_pregunta: 107, valor: 'UAM Azcapotzalco' }, // Institución de procedencia
],
fecha_envio: '2025-04-02T13:45:00',
};
*/
+2 -2
View File
@@ -1,8 +1,8 @@
.box {
@extend .shadow-sm;
@extend .border;
@extend .rounded;
@extend .bg-white;
@extend .p-3;
@extend .p-2;
&.active {
@extend .bg-primary;
+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 {
+11 -1
View File
@@ -5,7 +5,7 @@ $hover-scales: (
2: 1.1,
3: 1.15,
4: 1.2,
5: 1.25
5: 1.25,
);
@each $key, $value in $hover-scales {
@@ -17,3 +17,13 @@ $hover-scales: (
}
}
}
$min-width: 100px;
$max-width: 600px;
@for $i from 1 through (($max-width - $min-width) / 100px + 1) {
$current-width: $min-width + ($i - 1) * 100px;
.w-#{$current-width} {
width: $current-width;
}
}
+79 -14
View File
@@ -1,29 +1,29 @@
@import "bootstrap/scss/functions";
@import 'bootstrap/scss/functions';
@import "variables";
@import 'variables';
@import "bootstrap/scss/variables";
@import "bootstrap/scss/variables-dark";
@import 'bootstrap/scss/variables';
@import 'bootstrap/scss/variables-dark';
// Include any default map overrides here
$azul: #003d79;
$dorado: #d59f0f;
$custom-colors: (
"azul": $azul,
"dorado": $dorado,
'azul': $azul,
'dorado': $dorado,
);
$theme-colors: map-merge($theme-colors, $custom-colors);
@import "bootstrap/scss/maps";
@import "bootstrap/scss/mixins";
@import "bootstrap/scss/bootstrap";
@import 'bootstrap/scss/maps';
@import 'bootstrap/scss/mixins';
@import 'bootstrap/scss/bootstrap';
@import "gradients";
@import "extends";
@import "table";
@import "utilities";
@import 'gradients';
@import 'extends';
@import 'table';
@import 'utilities';
@import 'sidebar';
@import 'hover';
@import 'tabs';
@@ -33,7 +33,7 @@ $theme-colors: map-merge($theme-colors, $custom-colors);
@include media-breakpoint-up(lg) {
.bd-layout {
display: grid;
grid-template-areas: "sidebar main";
grid-template-areas: 'sidebar main';
grid-template-columns: 1fr 5fr;
gap: $spacer * 1.5;
}
@@ -60,3 +60,68 @@ ul.list-unstyled li {
}
}
.card {
@extend .bg-white, .text-dark, .rounded, .shadow-sm;
display: inline-block;
position: relative;
width: 100%;
margin-bottom: 1.5rem;
.card-image {
position: relative;
overflow: hidden;
margin: -1.5rem 1rem 0;
height: 60%;
@extend .rounded;
@extend .shadow;
img {
@extend .img-fluid, .rounded;
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
}
.card-caption {
position: absolute;
bottom: 15px;
left: 15px;
color: #fff;
font-size: 1.25rem;
text-shadow: 0 2px 5px rgba(33, 33, 33, 0.5);
}
}
.table {
@extend .p-3;
}
.ftr {
@extend .mt-3;
div {
display: inline-block;
}
.author {
@extend .text-muted;
}
.stats {
float: right;
line-height: 30px;
font-size: 0.875rem;
position: relative;
top: 1px;
}
}
}
.cursor-pointer {
cursor: pointer;
}
input {
--bs-body-bg: #fff;
}
+28
View File
@@ -0,0 +1,28 @@
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: 'Abierta' | 'Cerrada' | 'Multiple';
opciones?: { valor: string }[];
obligatoria: boolean;
limite?: number;
validacion?: 'correo' | 'cuenta_alumno' | 'nombre'; // puedes ampliar esta lista si lo necesitas
}
export interface OpcionFormulario {
valor: string;
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Respuesta del endpoint GET /cuestionario
*/
export interface GetCuestionario {
id_cuestionario: number;
nombre_form: string;
descripcion: string;
contador_secciones: number;
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 FormularioCreateDto {
nombre_form: string;
descripcion: string;
fecha_inicio: string; // Formato ISO: 'YYYY-MM-DDTHH:mm:ss'
fecha_fin: string; // Formato ISO: 'YYYY-MM-DDTHH:mm:ss'
id_tipo_cuestionario: number;
evento?: string;
secciones: Seccion[];
}
export interface Seccion {
titulo: string;
descripcion: string;
preguntas: Pregunta[];
}
export interface Opcion {
valor: string;
}
export interface CuestionarioMetadata {
id_cuestionario: number;
nombre_form: string;
descripcion: string;
fecha_inicio: string | null; // Formato ISO o null
fecha_fin: string | null;
id_tipo_cuestionario: number;
editable: boolean;
contador_secciones: number;
id_evento: number | null;
id_cuestionario_original: number | null;
}
/* ESto es para repsonder el formulario */
+15
View File
@@ -0,0 +1,15 @@
export interface ParticipacionEvento {
id_participante: number;
id_evento: number;
fecha_inscripcion: string; // formato ISO
estatus: boolean;
fecha_asistencia: string | null;
participante: Participante;
}
export interface Participante {
id_participante: number;
correo: string;
id_tipo_user: number;
}
+74
View File
@@ -0,0 +1,74 @@
import { GetCuestionario } from "./cuestionario";
export interface CuestionarioResponse {
tipo_cuestionario: TipoCuestionario;
evento: Evento;
cuestionario: Cuestionario;
}
export interface TipoCuestionario {
id_tipo_cuestionario: number;
tipo_cuestionario: string;
}
export interface Evento {
id_evento: number;
nombre_evento: string;
tipo_evento: string;
fecha_inicio: string; // ISO format date string
fecha_fin: string;
}
export interface Cuestionario extends GetCuestionario {
secciones: CuestionarioSeccion[];
}
export interface CuestionarioSeccion {
id_cuestionario_seccion: number;
posicion: number;
seccion: Seccion;
preguntas: SeccionPregunta[];
}
export interface Seccion {
id_seccion: number;
contador_pregunta: number;
descripcion: string;
titulo: string;
}
export interface SeccionPregunta {
id_seccion_pregunta: number;
posicion: number;
pregunta: Pregunta;
}
export interface Pregunta {
id_pregunta: number;
pregunta: string;
contador_opcion: number;
obligatoria: boolean;
id_tipo_pregunta: number;
id_opcion_dependiente: number | null;
validacion: string | null;
tipo_pregunta: TipoPregunta;
opciones: PreguntaOpcion[];
}
export interface TipoPregunta {
id_tipo: number;
tipo_pregunta: string;
}
export interface PreguntaOpcion {
id_pregunta_opcion: number;
posicion: number;
id_opcion: number;
opcion: Opcion;
}
export interface Opcion {
id_opcion: number;
opcion: string;
}
+14
View File
@@ -0,0 +1,14 @@
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;
}
+17
View File
@@ -0,0 +1,17 @@
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
headers: {
'Content-Type': 'application/json',
},
});
export default axiosInstance;
+4
View File
@@ -0,0 +1,4 @@
export function formatearFecha(fecha: string, tipo: 'inicio' | 'fin'): string {
const sufijo = tipo === 'inicio' ? 'T00:00:01' : 'T23:59:59';
return `${fecha}${sufijo}`;
}
+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;
};
+295
View File
@@ -0,0 +1,295 @@
/**
* Módulo de validación para respuestas de cuestionarios
* Este módulo puede ser utilizado tanto en backend (NestJS) como en frontend (Angular, React, etc.)
*/
// Interfaz para el resultado de validación
export interface ResultadoValidacion {
valido: boolean;
mensaje: string;
}
// Clase base para todos los validadores
export abstract class Validador {
abstract validar(valor: string): ResultadoValidacion;
}
// Clase para validar correos electrónicos
export class ValidadorCorreo extends Validador {
validar(valor: string): ResultadoValidacion {
if (!valor) {
return {
valido: false,
mensaje: 'El correo electrónico es requerido',
};
}
// Expresión regular para validar correos
// Valida el formato básico de correos: usuario@dominio.extensión
const regex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
if (!regex.test(valor)) {
return {
valido: false,
mensaje: 'El formato del correo electrónico no es válido',
};
}
// Para correos institucionales (opcional)
if (this.validarDominioInstitucional) {
const dominios = ['acatlan.unam.mx', 'unam.mx', 'comunidad.unam.mx'];
const dominio = valor.split('@')[1];
if (!dominios.includes(dominio)) {
return {
valido: false,
mensaje:
'Debe ser un correo institucional (@acatlan.unam.mx, @unam.mx, @comunidad.unam.mx)',
};
}
}
return {
valido: true,
mensaje: 'Correo electrónico válido',
};
}
// Propiedad que permite configurar si se validan sólo correos institucionales
constructor(public validarDominioInstitucional: boolean = false) {
super();
}
}
// Clase para validar números telefónicos
export class ValidadorTelefono extends Validador {
validar(valor: string): ResultadoValidacion {
if (!valor) {
return {
valido: false,
mensaje: 'El número telefónico es requerido',
};
}
// Eliminar espacios, guiones y paréntesis para la validación
const numeroLimpio = valor.replace(/[\s\-()]/g, '');
// Verificar que solo contenga dígitos
if (!/^\d+$/.test(numeroLimpio)) {
return {
valido: false,
mensaje: 'El número telefónico solo debe contener dígitos',
};
}
// Verificar longitud (para México, 10 dígitos)
if (numeroLimpio.length !== 10) {
return {
valido: false,
mensaje: 'El número telefónico debe tener 10 dígitos',
};
}
return {
valido: true,
mensaje: 'Número telefónico válido',
};
}
}
// Clase para validar nombres
export class ValidadorNombre extends Validador {
validar(valor: string): ResultadoValidacion {
const regex = new RegExp(
'/^[A-Za-zÁÉÍÓÚáéíóúÑñ]+(?: [A-Za-zÁÉÍÓÚáéíóúÑñ]+)*$/'
);
if (!valor) {
return {
valido: false,
mensaje: 'El nombre es requerido',
};
}
// Verificar longitud mínima
if (valor.trim().length < 2) {
return {
valido: false,
mensaje: 'El nombre debe tener al menos 2 caracteres',
};
}
// Verificar que solo contenga letras y espacios
if (regex.test(valor)) {
return {
valido: false,
mensaje: 'El nombre solo debe contener letras y espacios',
};
}
return {
valido: true,
mensaje: 'Nombre válido',
};
}
}
// Clase para validar números enteros
export class ValidadorEntero extends Validador {
constructor(
private min: number = Number.MIN_SAFE_INTEGER,
private max: number = Number.MAX_SAFE_INTEGER
) {
super();
}
validar(valor: string): ResultadoValidacion {
if (!valor) {
return {
valido: false,
mensaje: 'El valor numérico es requerido',
};
}
// Verificar que sea un número entero
if (!/^-?\d+$/.test(valor)) {
return {
valido: false,
mensaje: 'El valor debe ser un número entero',
};
}
const numero = parseInt(valor, 10);
// Verificar rango
if (numero < this.min || numero > this.max) {
return {
valido: false,
mensaje: `El valor debe estar entre ${this.min} y ${this.max}`,
};
}
return {
valido: true,
mensaje: 'Número entero válido',
};
}
}
// Clase para validar números decimales
export class ValidadorDecimal extends Validador {
constructor(
private min: number = Number.MIN_SAFE_INTEGER,
private max: number = Number.MAX_SAFE_INTEGER,
private decimales: number = 2
) {
super();
}
validar(valor: string): ResultadoValidacion {
if (!valor) {
return {
valido: false,
mensaje: 'El valor numérico es requerido',
};
}
// Verificar que sea un número decimal válido
if (!/^-?\d+(\.\d+)?$/.test(valor)) {
return {
valido: false,
mensaje: 'El valor debe ser un número decimal válido',
};
}
const numero = parseFloat(valor);
// Verificar rango
if (numero < this.min || numero > this.max) {
return {
valido: false,
mensaje: `El valor debe estar entre ${this.min} y ${this.max}`,
};
}
// Verificar precisión decimal
const partes = valor.split('.');
if (partes.length > 1 && partes[1].length > this.decimales) {
return {
valido: false,
mensaje: `El valor debe tener máximo ${this.decimales} decimales`,
};
}
return {
valido: true,
mensaje: 'Número decimal válido',
};
}
}
// Clase para validar cuentas de alumno (formato específico UNAM - 9 dígitos)
export class ValidadorCuentaAlumno extends Validador {
validar(valor: string): ResultadoValidacion {
if (!valor) {
return {
valido: false,
mensaje: 'La cuenta de alumno es requerida',
};
}
// Formato de cuenta UNAM: 9 dígitos para todos los alumnos
if (!/^\d{9}$/.test(valor)) {
return {
valido: false,
mensaje:
'La cuenta de alumno debe tener exactamente 9 dígitos numéricos',
};
}
return {
valido: true,
mensaje: 'Cuenta de alumno válida',
};
}
}
// Fábrica de validadores para crear el validador apropiado según el tipo
export class FabricaValidadores {
static crear(tipo: string): Validador | null {
switch (tipo?.toLowerCase()) {
case 'correo':
return new ValidadorCorreo();
case 'correo_institucional':
return new ValidadorCorreo(true);
case 'telefono':
return new ValidadorTelefono();
case 'nombre':
return new ValidadorNombre();
case 'entero':
return new ValidadorEntero();
case 'decimal':
return new ValidadorDecimal();
case 'cuenta_alumno':
return new ValidadorCuentaAlumno();
default:
return null;
}
}
}
// Función auxiliar para validar respuestas basada en el tipo de validación
export function validarRespuesta(
respuesta: string,
tipoValidacion: string
): ResultadoValidacion {
const validador = FabricaValidadores.crear(tipoValidacion);
if (!validador) {
return {
valido: true, // Si no hay validador, consideramos válida la respuesta
mensaje: 'No se requiere validación específica',
};
}
return validador.validar(respuesta);
}