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:
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 80,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -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": "" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+214
-17
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'
|
||||
>
|
||||
«
|
||||
</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'
|
||||
>
|
||||
»
|
||||
</button>
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
// @components/table.tsx
|
||||
import React, { useState, useMemo } from "react";
|
||||
import Pagination from "./pagination";
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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',
|
||||
};
|
||||
*/
|
||||
@@ -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;
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
|
||||
thead {
|
||||
tr {
|
||||
box-shadow: var(--bs-box-shadow-sm);
|
||||
th {
|
||||
background-color: var(--bg-header-table);
|
||||
position: sticky;
|
||||
@@ -39,8 +38,7 @@
|
||||
tr {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--bs-box-shadow-sm);
|
||||
|
||||
|
||||
&.clickable-row {
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
|
||||
@@ -5,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;
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+79
-14
@@ -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;
|
||||
}
|
||||
|
||||
Vendored
+28
@@ -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;
|
||||
}
|
||||
Vendored
+52
@@ -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 */
|
||||
Vendored
+15
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Vendored
+14
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user