Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c86f5d223 | |||
| eaa955a995 | |||
| ad983c6ae8 | |||
| ac0671bb1a | |||
| 8c2a6ecb6f | |||
| 2f50d4f2fb | |||
| dcc474a065 | |||
| fe8ec5f591 | |||
| d3e1441f87 | |||
| f7d7bcfae6 | |||
| c0f4f31e18 | |||
| 3eba0c1348 | |||
| 5a88e93e04 | |||
| 6f89b993e1 | |||
| 9068cef62d | |||
| b0f744ef14 | |||
| 4ff66e81c7 | |||
| 3123c9911a | |||
| fee9d92ed7 | |||
| 8f2ee27711 | |||
| 509ff4255a | |||
| 4c300a8e20 | |||
| 534ba64e0c | |||
| dcb66ce2c1 | |||
| dc161cf269 | |||
| b399821439 | |||
| 978f6c8a69 | |||
| 99bfb46b8b | |||
| 24642d1c3b | |||
| 7f2ab5b359 | |||
| be8f207ecd | |||
| 5b0f0ad6bf | |||
| 99b444ccf4 | |||
| 3493d99038 | |||
| f624724521 | |||
| b2205b3f98 | |||
| 4d124a7857 | |||
| 3fc7dfcfda | |||
| 60de9fb324 | |||
| 68467c2570 | |||
| 670d8558f4 | |||
| 2cb42cd4fc | |||
| a107233007 | |||
| 83c50f088e | |||
| 3723f5771d | |||
| 544d089824 | |||
| 613cc0b711 | |||
| 50e37aa8fa | |||
| c0cc7da989 | |||
| f85c3a35df | |||
| e092eeec4d | |||
| ea9f3b72f7 | |||
| b110109cb3 | |||
| f00bd8ac5e | |||
| e275b33131 | |||
| 8007d2f409 | |||
| 20cc8bc4e7 | |||
| fe1e13c51c | |||
| b298937ffb | |||
| c04a7613cd | |||
| b04dc94eec | |||
| 9d10c18a52 | |||
| d69a90a458 | |||
| 4d04a5383d | |||
| 8cbb968676 | |||
| 43a9bcfb58 | |||
| 404dd3a627 | |||
| b992e3386a | |||
| 243bd1afc1 | |||
| 9f4abe1db2 | |||
| 587bbc4b7c | |||
| 126377a2b8 | |||
| 8d8299b71a | |||
| e0ed64548a | |||
| 0d610c470b | |||
| f308cf30c0 | |||
| 1b817788bb | |||
| 8506d8c018 | |||
| 8945c1e339 | |||
| 4ef87790e2 | |||
| 842972963a | |||
| 6eebaa0a94 | |||
| 9b0021a166 | |||
| b2e639efcb | |||
| a0e86b3873 | |||
| 6c51e326dd | |||
| f522db5211 |
@@ -0,0 +1,85 @@
|
|||||||
|
name: Despliegue Automatizado Universal
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
get-context:
|
||||||
|
runs-on: host
|
||||||
|
outputs:
|
||||||
|
node_version: ${{ steps.setup.outputs.version }}
|
||||||
|
target_app: ${{ steps.setup.outputs.app }}
|
||||||
|
steps:
|
||||||
|
- name: Resolver Aplicación desde apps.conf
|
||||||
|
id: setup
|
||||||
|
run: |
|
||||||
|
# 1. Obtener el nombre del repositorio actual en Gitea (ej: front-Censo)
|
||||||
|
REPO_NAME="${{ github.event.repository.name }}"
|
||||||
|
|
||||||
|
# 2. Determinar si buscamos un entorno -dev (develop) o producción (master)
|
||||||
|
if [ "${{ github.ref_name }}" = "develop" ]; then
|
||||||
|
SUFFIX="-dev"
|
||||||
|
else
|
||||||
|
SUFFIX=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Buscando qué sección de apps.conf tiene REPO=$REPO_NAME y termina en '$SUFFIX'..."
|
||||||
|
|
||||||
|
# 3. Buscar en /etc/apps.conf el bloque que contenga REPO=nombre_del_repo
|
||||||
|
TARGET_APP=$(awk -v repo="REPO=$REPO_NAME" -v suf="$SUFFIX" '
|
||||||
|
/^\[/ { gsub(/[\[\]]/, "", $0); current_box=$0; next }
|
||||||
|
$0 == repo {
|
||||||
|
if ((suf == "-dev" && current_box ~ /-dev$/) || (suf == "" && current_box !~ /-dev$/)) {
|
||||||
|
print current_box;
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
' /etc/apps.conf)
|
||||||
|
|
||||||
|
if [ -z "$TARGET_APP" ]; then
|
||||||
|
echo "ERROR: No se encontró ninguna sección en /etc/apps.conf para el repositorio $REPO_NAME con el sufijo correcto."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. Leer la versión de Node del bloque encontrado
|
||||||
|
VERSION=$(awk -v app="$TARGET_APP" '
|
||||||
|
$0=="["app"]" { found=1; next }
|
||||||
|
/^\[/ && found { exit }
|
||||||
|
found && /^NODE=/ { split($0, a, "="); print a[2]; exit }
|
||||||
|
' /etc/apps.conf)
|
||||||
|
|
||||||
|
MAJOR_VERSION=$(echo "$VERSION" | tr -d '[:space:]' | grep -oE '[0-9]+' | head -n1)
|
||||||
|
|
||||||
|
echo "-> ID de Aplicación encontrado: $TARGET_APP"
|
||||||
|
echo "-> Versión Mayor de Node resuelta: $MAJOR_VERSION"
|
||||||
|
|
||||||
|
echo "app=${TARGET_APP}" >> $GITHUB_OUTPUT
|
||||||
|
echo "version=${MAJOR_VERSION}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
build:
|
||||||
|
needs: get-context
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Configurar Node.js dinámicamente
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ needs.get-context.outputs.node_version }}
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run build
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: [get-context, build]
|
||||||
|
runs-on: host
|
||||||
|
steps:
|
||||||
|
- name: Ejecutar Despliegue en Servidor Físico
|
||||||
|
run: |
|
||||||
|
TARGET_APP="${{ needs.get-context.outputs.target_app }}"
|
||||||
|
BRANCH_NAME="${{ github.ref_name }}"
|
||||||
|
|
||||||
|
sudo -u deploy /home/deploy/scripts/deploy-front.sh "$TARGET_APP" "$BRANCH_NAME"
|
||||||
@@ -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": "" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,19 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from 'next';
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'http',
|
||||||
|
hostname: 'localhost',
|
||||||
|
port: '4200', //4200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'venus.acatlan.unam.mx',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -5,28 +5,37 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start:local": "next build && next start -p 3246",
|
||||||
|
"start:dev": "source ~/.nvm/nvm.sh && nvm use 22.10.0 && npm install && next build && next start -p 3246",
|
||||||
|
"start:prod": "source ~/.nvm/nvm.sh && nvm use 22.10.0 && npm install && next build && next start -p 3240",
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@uiw/react-md-editor": "^4.0.7",
|
||||||
"@yudiel/react-qr-scanner": "^2.2.1",
|
"@yudiel/react-qr-scanner": "^2.2.1",
|
||||||
"axios": "^1.8.4",
|
"axios": "^1.8.4",
|
||||||
"bootstrap": "^5.3.3",
|
"bootstrap": "^5.3.8",
|
||||||
"bootstrap-icons": "^1.11.3",
|
"bootstrap-icons": "^1.11.3",
|
||||||
"next": "15.2.4",
|
"js-cookie": "^3.0.5",
|
||||||
|
"next": "^15.5.12",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
"react-bootstrap": "^2.10.10",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-hot-toast": "^2.5.2"
|
"react-hot-toast": "^2.5.2",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
"@types/bootstrap": "^5.2.10",
|
"@types/bootstrap": "^5.2.10",
|
||||||
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.2.4",
|
"eslint-config-next": "15.2.4",
|
||||||
"sass": "^1.86.1",
|
"sass": "^1.32.13",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,153 @@
|
|||||||
|
%PDF-1.3
|
||||||
|
%ÿÿÿÿ
|
||||||
|
8 0 obj
|
||||||
|
<<
|
||||||
|
/Type /ExtGState
|
||||||
|
/ca 1
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
9 0 obj
|
||||||
|
<<
|
||||||
|
/Type /ExtGState
|
||||||
|
/ca 1
|
||||||
|
/CA 1
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
12 0 obj
|
||||||
|
<<
|
||||||
|
/Type /ExtGState
|
||||||
|
/CA 1
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
7 0 obj
|
||||||
|
<<
|
||||||
|
/Type /Page
|
||||||
|
/Parent 1 0 R
|
||||||
|
/MediaBox [0 0 595.28 841.89]
|
||||||
|
/Contents 5 0 R
|
||||||
|
/Resources 6 0 R
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
6 0 obj
|
||||||
|
<<
|
||||||
|
/ProcSet [/PDF /Text /ImageB /ImageC /ImageI]
|
||||||
|
/ExtGState <<
|
||||||
|
/Gs1 8 0 R
|
||||||
|
/Gs2 9 0 R
|
||||||
|
/Gs3 12 0 R
|
||||||
|
>>
|
||||||
|
/Font <<
|
||||||
|
/F1 10 0 R
|
||||||
|
/F2 11 0 R
|
||||||
|
/F3 13 0 R
|
||||||
|
>>
|
||||||
|
/ColorSpace <<
|
||||||
|
>>
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
5 0 obj
|
||||||
|
<<
|
||||||
|
/Length 1506
|
||||||
|
/Filter /FlateDecode
|
||||||
|
>>
|
||||||
|
stream
|
||||||
|
xœÍZ]k\7}¿¿B cÍ·f’¶�<Úø-äÁìî--u¡5´¿è~î:«Ôñ]¥Á{Œsæh4sf5†bx…!†$)‡ýC‡áŸîÃÇá‹á·.†w�DÐ@dàÁ- †¿ŽÝÍwÇ¿Ý~û:ì»)Ìÿ÷t7o1üòØõ—@1�]–I Ò›ò5 y…|!Ó›·�T~zò÷Ô¿ì»?;¼´¯ï¦ßc@r`ž§p÷ÐÝü€%Üõ݇[U9JVUR–,²Ã Å]ˆÃÝ»îû»î§«�!rÀ˜jlä(8ù/\úbË¾ŽŒu¸ÃªNñ8°¥—ƒ`6)'ª²Ñ^ ¼îô°Ïš$KuרªÁS&Y\ÓäÚv!
|
||||||
|
98炪ìw!—‘ñ{«üĩʬÍN¯VõªÊ*ª"¥&”›Ô¥*eU�E#T5©þLVmÀ9d¿G9^?A
|
||||||
|
�Ý‚‹+-Lì)“5[äEL€ŸaÑ*’¤ªWUVÚšRré!Ò·Èv°:Í-|*�[}ý|`9h\{C�”<f�¹èDlojll¹g;òýت"#Uy°õv°ìrl± ÈejÎM¬Eæ£d©9õèd½ù.`·NãwC;X›âgH©ÊFÐØÅÕ°0y…n�Ùh™W9^¨Xöh-ÊÞÌæ¸Ñî'ÖºÓ¯WwÈ"RcÒºò\ÀSÕ{«Ò+bcU¯ú™Úk’òî@BoýPn¤áÖŽ†&Mj?äLUS*ºŒl¦P웦d#ù¿ã‚ÉÀо40M>$(V·ˆ‰#«–JE'¿ß…"ÑÖ䣂^‡í—ÙJ†
|
||||||
|
óÃ3D«ñ`äH=GêçÀ4 %Ðôéˆc8®\>B6:N!;$L5F�>¸—C–VñºorHÁÄàX
|
||||||
|
¾�9W>#ËYð×9O™œÑº8Lœ&i¦a–6š¿Ÿró~âþþÍ�em\Ööþòñã¡cbPÚy†ñЕéÊVÈ3Œ‡NU€Ò@^¥eKÁ¢@ôªÄô¼ÊÿajÒEŒ¯ßœ‰¼ôÆ
|
||||||
|
!6´£‰åÒ‚J#hp>d0†D§é•õCâë+�FP±çúo19L<Ì«*(¹˜Ž‰áåçžò×÷tY@¥vCøåõ½\Ð £¹ò䊀ÉÍ-�'Wò'sdÑOälnb A:‘ÞÁÜ(½ä,›[ Ï0VéÝy†±Jï³!¯QfšÀ<hrÈ„moå$ȦN-úz$ˆ(5ÿthâ3‹U×<¬u^
|
||||||
|
Êb»0ô´cÑ›J;÷š:�æ½Æ0U7�h»GH9}~ž¬´ªçBÀ‹žkÙªçàln�\|„œÍ-�‹€OËžÌ-�‹€��³¹‰e¤ ò‰žæF=Ÿ g-Þy†±êùÈ3ŒUÏŸ
|
||||||
|
y•©¦@ÌÔÒ…ÊR/ÝulÑh„˜Q�EYG…\_¹Ò0ÐüTW®ªá•¨E
|
||||||
|
¿Ì ¥†W×ü® dÑpWÐ>ÎæÈE´GÈÙܹˆö´ìÉܹˆö9››XÆòÿOÜ7lË‚°ª÷ËáNVå~9Ü ÂªÚÏ„»ÒXQÙÁç*:y!'çì6CfGˆ"5®Ápª:õy¦Ünº€ÄÀV_¶Ž—_ƒb÷Îeª-�n=<ƒZ�Ç&ÓÔâ2Õ|RšÏ@4l“ÁÊ•2^’]xfÃkÁåeÞô¨I(UY4ž»sªzeÁá}侌ћ]¼ró©ÎCºëxÔmyÑ„©Ê¦¿ÆW“ð!
|
||||||
|
7”O óp!XB('O%,[ß4Š2[•Ô7ENzébÕÙx‰žÁ´é–u˜Æ4
|
||||||
|
¦€1Õ¸}³Áž¸²"$9s9Ù�ñôRÊ£�/§½iÊ]�WH”-
|
||||||
|
·MŒA¥âûÒ•pŠluÊ÷Œ,|ÏÂ~æý_eϸ
|
||||||
|
endstream
|
||||||
|
endobj
|
||||||
|
15 0 obj
|
||||||
|
(pdfmake)
|
||||||
|
endobj
|
||||||
|
16 0 obj
|
||||||
|
(pdfmake)
|
||||||
|
endobj
|
||||||
|
17 0 obj
|
||||||
|
(D:20250410211447Z)
|
||||||
|
endobj
|
||||||
|
14 0 obj
|
||||||
|
<<
|
||||||
|
/Producer 15 0 R
|
||||||
|
/Creator 16 0 R
|
||||||
|
/CreationDate 17 0 R
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
10 0 obj
|
||||||
|
<<
|
||||||
|
/Type /Font
|
||||||
|
/BaseFont /Helvetica-Bold
|
||||||
|
/Subtype /Type1
|
||||||
|
/Encoding /WinAnsiEncoding
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
11 0 obj
|
||||||
|
<<
|
||||||
|
/Type /Font
|
||||||
|
/BaseFont /Helvetica
|
||||||
|
/Subtype /Type1
|
||||||
|
/Encoding /WinAnsiEncoding
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
13 0 obj
|
||||||
|
<<
|
||||||
|
/Type /Font
|
||||||
|
/BaseFont /Helvetica-Oblique
|
||||||
|
/Subtype /Type1
|
||||||
|
/Encoding /WinAnsiEncoding
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
4 0 obj
|
||||||
|
<<
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
3 0 obj
|
||||||
|
<<
|
||||||
|
/Type /Catalog
|
||||||
|
/Pages 1 0 R
|
||||||
|
/Names 2 0 R
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
1 0 obj
|
||||||
|
<<
|
||||||
|
/Type /Pages
|
||||||
|
/Count 1
|
||||||
|
/Kids [7 0 R]
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
2 0 obj
|
||||||
|
<<
|
||||||
|
/Dests <<
|
||||||
|
/Names [
|
||||||
|
]
|
||||||
|
>>
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
xref
|
||||||
|
0 18
|
||||||
|
0000000000 65535 f
|
||||||
|
0000002578 00000 n
|
||||||
|
0000002635 00000 n
|
||||||
|
0000002516 00000 n
|
||||||
|
0000002495 00000 n
|
||||||
|
0000000445 00000 n
|
||||||
|
0000000264 00000 n
|
||||||
|
0000000154 00000 n
|
||||||
|
0000000015 00000 n
|
||||||
|
0000000059 00000 n
|
||||||
|
0000002188 00000 n
|
||||||
|
0000002291 00000 n
|
||||||
|
0000000109 00000 n
|
||||||
|
0000002389 00000 n
|
||||||
|
0000002112 00000 n
|
||||||
|
0000002024 00000 n
|
||||||
|
0000002050 00000 n
|
||||||
|
0000002076 00000 n
|
||||||
|
After Width: | Height: | Size: 10 MiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 790 KiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
@@ -1,533 +0,0 @@
|
|||||||
'use client';
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import Pagination from '@/components/pagination';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import axiosInstance from '@/utils/api-config';
|
|
||||||
import Select from '@/components/select';
|
|
||||||
import Input from '@/components/input';
|
|
||||||
import Button from '@/components/button';
|
|
||||||
import FloatingInput from '@/components/floating-input';
|
|
||||||
import { plantillasDisponibles } from '@/data/plantillas';
|
|
||||||
import { FormularioCreacion, PreguntaFormulario, SeccionFormulario } from '@/types/crear-formulario';
|
|
||||||
import toast from 'react-hot-toast';
|
|
||||||
|
|
||||||
export default function AdminFormPage() {
|
|
||||||
const [nombreFormulario, setNombreFormulario] = useState('');
|
|
||||||
const [descripcion, setDescripcion] = useState('');
|
|
||||||
const [secciones, setSecciones] = useState<SeccionFormulario[]>([]);
|
|
||||||
const [paginaActual, setPaginaActual] = useState(1);
|
|
||||||
const [bannerPreview, setBannerPreview] = useState<string | null>(null);
|
|
||||||
const [tipoCuestionario, setTipoCuestionario] = useState<number | null>(null);
|
|
||||||
const [fechaInicio, setFechaInicio] = useState<string | null>(null);
|
|
||||||
const [fechaFin, setFechaFin] = useState<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const swapItems = <T,>(arr: T[], index1: number, index2: number): T[] => {
|
|
||||||
const result = [...arr];
|
|
||||||
[result[index1], result[index2]] = [result[index2], result[index1]];
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const moverSeccion = (from: number, to: number) => {
|
|
||||||
if (to >= 0 && to < secciones.length) {
|
|
||||||
setSecciones((prev) => swapItems(prev, from, to));
|
|
||||||
setPaginaActual(to + 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const moverPregunta = (seccionIndex: number, from: number, to: number) => {
|
|
||||||
const nuevas = [...secciones];
|
|
||||||
const preguntas = nuevas[seccionIndex].preguntas;
|
|
||||||
if (to >= 0 && to < preguntas.length) {
|
|
||||||
nuevas[seccionIndex].preguntas = swapItems(preguntas, from, to);
|
|
||||||
setSecciones(nuevas);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const agregarSeccion = () => {
|
|
||||||
setSecciones((prev) => [
|
|
||||||
...prev,
|
|
||||||
{ titulo: '', descripcion: '', preguntas: [] },
|
|
||||||
]);
|
|
||||||
setPaginaActual(secciones.length + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const eliminarSeccion = (index: number) => {
|
|
||||||
const nuevas = secciones.filter((_, i) => i !== index);
|
|
||||||
setSecciones(nuevas);
|
|
||||||
setPaginaActual((prev) =>
|
|
||||||
Math.max(1, prev > nuevas.length ? nuevas.length : prev)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const actualizarSeccion = <K extends keyof SeccionFormulario>(
|
|
||||||
index: number,
|
|
||||||
campo: K,
|
|
||||||
valor: SeccionFormulario[K]
|
|
||||||
) => {
|
|
||||||
setSecciones((prev) => {
|
|
||||||
const nuevas = [...prev];
|
|
||||||
nuevas[index][campo] = valor;
|
|
||||||
return nuevas;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const agregarPregunta = (seccionIndex: number) => {
|
|
||||||
const nuevas = [...secciones];
|
|
||||||
nuevas[seccionIndex].preguntas.push({
|
|
||||||
titulo: '',
|
|
||||||
tipo: 'Abierta',
|
|
||||||
opciones: [],
|
|
||||||
obligatoria: false,
|
|
||||||
});
|
|
||||||
setSecciones(nuevas);
|
|
||||||
};
|
|
||||||
|
|
||||||
const eliminarPregunta = (seccionIndex: number, preguntaIndex: number) => {
|
|
||||||
const nuevas = [...secciones];
|
|
||||||
nuevas[seccionIndex].preguntas.splice(preguntaIndex, 1);
|
|
||||||
setSecciones(nuevas);
|
|
||||||
};
|
|
||||||
|
|
||||||
const actualizarPregunta = (
|
|
||||||
seccionIndex: number,
|
|
||||||
preguntaIndex: number,
|
|
||||||
campo: keyof PreguntaFormulario,
|
|
||||||
valor: PreguntaFormulario[keyof PreguntaFormulario]
|
|
||||||
) => {
|
|
||||||
setSecciones((prev) => {
|
|
||||||
const nuevas = [...prev];
|
|
||||||
const pregunta = nuevas[seccionIndex].preguntas[preguntaIndex];
|
|
||||||
nuevas[seccionIndex].preguntas[preguntaIndex] = {
|
|
||||||
...pregunta,
|
|
||||||
[campo]: valor,
|
|
||||||
};
|
|
||||||
return nuevas;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const agregarOpcion = (seccionIndex: number, preguntaIndex: number) => {
|
|
||||||
const nuevas = [...secciones];
|
|
||||||
if (nuevas[seccionIndex]?.preguntas[preguntaIndex]?.opciones) {
|
|
||||||
nuevas[seccionIndex].preguntas[preguntaIndex].opciones!.push({ valor: '' });
|
|
||||||
}
|
|
||||||
setSecciones(nuevas);
|
|
||||||
};
|
|
||||||
|
|
||||||
const eliminarOpcion = (
|
|
||||||
seccionIndex: number,
|
|
||||||
preguntaIndex: number,
|
|
||||||
opcionIndex: number
|
|
||||||
) => {
|
|
||||||
const nuevas = [...secciones];
|
|
||||||
nuevas[seccionIndex]?.preguntas[preguntaIndex]?.opciones?.splice(
|
|
||||||
opcionIndex,
|
|
||||||
1
|
|
||||||
);
|
|
||||||
setSecciones(nuevas);
|
|
||||||
};
|
|
||||||
|
|
||||||
const actualizarOpcion = (
|
|
||||||
seccionIndex: number,
|
|
||||||
preguntaIndex: number,
|
|
||||||
opcionIndex: number,
|
|
||||||
valor: string
|
|
||||||
) => {
|
|
||||||
const nuevas = [...secciones];
|
|
||||||
if (
|
|
||||||
nuevas[seccionIndex]?.preguntas[preguntaIndex]?.opciones &&
|
|
||||||
nuevas[seccionIndex].preguntas[preguntaIndex].opciones![opcionIndex]
|
|
||||||
) {
|
|
||||||
nuevas[seccionIndex].preguntas[preguntaIndex].opciones![opcionIndex].valor = valor;
|
|
||||||
}
|
|
||||||
setSecciones(nuevas);
|
|
||||||
};
|
|
||||||
|
|
||||||
const guardarFormulario = async () => {
|
|
||||||
if (!fechaInicio || !fechaFin || !tipoCuestionario) {
|
|
||||||
alert('Todos los campos obligatorios deben estar llenos');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatearFecha = (fecha: string, tipo: 'inicio' | 'fin'): string => {
|
|
||||||
const sufijo = tipo === 'inicio' ? 'T00:00:01' : 'T23:59:59';
|
|
||||||
return `${fecha}${sufijo}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formData: FormularioCreacion = {
|
|
||||||
nombre_form: nombreFormulario,
|
|
||||||
descripcion,
|
|
||||||
fecha_inicio: formatearFecha(fechaInicio, 'inicio'),
|
|
||||||
fecha_fin: formatearFecha(fechaFin, 'fin'),
|
|
||||||
id_tipo_cuestionario: tipoCuestionario,
|
|
||||||
evento: nombreFormulario, // temporalmente igual al nombre del form
|
|
||||||
secciones,
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('Formulario a guardar:', formData);
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
await toast.promise(
|
|
||||||
axiosInstance.post('/cuestionario', formData),
|
|
||||||
{
|
|
||||||
loading: 'Guardando formulario...',
|
|
||||||
success: 'Formulario guardado exitosamente!',
|
|
||||||
error: 'Error al guardar el formulario.',
|
|
||||||
}
|
|
||||||
).then((res) => {
|
|
||||||
console.log('Formulario guardado:', res.data);
|
|
||||||
}).catch((error) => {
|
|
||||||
console.error('Error al guardar el formulario:', error);
|
|
||||||
}).finally(() => {
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBannerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0] || null;
|
|
||||||
if (file) {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = () => setBannerPreview(reader.result as string);
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
} else {
|
|
||||||
setBannerPreview(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const seccionActual = secciones[paginaActual - 1];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h2 className="my-4">Crear nuevo formulario</h2>
|
|
||||||
|
|
||||||
<div className="row">
|
|
||||||
{secciones.length === 0 &&
|
|
||||||
plantillasDisponibles.map((plantilla, index) => (
|
|
||||||
<div className="col-md-4 mb-3" key={plantilla.id}>
|
|
||||||
<h3>Plantillas</h3>
|
|
||||||
<div
|
|
||||||
className="box w-100 cursor-pointer"
|
|
||||||
onClick={() => {
|
|
||||||
const {
|
|
||||||
nombre_form,
|
|
||||||
descripcion,
|
|
||||||
fecha_inicio,
|
|
||||||
fecha_fin,
|
|
||||||
id_tipo_cuestionario,
|
|
||||||
secciones,
|
|
||||||
} = plantilla.datos;
|
|
||||||
setNombreFormulario(nombre_form);
|
|
||||||
setDescripcion(descripcion);
|
|
||||||
setFechaInicio(fecha_inicio.slice(0, 10));
|
|
||||||
setFechaFin(fecha_fin.slice(0, 10));
|
|
||||||
setTipoCuestionario(id_tipo_cuestionario);
|
|
||||||
setSecciones(
|
|
||||||
secciones.map((seccion) => ({
|
|
||||||
...seccion,
|
|
||||||
preguntas: seccion.preguntas.map((pregunta) => ({
|
|
||||||
...pregunta,
|
|
||||||
tipo: pregunta.tipo as
|
|
||||||
| 'Abierta'
|
|
||||||
| 'Cerrada'
|
|
||||||
| 'Multiple',
|
|
||||||
})),
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
setPaginaActual(1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={plantilla.imagen}
|
|
||||||
width={1000}
|
|
||||||
height={300}
|
|
||||||
alt={`Plantilla de formulario ${index + 1}`}
|
|
||||||
className="img-fluid rounded shadow-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="form-label">Banner del formulario</label>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="form-control mb-2"
|
|
||||||
onChange={handleBannerChange}
|
|
||||||
/>
|
|
||||||
{bannerPreview && (
|
|
||||||
<div className="text-center">
|
|
||||||
<Image
|
|
||||||
src={bannerPreview}
|
|
||||||
width={1000}
|
|
||||||
height={300}
|
|
||||||
alt="Ejemplo de banner"
|
|
||||||
className="rounded-4 shadow-sm"
|
|
||||||
style={{
|
|
||||||
objectFit: 'cover',
|
|
||||||
objectPosition: 'center',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
label="Tipo de cuestionario"
|
|
||||||
options={[
|
|
||||||
{ label: 'Seleccione un tipo', value: '' },
|
|
||||||
{ label: 'Tipo 1', value: 1 },
|
|
||||||
{ label: 'Tipo 2', value: 2 },
|
|
||||||
{ label: 'Tipo 3', value: 3 },
|
|
||||||
]}
|
|
||||||
value={tipoCuestionario?.toString() || ''}
|
|
||||||
onChange={(e) => setTipoCuestionario(Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
label="Nombre del formulario"
|
|
||||||
value={nombreFormulario}
|
|
||||||
onChange={(e) => setNombreFormulario(e.target.value)}
|
|
||||||
placeholder="Ingrese el nombre del formulario"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Descripción</label>
|
|
||||||
<textarea
|
|
||||||
className="form-control bg-white"
|
|
||||||
rows={3}
|
|
||||||
value={descripcion}
|
|
||||||
onChange={(e) => setDescripcion(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="row">
|
|
||||||
<div className="col-md-6">
|
|
||||||
<Input
|
|
||||||
label="Fecha de inicio"
|
|
||||||
type="date"
|
|
||||||
value={fechaInicio || ''}
|
|
||||||
onChange={(e) => setFechaInicio(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-md-6">
|
|
||||||
<Input
|
|
||||||
label="Fecha de fin"
|
|
||||||
type="date"
|
|
||||||
value={fechaFin || ''}
|
|
||||||
onChange={(e) => setFechaFin(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<Button outline onClick={agregarSeccion} icon="plus">
|
|
||||||
Agregar Sección
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{seccionActual && (
|
|
||||||
<div className="border rounded p-3 mb-4">
|
|
||||||
<div className="d-flex justify-content-between align-items-center">
|
|
||||||
<h5>Sección {paginaActual}</h5>
|
|
||||||
<div className="d-flex gap-2">
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-outline-secondary"
|
|
||||||
onClick={() =>
|
|
||||||
moverSeccion(paginaActual - 1, paginaActual - 2)
|
|
||||||
}
|
|
||||||
disabled={paginaActual === 1}
|
|
||||||
>
|
|
||||||
↑
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-outline-secondary"
|
|
||||||
onClick={() => moverSeccion(paginaActual - 1, paginaActual)}
|
|
||||||
disabled={paginaActual === secciones.length}
|
|
||||||
>
|
|
||||||
↓
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-danger"
|
|
||||||
onClick={() => eliminarSeccion(paginaActual - 1)}
|
|
||||||
>
|
|
||||||
Eliminar sección
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FloatingInput
|
|
||||||
label="Título de la sección"
|
|
||||||
className={{ container: 'mb-2' }}
|
|
||||||
value={seccionActual.titulo}
|
|
||||||
onChange={(e) =>
|
|
||||||
actualizarSeccion(paginaActual - 1, 'titulo', e.target.value)
|
|
||||||
}
|
|
||||||
placeholder="Título de la sección"
|
|
||||||
/>
|
|
||||||
<textarea
|
|
||||||
className="form-control mb-3"
|
|
||||||
placeholder="Descripción de la sección"
|
|
||||||
value={seccionActual.descripcion}
|
|
||||||
onChange={(e) =>
|
|
||||||
actualizarSeccion(
|
|
||||||
paginaActual - 1,
|
|
||||||
'descripcion',
|
|
||||||
e.target.value
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{seccionActual.preguntas.map((pregunta, pIdx) => (
|
|
||||||
<div key={pIdx} className="border rounded p-2 mb-3">
|
|
||||||
<div className="d-flex justify-content-between align-items-center">
|
|
||||||
<strong>Pregunta {pIdx + 1}</strong>
|
|
||||||
<div className="d-flex gap-2">
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-outline-secondary"
|
|
||||||
onClick={() =>
|
|
||||||
moverPregunta(paginaActual - 1, pIdx, pIdx - 1)
|
|
||||||
}
|
|
||||||
disabled={pIdx === 0}
|
|
||||||
>
|
|
||||||
↑
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-outline-secondary"
|
|
||||||
onClick={() =>
|
|
||||||
moverPregunta(paginaActual - 1, pIdx, pIdx + 1)
|
|
||||||
}
|
|
||||||
disabled={pIdx === seccionActual.preguntas.length - 1}
|
|
||||||
>
|
|
||||||
↓
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-outline-danger"
|
|
||||||
onClick={() => eliminarPregunta(paginaActual - 1, pIdx)}
|
|
||||||
>
|
|
||||||
Eliminar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
label="Texto de la pregunta"
|
|
||||||
className={{ container: 'my-2' }}
|
|
||||||
value={pregunta.titulo}
|
|
||||||
onChange={(e) =>
|
|
||||||
actualizarPregunta(
|
|
||||||
paginaActual - 1,
|
|
||||||
pIdx,
|
|
||||||
'titulo',
|
|
||||||
e.target.value
|
|
||||||
)
|
|
||||||
}
|
|
||||||
placeholder="Texto de la pregunta"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
label="Tipo de pregunta"
|
|
||||||
options={[
|
|
||||||
{ label: 'Abierta', value: 'Abierta' },
|
|
||||||
{ label: 'Cerrada', value: 'Cerrada' },
|
|
||||||
{ label: 'Opción múltiple', value: 'Multiple' },
|
|
||||||
]}
|
|
||||||
value={pregunta.tipo}
|
|
||||||
onChange={(e) =>
|
|
||||||
actualizarPregunta(
|
|
||||||
paginaActual - 1,
|
|
||||||
pIdx,
|
|
||||||
'tipo',
|
|
||||||
e.target.value as PreguntaFormulario['tipo']
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="form-check form-switch mb-3">
|
|
||||||
<input
|
|
||||||
className="form-check-input"
|
|
||||||
type="checkbox"
|
|
||||||
id={`obligatoria-${pIdx}`}
|
|
||||||
checked={pregunta.obligatoria}
|
|
||||||
onChange={(e) =>
|
|
||||||
actualizarPregunta(
|
|
||||||
paginaActual - 1,
|
|
||||||
pIdx,
|
|
||||||
'obligatoria',
|
|
||||||
e.target.checked
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
className="form-check-label"
|
|
||||||
htmlFor={`obligatoria-${pIdx}`}
|
|
||||||
>
|
|
||||||
¿Es obligatoria?
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(pregunta.tipo === 'Cerrada' ||
|
|
||||||
pregunta.tipo === 'Multiple') && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-outline-secondary mb-2"
|
|
||||||
onClick={() => agregarOpcion(paginaActual - 1, pIdx)}
|
|
||||||
>
|
|
||||||
+ Agregar opción
|
|
||||||
</button>
|
|
||||||
{(pregunta.opciones ?? []).map((op, oIdx) => (
|
|
||||||
<div key={oIdx} className="input-group mb-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control bg-white"
|
|
||||||
placeholder={`Opción ${oIdx + 1}`}
|
|
||||||
value={op.valor}
|
|
||||||
onChange={(e) =>
|
|
||||||
actualizarOpcion(
|
|
||||||
paginaActual - 1,
|
|
||||||
pIdx,
|
|
||||||
oIdx,
|
|
||||||
e.target.value
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className="btn btn-outline-danger"
|
|
||||||
onClick={() =>
|
|
||||||
eliminarOpcion(paginaActual - 1, pIdx, oIdx)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
x
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-outline-primary"
|
|
||||||
onClick={() => agregarPregunta(paginaActual - 1)}
|
|
||||||
>
|
|
||||||
+ Agregar pregunta
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{secciones.length > 1 && (
|
|
||||||
<div className="d-flex justify-content-center mb-4">
|
|
||||||
<Pagination
|
|
||||||
currentPage={paginaActual}
|
|
||||||
totalPages={secciones.length}
|
|
||||||
onPageChange={setPaginaActual}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button onClick={guardarFormulario} color="success">
|
|
||||||
Guardar formulario
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<nav className='d-flex gap-2 my-4'>
|
|
||||||
<Link href={'/administrador/'} className='text-decoration-none'>
|
|
||||||
<div className='box'>Registros</div>
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href={'/administrador/crear-formulario'}
|
|
||||||
className='text-decoration-none'
|
|
||||||
>
|
|
||||||
<div className='box'>Editar formulario</div>
|
|
||||||
</Link>
|
|
||||||
</nav>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
'use client';
|
|
||||||
import { useParams } from 'next/navigation';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
const params = useParams<{
|
|
||||||
id_cuestionario: string;
|
|
||||||
}>();
|
|
||||||
const id_cuestionario = params?.id_cuestionario;
|
|
||||||
|
|
||||||
return <div>{id_cuestionario}</div>;
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import Footer from '@/components/layout/footer';
|
|
||||||
import Header from '@/components/layout/header';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className='d-flex flex-column min-vh-100'>
|
|
||||||
<Header />
|
|
||||||
<main className='container flex-grow-1'>
|
|
||||||
<nav className='d-flex gap-2 my-4'>
|
|
||||||
<Link href={'/administrador/'} className='text-decoration-none'>
|
|
||||||
<div className='box'>Formularios</div>
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href={'/administrador/crear-formulario'}
|
|
||||||
className='text-decoration-none'
|
|
||||||
>
|
|
||||||
<div className='box'>Crear cuestionario</div>
|
|
||||||
</Link>
|
|
||||||
</nav>
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import FormularioCard from '@/components/formulario-card';
|
|
||||||
import { GetCuestionario } from '@/types/cuestionario';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
async function getEventos(): Promise<GetCuestionario[]> {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/cuestionario`, {
|
|
||||||
method: 'GET',
|
|
||||||
cache: 'no-store', // para que no lo cachee ni en build ni entre requests
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`Error ${res.status}: ${res.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: GetCuestionario[] = await res.json();
|
|
||||||
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error en getEventos:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function Page() {
|
|
||||||
let eventos;
|
|
||||||
|
|
||||||
try {
|
|
||||||
eventos = await getEventos();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error al cargar los eventos:', error);
|
|
||||||
return <div>Error al cargar los eventos</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="cards-1 mt-3">
|
|
||||||
<div className="container">
|
|
||||||
<div className="row">
|
|
||||||
{eventos.map((cuestionario) => (
|
|
||||||
<FormularioCard
|
|
||||||
key={cuestionario.id_cuestionario}
|
|
||||||
cuestionario={cuestionario}
|
|
||||||
link={`/administrador/formulario/${cuestionario.id_cuestionario}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{eventos.length === 0 && (
|
|
||||||
<p className="text-center text-muted">
|
|
||||||
No hay formularios disponibles.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import dynamic from 'next/dynamic';
|
|
||||||
import { Scanner } from '@yudiel/react-qr-scanner';
|
|
||||||
import Button from '@/components/button';
|
|
||||||
import axiosInstance from '@/utils/api-config';
|
|
||||||
import Header from '@/components/layout/header';
|
|
||||||
import Footer from '@/components/layout/footer';
|
|
||||||
|
|
||||||
const Modal = dynamic(() => import('@/components/modal'), { ssr: false });
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
const [scannedData, setScannedData] = useState<{
|
|
||||||
id_participante: number;
|
|
||||||
id_evento: number;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const [showModal, setShowModal] = useState(false);
|
|
||||||
const [enableScan, setEnableScan] = useState(true);
|
|
||||||
const [statusMessage, setStatusMessage] = useState('');
|
|
||||||
|
|
||||||
const handleScan = (rawValue: string) => {
|
|
||||||
if (!enableScan) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(rawValue);
|
|
||||||
if (data.id_participante && data.id_evento) {
|
|
||||||
setScannedData({
|
|
||||||
id_participante: data.id_participante,
|
|
||||||
id_evento: data.id_evento,
|
|
||||||
});
|
|
||||||
setEnableScan(false);
|
|
||||||
setShowModal(true);
|
|
||||||
} else {
|
|
||||||
setStatusMessage('El QR no contiene los campos requeridos');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('QR malformado:', err);
|
|
||||||
setStatusMessage('Error al leer el QR: formato inválido');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
|
||||||
if (!scannedData) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await axiosInstance.post(
|
|
||||||
`/participante-evento/asistencia/${scannedData.id_participante}/${scannedData.id_evento}`
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log('Asistencia registrada:', response.data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error en la petición:', err);
|
|
||||||
setStatusMessage('❌ Error de red al registrar asistencia');
|
|
||||||
}
|
|
||||||
|
|
||||||
setShowModal(false);
|
|
||||||
setEnableScan(true);
|
|
||||||
setScannedData(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="d-flex flex-column min-vh-100">
|
|
||||||
<Header />
|
|
||||||
<main className='container flex-grow-1 d-flex flex-column align-items-center justify-content-center'>
|
|
||||||
<h1 className="mb-4">Lectura de QRs</h1>
|
|
||||||
|
|
||||||
{enableScan ? (
|
|
||||||
<>
|
|
||||||
<div style={{ width: '100%', maxWidth: '400px' }}>
|
|
||||||
<Scanner
|
|
||||||
onScan={(codes) => {
|
|
||||||
if (codes.length > 0) {
|
|
||||||
handleScan(codes[0].rawValue);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onError={(err) => console.error('QR Error:', err)}
|
|
||||||
constraints={{ facingMode: 'environment' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => setEnableScan(false)}
|
|
||||||
variant="outline-danger"
|
|
||||||
className="mt-3"
|
|
||||||
>
|
|
||||||
Detener cámara
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p className="text-muted mb-3">La cámara está detenida.</p>
|
|
||||||
<Button
|
|
||||||
onClick={() => setEnableScan(true)}
|
|
||||||
variant="outline-primary"
|
|
||||||
>
|
|
||||||
Activar cámara
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{statusMessage && <p className="mt-3">{statusMessage}</p>}
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
isVisible={showModal}
|
|
||||||
size="md"
|
|
||||||
onClose={() => {
|
|
||||||
setShowModal(false);
|
|
||||||
setEnableScan(true);
|
|
||||||
setScannedData(null);
|
|
||||||
}}
|
|
||||||
closeButton
|
|
||||||
className={{
|
|
||||||
content: 'p-3',
|
|
||||||
body: 'text-center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h5 className="mb-3">Datos escaneados</h5>
|
|
||||||
{scannedData ? (
|
|
||||||
<>
|
|
||||||
<p>
|
|
||||||
<strong>ID Participante:</strong> {scannedData.id_participante}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>ID Evento:</strong> {scannedData.id_evento}
|
|
||||||
</p>
|
|
||||||
<Button variant="success" onClick={handleConfirm}>
|
|
||||||
Confirmar lectura
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p>QR inválido</p>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
</main>
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import axiosInstance from '@/utils/api-config';
|
||||||
|
import { Button, Form, Alert } from 'react-bootstrap';
|
||||||
|
import { FaUpload, FaDownload } from 'react-icons/fa';
|
||||||
|
|
||||||
|
type TipoCarga = 'alumnos' | 'trabajadores';
|
||||||
|
|
||||||
|
interface ResultadoCarga {
|
||||||
|
insertados: number;
|
||||||
|
omitidos: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SeccionState {
|
||||||
|
archivo: File | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
success: string | null;
|
||||||
|
resultado: ResultadoCarga | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: SeccionState = {
|
||||||
|
archivo: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
success: null,
|
||||||
|
resultado: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const [alumnos, setAlumnos] = useState<SeccionState>(initialState);
|
||||||
|
const [trabajadores, setTrabajadores] = useState<SeccionState>(initialState);
|
||||||
|
|
||||||
|
const alumnosRef = useRef<HTMLInputElement>(null);
|
||||||
|
const trabajadoresRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const getState = (tipo: TipoCarga) =>
|
||||||
|
tipo === 'alumnos' ? alumnos : trabajadores;
|
||||||
|
const setState = (tipo: TipoCarga) =>
|
||||||
|
tipo === 'alumnos' ? setAlumnos : setTrabajadores;
|
||||||
|
|
||||||
|
const validarExt = (file: File | null): boolean => {
|
||||||
|
if (!file) return false;
|
||||||
|
return /\.xlsx$/i.test(file.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArchivoChange = (tipo: TipoCarga, file: File | null) => {
|
||||||
|
if (file && !validarExt(file)) {
|
||||||
|
setState(tipo)((prev) => ({
|
||||||
|
...prev,
|
||||||
|
archivo: null,
|
||||||
|
error: 'Selecciona un archivo .xlsx válido',
|
||||||
|
success: null,
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(tipo)((prev) => ({
|
||||||
|
...prev,
|
||||||
|
archivo: file,
|
||||||
|
error: null,
|
||||||
|
success: null,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCargaMasiva = async (tipo: TipoCarga) => {
|
||||||
|
const estado = getState(tipo);
|
||||||
|
if (!estado.archivo) {
|
||||||
|
setState(tipo)((prev) => ({
|
||||||
|
...prev,
|
||||||
|
error: 'Selecciona un archivo xlsx',
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', estado.archivo);
|
||||||
|
setState(tipo)((prev) => ({
|
||||||
|
...prev,
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
success: null,
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
const res = await axiosInstance.post(`/${tipo}/cargar`, formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
setState(tipo)((prev) => ({
|
||||||
|
...prev,
|
||||||
|
archivo: null,
|
||||||
|
success: `${tipo === 'alumnos' ? 'Alumnos' : 'Trabajadores'} cargados exitosamente`,
|
||||||
|
resultado: res.data ?? null,
|
||||||
|
}));
|
||||||
|
const ref = tipo === 'alumnos' ? alumnosRef : trabajadoresRef;
|
||||||
|
if (ref.current) ref.current.value = '';
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as { response?: { data?: { message?: string } } };
|
||||||
|
setState(tipo)((prev) => ({
|
||||||
|
...prev,
|
||||||
|
error: err?.response?.data?.message || 'Error al cargar el archivo',
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
setState(tipo)((prev) => ({ ...prev, loading: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDescargarPlantilla = (tipo: TipoCarga) => {
|
||||||
|
const nombre = tipo === 'alumnos' ? 'carga-alumnos' : 'carga-trabajadores';
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = `/plantillas/${nombre}.xlsx`;
|
||||||
|
link.setAttribute('download', `${nombre}.xlsx`);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
const SeccionCarga = ({
|
||||||
|
tipo,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
tipo: TipoCarga;
|
||||||
|
label: string;
|
||||||
|
}) => {
|
||||||
|
const estado = getState(tipo);
|
||||||
|
const inputRef = tipo === 'alumnos' ? alumnosRef : trabajadoresRef;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 border rounded bg-light mb-4">
|
||||||
|
<h2 className="mb-3">{label}</h2>
|
||||||
|
|
||||||
|
{estado.error && (
|
||||||
|
<Alert
|
||||||
|
variant="danger"
|
||||||
|
onClose={() => setState(tipo)((p) => ({ ...p, error: null }))}
|
||||||
|
dismissible
|
||||||
|
>
|
||||||
|
{estado.error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{estado.success && (
|
||||||
|
<Alert
|
||||||
|
variant="success"
|
||||||
|
onClose={() =>
|
||||||
|
setState(tipo)((p) => ({ ...p, success: null, resultado: null }))
|
||||||
|
}
|
||||||
|
dismissible
|
||||||
|
>
|
||||||
|
<p className="mb-1">{estado.success}</p>
|
||||||
|
{estado.resultado && (
|
||||||
|
<ul className="mb-0">
|
||||||
|
<li>
|
||||||
|
Insertados: <strong>{estado.resultado.insertados}</strong>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Omitidos: <strong>{estado.resultado.omitidos}</strong>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Archivo Excel (.xlsx)</Form.Label>
|
||||||
|
<div
|
||||||
|
className="border p-4 text-center rounded"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<FaUpload size={36} className="mb-2 text-secondary" />
|
||||||
|
<p className="mb-0">
|
||||||
|
{estado.archivo?.name ||
|
||||||
|
'Arrastra aquí tu archivo o haz click para buscar'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleArchivoChange(tipo, e.target.files?.[0] ?? null)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<div className="d-flex gap-2 flex-wrap">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={estado.loading || !estado.archivo}
|
||||||
|
onClick={() => handleCargaMasiva(tipo)}
|
||||||
|
>
|
||||||
|
{estado.loading ? 'Cargando...' : 'Cargar archivo'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline-success"
|
||||||
|
onClick={() => handleDescargarPlantilla(tipo)}
|
||||||
|
>
|
||||||
|
<FaDownload className="me-2" />
|
||||||
|
Descargar plantilla
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container-fluid my-4">
|
||||||
|
<div className="row mb-4">
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="p-4 border rounded bg-light">
|
||||||
|
<h1 className="mb-1">Carga masiva de usuarios</h1>
|
||||||
|
<p className="mb-0 text-muted">
|
||||||
|
Sube un archivo Excel para registrar alumnos o trabajadores en el
|
||||||
|
sistema.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SeccionCarga tipo="alumnos" label="Carga de alumnos" />
|
||||||
|
<SeccionCarga tipo="trabajadores" label="Carga de trabajadores" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
'use client';
|
||||||
|
import Breadcrumb from '@/components/breadcrumb';
|
||||||
|
import SimpleInput from '@/components/input';
|
||||||
|
import Table, { Header } from '@/components/table';
|
||||||
|
import EditFormulario from '@/containers/edit-formulario';
|
||||||
|
import { useGetApi } from '@/hooks/use-get-api';
|
||||||
|
import { GetCuestionario } from '@/types/cuestionario';
|
||||||
|
import { ParticipacionEvento } from '@/types/participante-evento';
|
||||||
|
import axiosInstance from '@/utils/api-config';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { useEvento } from '@/context/evento';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import Button from '@/components/button';
|
||||||
|
|
||||||
|
const Modal = dynamic(() => import('@/components/modal'), { ssr: false });
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
id_evento: string;
|
||||||
|
id_cuestionario: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const params = useParams<Params>();
|
||||||
|
const { evento } = useEvento();
|
||||||
|
|
||||||
|
const [cuestionario, setCuestionario] =
|
||||||
|
React.useState<GetCuestionario | null>(null);
|
||||||
|
const [busquedaCorreo, setBusquedaCorreo] = React.useState('');
|
||||||
|
const [loadingAsistencia, setLoadingAsistencia] = React.useState<
|
||||||
|
Record<number, boolean>
|
||||||
|
>({});
|
||||||
|
const [contadorModal, setContadorModal] = React.useState<number | null>(null);
|
||||||
|
|
||||||
|
const { data: participantes, setData } = useGetApi<ParticipacionEvento[]>(
|
||||||
|
`/participante-evento/evento/${params.id_cuestionario}`
|
||||||
|
);
|
||||||
|
const { data } = useGetApi<GetCuestionario>(
|
||||||
|
`/cuestionario/${params.id_cuestionario}`
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) setCuestionario(data);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const confirmarAsistencia = async (
|
||||||
|
id_participante: number,
|
||||||
|
id_evento: number
|
||||||
|
) => {
|
||||||
|
setLoadingAsistencia((prev) => ({ ...prev, [id_participante]: true }));
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.post(
|
||||||
|
`/participante-evento/asistencia/${id_participante}/${id_evento}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data?.contador != null) {
|
||||||
|
setContadorModal(response.data.contador);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success('Asistencia confirmada');
|
||||||
|
const { data } = await axiosInstance.get<ParticipacionEvento[]>(
|
||||||
|
`/participante-evento/evento/${id_evento}`
|
||||||
|
);
|
||||||
|
setData(data);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Error al confirmar asistencia');
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoadingAsistencia((prev) => ({ ...prev, [id_participante]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const headers: Header<ParticipacionEvento>[] = [
|
||||||
|
{
|
||||||
|
key: 'id_participante',
|
||||||
|
label: '#',
|
||||||
|
render: (_, row) => row.participante.id_participante,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'participante',
|
||||||
|
label: 'Correo',
|
||||||
|
render: (_, row) => row.participante.correo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'fecha_registro',
|
||||||
|
label: 'Fecha registro',
|
||||||
|
render: (_, row) =>
|
||||||
|
row.fecha_registro
|
||||||
|
? new Date(row.fecha_registro).toLocaleString()
|
||||||
|
: 'Sin registro',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'estatus',
|
||||||
|
label: 'Asistencia',
|
||||||
|
render: (_, row) => {
|
||||||
|
const isLoading = loadingAsistencia[row.id_participante];
|
||||||
|
|
||||||
|
return row.fecha_asistencia ? (
|
||||||
|
<span className="text-success">
|
||||||
|
<i className="bi bi-check-circle-fill me-2"></i>
|
||||||
|
Asistió
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline-success"
|
||||||
|
onClick={() =>
|
||||||
|
confirmarAsistencia(row.id_participante, row.id_cuestionario)
|
||||||
|
}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="spinner-border spinner-border-sm me-2"
|
||||||
|
role="status"
|
||||||
|
aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
Confirmando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Confirmar asistencia'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleChange = (field: keyof GetCuestionario, value: string | Date) => {
|
||||||
|
setCuestionario((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
[field]: value,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCuestionarioActualizado = (
|
||||||
|
eventoActualizado: GetCuestionario
|
||||||
|
) => {
|
||||||
|
setCuestionario(eventoActualizado);
|
||||||
|
};
|
||||||
|
|
||||||
|
const participantesFiltrados =
|
||||||
|
participantes &&
|
||||||
|
participantes.filter((p) =>
|
||||||
|
p.participante.correo.toLowerCase().includes(busquedaCorreo.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const breadcrumbItems = [
|
||||||
|
{ label: 'Inicio', href: '/user/eventos' },
|
||||||
|
{
|
||||||
|
label: evento?.nombre_evento || 'Evento',
|
||||||
|
href: `/user/evento/${params.id_evento}`,
|
||||||
|
},
|
||||||
|
{ label: cuestionario?.nombre_form || 'Formulario' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-4">
|
||||||
|
<Breadcrumb items={breadcrumbItems} />
|
||||||
|
|
||||||
|
{cuestionario && (
|
||||||
|
<EditFormulario
|
||||||
|
cuestionario={cuestionario}
|
||||||
|
evento={evento}
|
||||||
|
handleChange={handleChange}
|
||||||
|
handleOnChange={handleCuestionarioActualizado}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h3 className="mt-5">Participantes</h3>
|
||||||
|
|
||||||
|
{participantes?.length === 0 && (
|
||||||
|
<p className="text-muted">No hay participantes registrados aún.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{participantes && participantes.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SimpleInput
|
||||||
|
label="Buscar por correo"
|
||||||
|
placeholder="ejemplo@correo.com"
|
||||||
|
value={busquedaCorreo}
|
||||||
|
onChange={(e) => setBusquedaCorreo(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<Table
|
||||||
|
headers={headers}
|
||||||
|
data={participantesFiltrados ?? []}
|
||||||
|
rowKey={(row) => row.id_participante}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Modal
|
||||||
|
isVisible={contadorModal !== null}
|
||||||
|
size="sm"
|
||||||
|
onClose={() => setContadorModal(null)}
|
||||||
|
closeButton
|
||||||
|
className={{ content: 'border-0 rounded-3', body: 'text-center' }}
|
||||||
|
>
|
||||||
|
<div className="modal-header bg-primary text-white border-0 rounded-top-3">
|
||||||
|
<h5 className="modal-title mb-0 fw-bold">
|
||||||
|
<i className="bi bi-hash me-2"></i>
|
||||||
|
Contador
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body p-4">
|
||||||
|
<p className="display-4 fw-bold text-primary mb-1">{contadorModal}</p>
|
||||||
|
<p className="text-muted mb-4">
|
||||||
|
Este es el numero de estampillas a entregar al usuario
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => setContadorModal(null)}
|
||||||
|
className="px-4 py-2 rounded-pill"
|
||||||
|
>
|
||||||
|
Cerrar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
'use client';
|
||||||
|
import { plantillasDisponibles } from '@/data/plantillas';
|
||||||
|
import { FormularioCreacion } from '@/types/create-formulario';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import CreateFormulario from '@/containers/create-formulario';
|
||||||
|
import FormularioCardPreview from '@/components/formulario/formulario-card-preview';
|
||||||
|
import Button from '@/components/button';
|
||||||
|
import Breadcrumb from '@/components/breadcrumb';
|
||||||
|
import { useEvento } from '@/context/evento';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import axiosInstance from '@/utils/api-config';
|
||||||
|
import { getAxiosError } from '@/utils/errors-utils';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { useGetApi } from '@/hooks/use-get-api';
|
||||||
|
import { TipoEvento } from '@/types/evento';
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
id_evento: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const params = useParams<Params>();
|
||||||
|
const router = useRouter();
|
||||||
|
const { evento, refetch } = useEvento();
|
||||||
|
const [datosFormulario, setDatosFormulario] =
|
||||||
|
useState<FormularioCreacion | null>(null);
|
||||||
|
const { data: tipo_eventos } = useGetApi<TipoEvento[]>(`/tipo-evento`);
|
||||||
|
|
||||||
|
const handleSeleccionarPlantilla = (plantillaId: string) => {
|
||||||
|
const seleccionada = plantillasDisponibles.find(
|
||||||
|
(p) => p.id === plantillaId
|
||||||
|
);
|
||||||
|
if (seleccionada && evento) {
|
||||||
|
// Crear una copia de los datos de la plantilla
|
||||||
|
const datosPlantilla = { ...seleccionada.datos };
|
||||||
|
|
||||||
|
// Ajustar las fechas para que estén dentro del rango del evento
|
||||||
|
const fechaInicioEvento = new Date(evento.fecha_inicio);
|
||||||
|
const fechaFinEvento = new Date(evento.fecha_fin);
|
||||||
|
|
||||||
|
// Formatear las fechas para inputs datetime-local (YYYY-MM-DDTHH:MM)
|
||||||
|
const formatoFechaInput = (fecha: Date) => {
|
||||||
|
return fecha.toISOString().slice(0, 16);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Establecer fecha de inicio del formulario igual a la del evento
|
||||||
|
datosPlantilla.fecha_inicio = formatoFechaInput(fechaInicioEvento);
|
||||||
|
|
||||||
|
// Establecer fecha de fin del formulario igual a la del evento
|
||||||
|
datosPlantilla.fecha_fin = formatoFechaInput(fechaFinEvento);
|
||||||
|
|
||||||
|
setDatosFormulario(datosPlantilla);
|
||||||
|
console.log('Plantilla seleccionada:', seleccionada);
|
||||||
|
console.log('Fechas ajustadas al evento:', {
|
||||||
|
inicio: datosPlantilla.fecha_inicio,
|
||||||
|
fin: datosPlantilla.fecha_fin,
|
||||||
|
});
|
||||||
|
} else if (seleccionada) {
|
||||||
|
// Si no hay evento disponible, usar las fechas originales de la plantilla
|
||||||
|
setDatosFormulario(seleccionada.datos);
|
||||||
|
console.log(
|
||||||
|
'Plantilla seleccionada (sin ajuste de fechas):',
|
||||||
|
seleccionada
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const breadcrumbItems = [
|
||||||
|
{ label: 'Inicio', href: '/user/eventos' },
|
||||||
|
{
|
||||||
|
label: evento?.nombre_evento || 'Evento',
|
||||||
|
href: `/user/evento/${params.id_evento}`,
|
||||||
|
},
|
||||||
|
{ label: 'Crear formulario' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleCrearEvento = async () => {
|
||||||
|
if (!datosFormulario || !evento) {
|
||||||
|
toast.error('Faltan datos para crear el formulario');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar fechas del formulario respecto al evento
|
||||||
|
const fechaInicioFormulario = new Date(datosFormulario.fecha_inicio);
|
||||||
|
const fechaFinFormulario = new Date(datosFormulario.fecha_fin);
|
||||||
|
const fechaInicioEvento = new Date(evento.fecha_inicio);
|
||||||
|
const fechaFinEvento = new Date(evento.fecha_fin);
|
||||||
|
|
||||||
|
// Convertir a solo fecha (sin hora) para comparación
|
||||||
|
const fechaInicioFormularioSoloFecha = new Date(
|
||||||
|
fechaInicioFormulario.getFullYear(),
|
||||||
|
fechaInicioFormulario.getMonth(),
|
||||||
|
fechaInicioFormulario.getDate()
|
||||||
|
);
|
||||||
|
const fechaFinFormularioSoloFecha = new Date(
|
||||||
|
fechaFinFormulario.getFullYear(),
|
||||||
|
fechaFinFormulario.getMonth(),
|
||||||
|
fechaFinFormulario.getDate()
|
||||||
|
);
|
||||||
|
const fechaInicioEventoSoloFecha = new Date(
|
||||||
|
fechaInicioEvento.getFullYear(),
|
||||||
|
fechaInicioEvento.getMonth(),
|
||||||
|
fechaInicioEvento.getDate()
|
||||||
|
);
|
||||||
|
const fechaFinEventoSoloFecha = new Date(
|
||||||
|
fechaFinEvento.getFullYear(),
|
||||||
|
fechaFinEvento.getMonth(),
|
||||||
|
fechaFinEvento.getDate()
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Fechas del formulario:', {
|
||||||
|
inicio: fechaInicioFormulario,
|
||||||
|
fin: fechaFinFormulario,
|
||||||
|
});
|
||||||
|
console.log('Fechas del evento:', {
|
||||||
|
inicio: fechaInicioEvento,
|
||||||
|
fin: fechaFinEvento,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fechaInicioFormularioSoloFecha < fechaInicioEventoSoloFecha) {
|
||||||
|
toast.error(
|
||||||
|
'La fecha de inicio del formulario debe ser posterior o igual a la fecha de inicio del evento'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fechaFinFormularioSoloFecha > fechaFinEventoSoloFecha) {
|
||||||
|
toast.error(
|
||||||
|
'La fecha de fin del formulario debe ser anterior o igual a la fecha de fin del evento'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fechaInicioFormulario > fechaFinFormulario) {
|
||||||
|
toast.error(
|
||||||
|
'La fecha de inicio del formulario debe ser anterior a la fecha de fin'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await axiosInstance.post(`/cuestionario`, {
|
||||||
|
id_evento: evento?.id_evento || params.id_evento,
|
||||||
|
...datosFormulario,
|
||||||
|
});
|
||||||
|
console.log('Formulario creado:', res.data);
|
||||||
|
refetch();
|
||||||
|
toast.success('Formulario creado exitosamente');
|
||||||
|
router.push(`/user/evento/${params.id_evento}`);
|
||||||
|
} catch (error) {
|
||||||
|
const msg = getAxiosError(error);
|
||||||
|
toast.error(msg.message || 'Error al crear el formulario');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container-fluid my-4">
|
||||||
|
<Breadcrumb items={breadcrumbItems} />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="row mb-4">
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="p-4 border rounded bg-light">
|
||||||
|
<h2 className="mb-2">Creación de formulario</h2>
|
||||||
|
<p className="mb-0 text-muted">
|
||||||
|
Esta sección te permite crear y configurar sub-eventos en forma de
|
||||||
|
formularios asociados al evento. Selecciona una plantilla
|
||||||
|
compatible, ajusta los campos necesarios y publícalo para
|
||||||
|
habilitar el registro, encuestas o confirmaciones dentro de este
|
||||||
|
evento.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!datosFormulario && (
|
||||||
|
<div className="border p-3 rounded my-4">
|
||||||
|
<h2 className="h5 fw-bold mb-2">
|
||||||
|
Selecciona una plantilla para el formulario:
|
||||||
|
</h2>
|
||||||
|
<p className="text-secondary small lh-sm">
|
||||||
|
Elige una de las plantillas disponibles para crear tu formulario de
|
||||||
|
registro.
|
||||||
|
<br />
|
||||||
|
Estas plantillas están diseñadas para garantizar la correcta
|
||||||
|
integración con el sistema, permitiendo que los datos se obtengan y
|
||||||
|
se prellenan automáticamente de forma precisa.
|
||||||
|
<br />
|
||||||
|
<span className="text-danger fw-semibold">
|
||||||
|
Para asegurar el funcionamiento óptimo del prellenado, modifica la
|
||||||
|
estructura solo si es necesario.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="d-flex gap-2 flex-wrap">
|
||||||
|
{plantillasDisponibles.map((plantilla) => (
|
||||||
|
<div
|
||||||
|
className="cursor-pointer"
|
||||||
|
key={plantilla.id}
|
||||||
|
onClick={() => handleSeleccionarPlantilla(plantilla.id)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="d-flex align-items-center justify-content-center rounded shadow-sm border text-center fw-semibold bg-azul text-white"
|
||||||
|
style={{ padding: '1rem' }}
|
||||||
|
>
|
||||||
|
{plantilla.nombre}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{datosFormulario && (
|
||||||
|
<>
|
||||||
|
<div className="row">
|
||||||
|
{/* Columna Izquierda - Formulario */}
|
||||||
|
<div className="col-xl-7 col-lg-6">
|
||||||
|
<div className="pe-lg-4">
|
||||||
|
{(() => {
|
||||||
|
const formularioEditor = CreateFormulario({
|
||||||
|
formulario: datosFormulario,
|
||||||
|
onChange: (nuevo) => setDatosFormulario({ ...nuevo }),
|
||||||
|
evento: evento ? evento : undefined,
|
||||||
|
tipo_eventos: tipo_eventos?.map((tipo) => ({
|
||||||
|
value: tipo.id_tipo_evento,
|
||||||
|
label: tipo.tipo_evento,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
return formularioEditor.informacionBasica();
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Botón de crear */}
|
||||||
|
<div className="d-flex justify-content-end mt-4">
|
||||||
|
<Button icon="save" onClick={handleCrearEvento}>
|
||||||
|
Crear Formulario
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Columna Derecha - Preview */}
|
||||||
|
<div className="col-xl-5 col-lg-6">
|
||||||
|
<div className="ps-lg-4">
|
||||||
|
<div className="sticky-top" style={{ top: '20px' }}>
|
||||||
|
<h4 className="mb-3 d-none d-lg-block">
|
||||||
|
Preview del Formulario
|
||||||
|
</h4>
|
||||||
|
<div className="d-lg-none mt-4">
|
||||||
|
<h4 className="mb-3">Preview del Formulario</h4>
|
||||||
|
</div>
|
||||||
|
<FormularioCardPreview
|
||||||
|
formulario={datosFormulario}
|
||||||
|
evento={evento || undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Alert de imagen del evento */}
|
||||||
|
{evento?.banner && (
|
||||||
|
<div className="alert alert-info mt-3">
|
||||||
|
<i className="bi bi-info-circle-fill me-2"></i>
|
||||||
|
<strong>Nota:</strong> Al no proporcionar una imagen para
|
||||||
|
este formulario, se usará automáticamente la imagen del
|
||||||
|
evento.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="alert alert-warning text-center mt-3">
|
||||||
|
<strong>Vista previa:</strong> Así se verá tu formulario en
|
||||||
|
las listas de formularios.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Secciones y preguntas - Ancho completo */}
|
||||||
|
<div className="row mt-5">
|
||||||
|
<div className="col-12">
|
||||||
|
{(() => {
|
||||||
|
const formularioEditor = CreateFormulario({
|
||||||
|
formulario: datosFormulario,
|
||||||
|
onChange: (nuevo) => setDatosFormulario({ ...nuevo }),
|
||||||
|
evento: evento ? evento : undefined,
|
||||||
|
});
|
||||||
|
return formularioEditor.seccionesYPreguntas();
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
'use client';
|
||||||
|
import { EventoProvider } from '@/context/evento/evento-context';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
id_evento: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
const params = useParams<Params>();
|
||||||
|
|
||||||
|
if (!params.id_evento) {
|
||||||
|
return <div>Error: ID de evento no encontrado</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EventoProvider id_evento={params.id_evento}>{children}</EventoProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
'use client';
|
||||||
|
import EditEvento from '@/containers/edit-evento';
|
||||||
|
import { GetEventoWithCuestionariosWithCupos } from '@/types/evento';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { CreateEventoType } from '@/types/create-evento';
|
||||||
|
import { useEvento } from '@/context/evento';
|
||||||
|
import axiosInstance from '@/utils/api-config';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { downloadFile } from '@/utils/downloas-utils';
|
||||||
|
import Breadcrumb from '@/components/breadcrumb';
|
||||||
|
import Button from '@/components/button';
|
||||||
|
import FormularioCardAdmin from '@/components/formulario/formulario-card-admin';
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
id_evento: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams<Params>();
|
||||||
|
const { evento, loading, error, updateEvento, setEventoData } = useEvento();
|
||||||
|
|
||||||
|
const tipo_usuario = sessionStorage.getItem('axxxd')?.replace(/"/g, '') || '';
|
||||||
|
|
||||||
|
const [loadingStates, setLoadingStates] = useState<Record<number, boolean>>(
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
type TipoCarga = 'alumnos' | 'trabajadores';
|
||||||
|
const [tipoCarga, setTipoCarga] = useState<TipoCarga>('alumnos');
|
||||||
|
const [archivoCarga, setArchivoCarga] = useState<File | null>(null);
|
||||||
|
const [loadingCarga, setLoadingCarga] = useState(false);
|
||||||
|
|
||||||
|
const handleCargaMasiva = async () => {
|
||||||
|
if (!archivoCarga) {
|
||||||
|
toast.error('Selecciona un archivo xlsx');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', archivoCarga);
|
||||||
|
formData.append('id_evento', params.id_evento);
|
||||||
|
setLoadingCarga(true);
|
||||||
|
try {
|
||||||
|
await axiosInstance.post(
|
||||||
|
`/evento/${params.id_evento}/usuarios`,
|
||||||
|
formData,
|
||||||
|
{
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
toast.success(
|
||||||
|
`${tipoCarga === 'alumnos' ? 'Alumnos' : 'Trabajadores'} cargados exitosamente`
|
||||||
|
);
|
||||||
|
setArchivoCarga(null);
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as { response?: { data?: { message?: string } } };
|
||||||
|
toast.error(err?.response?.data?.message || 'Error al cargar el archivo');
|
||||||
|
} finally {
|
||||||
|
setLoadingCarga(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (
|
||||||
|
field: keyof CreateEventoType,
|
||||||
|
value: string | Date
|
||||||
|
) => {
|
||||||
|
// Update the evento state directly using the context
|
||||||
|
updateEvento({ [field]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEventoActualizado = (
|
||||||
|
eventoActualizado: GetEventoWithCuestionariosWithCupos
|
||||||
|
) => {
|
||||||
|
// Set the complete updated evento data
|
||||||
|
setEventoData(eventoActualizado);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <p>Cargando...</p>;
|
||||||
|
if (error) return <p>Error: {error}</p>;
|
||||||
|
if (!evento) return <p>No se encontró el evento</p>;
|
||||||
|
|
||||||
|
const handleDownload = async (
|
||||||
|
id_cuestionario: number,
|
||||||
|
nombre_formulario: string,
|
||||||
|
nombre_evento: string
|
||||||
|
) => {
|
||||||
|
setLoadingStates((prev) => ({ ...prev, [id_cuestionario]: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await axiosInstance.get(
|
||||||
|
`/cuestionario-respondido/reporte-respuestas/${id_cuestionario}`,
|
||||||
|
{
|
||||||
|
responseType: 'blob',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
downloadFile(res.data, `${nombre_evento} - ${nombre_formulario}`, 'csv');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al descargar el archivo:', error);
|
||||||
|
toast.error('Error al descargar el archivo');
|
||||||
|
} finally {
|
||||||
|
setLoadingStates((prev) => ({ ...prev, [id_cuestionario]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-4">
|
||||||
|
<Breadcrumb
|
||||||
|
items={[
|
||||||
|
{ label: 'Eventos', href: '/user/eventos' },
|
||||||
|
{
|
||||||
|
label: `${evento.nombre_evento}`,
|
||||||
|
href: `/user/evento/${params.id_evento}`,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EditEvento
|
||||||
|
evento={evento}
|
||||||
|
handleChange={handleChange}
|
||||||
|
handleOnChange={handleEventoActualizado}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Carga masiva de alumnos o trabajadores */}
|
||||||
|
{tipo_usuario !== 'staff' && (
|
||||||
|
<div className="p-4 border rounded bg-light mb-4">
|
||||||
|
<h2 className="mb-1">Carga masiva de participantes</h2>
|
||||||
|
<p className="text-muted mb-3">
|
||||||
|
Sube un archivo <strong>.xlsx</strong> para registrar los
|
||||||
|
participantes que podrán inscribirse a este evento. Solo los
|
||||||
|
usuarios incluidos en la lista tendrán acceso al registro.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="d-flex gap-3 align-items-end flex-wrap">
|
||||||
|
<div>
|
||||||
|
<label className="form-label mb-1">Tipo de participante</label>
|
||||||
|
<div className="d-flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn ${tipoCarga === 'alumnos' ? 'btn-primary' : 'btn-outline-primary'}`}
|
||||||
|
onClick={() => setTipoCarga('alumnos')}
|
||||||
|
>
|
||||||
|
<i className="bi bi-mortarboard me-2"></i>Alumnos
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn ${tipoCarga === 'trabajadores' ? 'btn-primary' : 'btn-outline-primary'}`}
|
||||||
|
onClick={() => setTipoCarga('trabajadores')}
|
||||||
|
>
|
||||||
|
<i className="bi bi-person-badge me-2"></i>Trabajadores
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-grow-1">
|
||||||
|
<label className="form-label mb-1">Archivo xlsx</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx"
|
||||||
|
className="form-control"
|
||||||
|
onChange={(e) => setArchivoCarga(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
icon="upload"
|
||||||
|
variant="success"
|
||||||
|
onClick={handleCargaMasiva}
|
||||||
|
disabled={loadingCarga || !archivoCarga}
|
||||||
|
>
|
||||||
|
{loadingCarga ? 'Cargando...' : 'Cargar'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Validacion del tipo de usuario */}
|
||||||
|
{tipo_usuario !== 'staff' && (
|
||||||
|
<div className="p-4 border rounded bg-light">
|
||||||
|
<div className="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-0">Formularios del evento</h2>
|
||||||
|
<p className="mb-0 text-muted">
|
||||||
|
Aquí puedes ver y gestionar los formularios asociados a este
|
||||||
|
evento.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
icon="plus"
|
||||||
|
variant="primary"
|
||||||
|
onClick={() =>
|
||||||
|
router.push(
|
||||||
|
`/user/evento/${params.id_evento}/formularios/crear`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Crear nuevo formulario
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{evento?.cuestionarios && evento.cuestionarios.length > 0 && (
|
||||||
|
<div className="row mt-3">
|
||||||
|
{evento.cuestionarios.map((item, key) => {
|
||||||
|
const fadeClass = `delay-${(key % 5) + 1}`;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`col-md-6 col-lg-4 my-4 fade-in-up-bounce ${fadeClass}`}
|
||||||
|
key={key}
|
||||||
|
>
|
||||||
|
<FormularioCardAdmin
|
||||||
|
cuestionario={item}
|
||||||
|
evento={evento}
|
||||||
|
handleDownload={handleDownload}
|
||||||
|
disabled={loadingStates[item.id_cuestionario] || false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
'use client'
|
||||||
|
import axiosInstance from "@/utils/api-config";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { Button, Form, ProgressBar } from "react-bootstrap";
|
||||||
|
import { FaUpload } from "react-icons/fa";
|
||||||
|
|
||||||
|
export default function CargaMasiva() {
|
||||||
|
const [xlsx, setXlsx] = useState<File | null>(null);
|
||||||
|
const [progress, setProgress] = useState<number>(0);
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const validarExt = (file: File | null) => {
|
||||||
|
if (!file) return;
|
||||||
|
const extPermitidas = /(.xlsx)$/i;
|
||||||
|
if (!extPermitidas.exec(file.name)) {
|
||||||
|
console.error("Asegúrate de ingresar un archivo .xlsx");
|
||||||
|
setXlsx(null);
|
||||||
|
} else {
|
||||||
|
setXlsx(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0] || null;
|
||||||
|
validarExt(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const enviarCargaMasiva = async () => {
|
||||||
|
if (!xlsx) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", xlsx);
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setProgress(0);
|
||||||
|
|
||||||
|
const res = await axiosInstance.post(
|
||||||
|
"/cuestionario/carga-masiva",
|
||||||
|
formData,
|
||||||
|
{
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
onUploadProgress: (event) => {
|
||||||
|
if (event.total) {
|
||||||
|
const porcentaje = Math.round(
|
||||||
|
(event.loaded * 100) / event.total
|
||||||
|
);
|
||||||
|
setProgress(porcentaje);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
//Falta hacer validacion
|
||||||
|
|
||||||
|
console.log("Carga masiva exitosa", res.data);
|
||||||
|
setTimeout(() => {
|
||||||
|
setProgress(100);
|
||||||
|
setLoading(false);
|
||||||
|
}, 500);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Carga masiva fallida", error);
|
||||||
|
setLoading(false);
|
||||||
|
setProgress(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container-fluid my-4">
|
||||||
|
<div className="row mb-4">
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="p-4 border rounded bg-light">
|
||||||
|
<h2 className="mb-2">Carga Masiva de Eventos</h2>
|
||||||
|
<p className="mb-0 text-muted">
|
||||||
|
Sube tu archivo excel para poder crear los eventos.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Archivo Excel</Form.Label>
|
||||||
|
<div
|
||||||
|
className="border p-4 text-center rounded"
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() => document.getElementById("xlsxInput")?.click()}
|
||||||
|
>
|
||||||
|
<FaUpload size={40} className="mb-2" />
|
||||||
|
<p className="mb-1">
|
||||||
|
{xlsx?.name ||
|
||||||
|
"Arrastra aquí tu archivo o da click aquí para buscar"}
|
||||||
|
</p>
|
||||||
|
<p className="is-size-6">Tamaño máximo 20MB</p>
|
||||||
|
<p className="is-size-7">
|
||||||
|
Si al momento de elegir un archivo este no se selecciona,
|
||||||
|
haga click en cancelar en la ventana emergente e intente de
|
||||||
|
nuevo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="xlsxInput"
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<div className="d-flex gap-2 mt-3">
|
||||||
|
<Button
|
||||||
|
variant="outline-primary"
|
||||||
|
disabled={!xlsx || loading}
|
||||||
|
onClick={enviarCargaMasiva}
|
||||||
|
>
|
||||||
|
{loading ? "Subiendo..." : "Enviar archivo"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Barra de progreso */}
|
||||||
|
{loading && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<ProgressBar
|
||||||
|
now={progress}
|
||||||
|
label={`${progress}%`}
|
||||||
|
animated
|
||||||
|
striped
|
||||||
|
variant="info"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { Button, Image } from "react-bootstrap";
|
||||||
|
import { FaUpload } from "react-icons/fa";
|
||||||
|
import axiosInstance from "@/utils/api-config";
|
||||||
|
|
||||||
|
//Para poder subir las imagenes
|
||||||
|
interface UploadedImage {
|
||||||
|
originalName: string;
|
||||||
|
filename: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BannerUpload() {
|
||||||
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [uploaded, setUploaded] = useState<UploadedImage[]>([]);
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files) {
|
||||||
|
setFiles(Array.from(e.target.files));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = async () => {
|
||||||
|
if (files.length === 0) {
|
||||||
|
alert("Selecciona al menos un archivo");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
files.forEach((file) => {
|
||||||
|
formData.append("banners", file);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setProgress(0);
|
||||||
|
|
||||||
|
const res = await axiosInstance.post("/cuestionario/banners/bulk", formData, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
onUploadProgress: (progressEvent) => {
|
||||||
|
if (progressEvent.total) {
|
||||||
|
setProgress(
|
||||||
|
Math.round((progressEvent.loaded * 100) / progressEvent.total)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
//Falta hacer validacion
|
||||||
|
|
||||||
|
setUploaded(res.data);
|
||||||
|
alert("Banners subidos correctamente ✅");
|
||||||
|
setFiles([]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
alert(error || "Error al subir archivos");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container-fluid my-4">
|
||||||
|
<div className="row mb-4">
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="p-4 border rounded bg-light">
|
||||||
|
<h2 className="mb-2">Subir Banners</h2>
|
||||||
|
<p className="mb-0 text-muted">
|
||||||
|
Arrastra o selecciona imágenes para subir tus banners (jpg, png, webp).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Área Drag & Drop */}
|
||||||
|
<div
|
||||||
|
className="border p-4 text-center rounded"
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() => document.getElementById("bannerInput")?.click()}
|
||||||
|
>
|
||||||
|
<FaUpload size={40} className="mb-2 text-muted" />
|
||||||
|
<p className="mb-1">
|
||||||
|
Arrastra aquí tus imágenes o haz clic para buscar
|
||||||
|
</p>
|
||||||
|
<p className="is-size-6">Tamaño máximo 20MB</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
id="bannerInput"
|
||||||
|
type="file"
|
||||||
|
accept=".jpg,.jpeg,.png,.webp"
|
||||||
|
multiple
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Previsualización */}
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="d-flex flex-wrap gap-3 mt-4">
|
||||||
|
{files.map((file, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="border rounded overflow-hidden shadow-sm"
|
||||||
|
style={{ width: "350px", height: "350px" }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={URL.createObjectURL(file)}
|
||||||
|
alt={file.name}
|
||||||
|
className="img-fluid h-75 w-100 object-fit-cover"
|
||||||
|
/>
|
||||||
|
<p className="small text-center bg-light p-2 m-0">{file.name}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Botón */}
|
||||||
|
<div className="d-flex gap-2 mt-3">
|
||||||
|
<Button
|
||||||
|
variant="outline-primary"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={handleUpload}
|
||||||
|
>
|
||||||
|
{loading ? "Subiendo..." : "Subir Banners"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Barra de progreso */}
|
||||||
|
{loading && (
|
||||||
|
<div className="progress mt-3" style={{ height: "8px" }}>
|
||||||
|
<div
|
||||||
|
className="progress-bar progress-bar-striped bg-primary"
|
||||||
|
role="progressbar"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Archivos subidos */}
|
||||||
|
{uploaded.length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<h5 className="mb-2 text-muted">Archivos subidos:</h5>
|
||||||
|
<ul className="list-group">
|
||||||
|
{uploaded.map((file, i) => (
|
||||||
|
<li key={i} className="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<span>{file.originalName}</span>
|
||||||
|
<a href={`/${file.path}`} target="_blank" className="text-primary">
|
||||||
|
Ver
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { CreateEventoType } from '@/types/create-evento';
|
||||||
|
import CreateEvento from '@/containers/create-evento';
|
||||||
|
import EventoCardPreview from '@/components/evento/evento-card-preview';
|
||||||
|
import axiosInstance from '@/utils/api-config';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { getAxiosError } from '@/utils/errors-utils';
|
||||||
|
import Button from '@/components/button';
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const [eventoBanner, setEventoBanner] = useState<File | null>(null);
|
||||||
|
const [evento, setEvento] = useState<CreateEventoType>({
|
||||||
|
tipo_evento: '',
|
||||||
|
nombre_evento: '',
|
||||||
|
descripcion_evento: '',
|
||||||
|
fecha_inicio: new Date(),
|
||||||
|
fecha_fin: new Date(),
|
||||||
|
});
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleChange = (
|
||||||
|
field: keyof CreateEventoType,
|
||||||
|
value: string | Date
|
||||||
|
) => {
|
||||||
|
setEvento((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[field]: value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBannerChange = (file: File | null) => {
|
||||||
|
setEventoBanner(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCrearEvento = async () => {
|
||||||
|
try {
|
||||||
|
const createEvento = await axiosInstance.post('/evento', evento);
|
||||||
|
|
||||||
|
if (createEvento) {
|
||||||
|
const formData = new FormData();
|
||||||
|
if (eventoBanner) {
|
||||||
|
formData.append('banner', eventoBanner);
|
||||||
|
const bannercreated = await axiosInstance.post(
|
||||||
|
`/evento/${createEvento.data.id_evento}/banner`,
|
||||||
|
formData,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
console.log('Banner creado:', bannercreated.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success('Evento y formulario creados exitosamente');
|
||||||
|
router.push(
|
||||||
|
`/user/evento/${createEvento.data.id_evento}/formularios/crear`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const msg = getAxiosError(error);
|
||||||
|
toast.error(
|
||||||
|
`Error al crear el evento o formulario: ${
|
||||||
|
msg.message || 'Error desconocido'
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
console.error('Error al crear evento y formulario:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container-fluid my-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="row mb-4">
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="p-4 border rounded bg-light">
|
||||||
|
<h2 className="mb-2">Creación del Evento</h2>
|
||||||
|
<p className="mb-0 text-muted">
|
||||||
|
Completa la siguiente información para describir los detalles
|
||||||
|
generales del evento. Presiona siguiente para continuar con la
|
||||||
|
creación del formulario.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
{/* Columna Izquierda - Formulario */}
|
||||||
|
<div className="col-xl-7 col-lg-6">
|
||||||
|
<div className="pe-lg-4">
|
||||||
|
<CreateEvento
|
||||||
|
evento={evento}
|
||||||
|
handleChange={handleChange}
|
||||||
|
handleBannerChange={handleBannerChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Botón de guardar */}
|
||||||
|
<div className="d-flex justify-content-end">
|
||||||
|
<Button icon="arrow-right" onClick={handleCrearEvento}>
|
||||||
|
Siguiente
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Columna Derecha - Preview */}
|
||||||
|
<div className="col-xl-5 col-lg-6">
|
||||||
|
<div className="ps-lg-4">
|
||||||
|
<div className="sticky-top" style={{ top: '20px' }}>
|
||||||
|
<h4 className="mb-3 d-none d-lg-block">Preview del Evento</h4>
|
||||||
|
<div className="d-lg-none mt-4">
|
||||||
|
<h4 className="mb-3">Preview del Evento</h4>
|
||||||
|
</div>
|
||||||
|
<EventoCardPreview evento={evento} eventoBanner={eventoBanner} />
|
||||||
|
<div className="alert alert-info text-center">
|
||||||
|
<strong>Nota:</strong> Esta preview es la que veras en tu ruta
|
||||||
|
de eventos y en la ruta publica de eventos
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import EventoCardAdmin from '@/components/evento/evento-card-admin';
|
||||||
|
import FormularioCardAdmin from '@/components/formulario/formulario-card-admin';
|
||||||
|
import { useGetApi } from '@/hooks/use-get-api';
|
||||||
|
import { GetEventoWithCuestionariosWithCupos } from '@/types/evento';
|
||||||
|
import axiosInstance from '@/utils/api-config';
|
||||||
|
import { downloadFile } from '@/utils/downloas-utils';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const [loadingStates, setLoadingStates] = useState<Record<number, boolean>>(
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
loading,
|
||||||
|
data: activos,
|
||||||
|
error,
|
||||||
|
} = useGetApi<GetEventoWithCuestionariosWithCupos[]>(
|
||||||
|
'/evento/activos/cuestionarios'
|
||||||
|
);
|
||||||
|
const { data: recientes } = useGetApi<GetEventoWithCuestionariosWithCupos[]>(
|
||||||
|
'/evento/recientes/cuestionarios'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) return <div>Cargando formularios...</div>;
|
||||||
|
if (error) return <div className="alert alert-danger">{error.message}</div>;
|
||||||
|
|
||||||
|
const handleDownload = async (
|
||||||
|
id_cuestionario: number,
|
||||||
|
nombre_formulario: string,
|
||||||
|
nombre_evento: string
|
||||||
|
) => {
|
||||||
|
setLoadingStates((prev) => ({ ...prev, [id_cuestionario]: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await axiosInstance.get(
|
||||||
|
`/cuestionario-respondido/reporte-respuestas/${id_cuestionario}`,
|
||||||
|
{
|
||||||
|
responseType: 'blob',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
downloadFile(res.data, `${nombre_evento} - ${nombre_formulario}`, 'csv');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al descargar el archivo:', error);
|
||||||
|
toast.error('Error al descargar el archivo');
|
||||||
|
} finally {
|
||||||
|
setLoadingStates((prev) => ({ ...prev, [id_cuestionario]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-4">
|
||||||
|
{loading && <div>Cargando...</div>}
|
||||||
|
{activos && activos.length > 0 && (
|
||||||
|
<div className="row">
|
||||||
|
<h1>Eventos Activos</h1>
|
||||||
|
{activos.map((evento, key) => {
|
||||||
|
const fadeClass = `delay-${(key % 5) + 1}`;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`col-md-6 col-lg-4 my-4 fade-in-up-bounce ${fadeClass}`}
|
||||||
|
key={key}
|
||||||
|
>
|
||||||
|
<EventoCardAdmin evento={evento} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{recientes && recientes.length > 0 && (
|
||||||
|
<div className="row">
|
||||||
|
<h2>Formularios Recientes</h2>
|
||||||
|
<p className="text-muted">Eventos de los ultimos 30 días.</p>
|
||||||
|
{recientes.map((evento) =>
|
||||||
|
evento.cuestionarios.map((cuestionario, cuestionarioIndex) => {
|
||||||
|
const fadeClass = `delay-${(cuestionarioIndex % 5) + 1}`;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`col-md-6 col-lg-4 my-4 fade-in-up-bounce ${fadeClass}`}
|
||||||
|
key={`${evento.id_evento}-${cuestionario.id_cuestionario}`}
|
||||||
|
>
|
||||||
|
<FormularioCardAdmin
|
||||||
|
cuestionario={cuestionario}
|
||||||
|
evento={evento}
|
||||||
|
handleDownload={handleDownload}
|
||||||
|
disabled={
|
||||||
|
loadingStates[cuestionario.id_cuestionario] || false
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
'use client'
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import Footer from '@/components/layout/footer';
|
||||||
|
import Header from '@/components/layout/header';
|
||||||
|
const Navbar = dynamic(() => import('@/components/navbar'), { ssr: false });
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="d-flex flex-column min-vh-100">
|
||||||
|
<Header small />
|
||||||
|
<Navbar />
|
||||||
|
<main className="container flex-grow-1">{children}</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
'use client';
|
||||||
|
import SimpleInput from '@/components/input';
|
||||||
|
import axiosInstance from '@/utils/api-config';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
const [response, setResponse] = useState<{
|
||||||
|
insertados: number;
|
||||||
|
omitidos: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
|
setSelectedFile(e.target.files[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!selectedFile) return;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', selectedFile);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await axiosInstance.post('/trabajadores/cargar', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setResponse(res.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al subir el archivo:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Subir Excel de Profesores</h2>
|
||||||
|
<SimpleInput type="file" onChange={handleFileChange} />
|
||||||
|
<button onClick={handleSubmit} disabled={!selectedFile}>
|
||||||
|
Enviar
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{response && (
|
||||||
|
<div>
|
||||||
|
<h3>Resultado de la carga:</h3>
|
||||||
|
<p>Insertados: {response.insertados}</p>
|
||||||
|
<p>Omitidos: {response.omitidos}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { Scanner } from '@yudiel/react-qr-scanner';
|
||||||
|
import Button from '@/components/button';
|
||||||
|
import axiosInstance from '@/utils/api-config';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
const Modal = dynamic(() => import('@/components/modal'), { ssr: false });
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [enableScan, setEnableScan] = useState(true);
|
||||||
|
const [statusMessage, setStatusMessage] = useState('');
|
||||||
|
|
||||||
|
const [participante, setParticipante] = useState('');
|
||||||
|
const [contador, setContador] = useState(0);
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [token, setToken] = useState('');
|
||||||
|
const [tokenValid, setTokenValid] = useState(false);
|
||||||
|
|
||||||
|
const handleScan = async (rawValue: string) => {
|
||||||
|
console.log('QR Escaneado:', rawValue);
|
||||||
|
if (!enableScan) return;
|
||||||
|
|
||||||
|
if (!rawValue) {
|
||||||
|
setStatusMessage('⚠️ El QR no contiene los campos requeridos');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.post(
|
||||||
|
'/participante-evento/decode-token',
|
||||||
|
{ token: rawValue }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data) {
|
||||||
|
// Token válido
|
||||||
|
setToken(rawValue);
|
||||||
|
setTokenValid(true);
|
||||||
|
setShowModal(true);
|
||||||
|
setEnableScan(false);
|
||||||
|
setContador(response.data.contador);
|
||||||
|
} else {
|
||||||
|
// Token caducado o cualquier otro error
|
||||||
|
setStatusMessage(`❌ Error el código qr ya caduco`);
|
||||||
|
setTokenValid(false);
|
||||||
|
setShowModal(true); // mostramos modal con el mensaje de error
|
||||||
|
setEnableScan(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error al decodificar el QR:', err);
|
||||||
|
setStatusMessage('❌ Error al leer el QR');
|
||||||
|
setTokenValid(false);
|
||||||
|
setShowModal(true);
|
||||||
|
setEnableScan(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
if (!tokenValid) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.post(
|
||||||
|
`/participante-evento/asistencia`,
|
||||||
|
{
|
||||||
|
token: token,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
//Validacion
|
||||||
|
if (response.data.success === false) {
|
||||||
|
console.error('El usuario ya se registro');
|
||||||
|
setMessage(response.data.message);
|
||||||
|
toast.error('❌ Error el usaurio ya se registro previamente.');
|
||||||
|
} else {
|
||||||
|
setParticipante(response.data.data.participante);
|
||||||
|
setMessage(response.data.message);
|
||||||
|
console.log('Asistencia registrada:', response.data);
|
||||||
|
toast.success('✅ Asistencia registrada correctamente');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error en la petición:', err);
|
||||||
|
setStatusMessage('❌ Error de red al registrar asistencia');
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowModal(false);
|
||||||
|
setEnableScan(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container-fluid flex-grow-1 d-flex flex-column justify-content-center py-4">
|
||||||
|
<div className="row justify-content-center">
|
||||||
|
<div className="col-12 col-md-8 col-lg-6 col-xl-5">
|
||||||
|
<div className="card shadow border-0 rounded-3">
|
||||||
|
<div className="card-header bg-primary text-white text-center py-4 border-0">
|
||||||
|
<div className="mb-3">
|
||||||
|
<i className="bi bi-qr-code-scan display-4"></i>
|
||||||
|
</div>
|
||||||
|
<h2 className="mb-2 fw-bold">Registro de Asistencia</h2>
|
||||||
|
<p className="mb-0 opacity-75">
|
||||||
|
Escanea el código QR para registrar la asistencia
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body p-4">
|
||||||
|
{enableScan ? (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-4 mx-auto position-relative d-inline-block">
|
||||||
|
<div className="border border-3 border-primary rounded-3 p-3 bg-light">
|
||||||
|
<Scanner
|
||||||
|
onScan={(codes) => {
|
||||||
|
if (codes.length > 0) {
|
||||||
|
handleScan(codes[0].rawValue);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onError={(err) => console.error('QR Error:', err)}
|
||||||
|
constraints={{ facingMode: 'environment' }}
|
||||||
|
styles={{
|
||||||
|
container: {
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '300px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="alert alert-info border-0 rounded-3 d-flex align-items-center">
|
||||||
|
<i className="bi bi-info-circle-fill me-2"></i>
|
||||||
|
<small>
|
||||||
|
Mantén el código QR centrado en el marco de la cámara
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{participante && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="alert alert-success border-0 rounded-3 d-flex align-items-center">
|
||||||
|
<i className="bi bi-person-check-fill me-2"></i>
|
||||||
|
<span>
|
||||||
|
Ultimo participante registrado: {participante}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="alert alert-info border-0 rounded-3 d-flex align-items-center">
|
||||||
|
<i className="bi bi-info-circle-fill me-2"></i>
|
||||||
|
<span>{message}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => setEnableScan(false)}
|
||||||
|
variant="outline-danger"
|
||||||
|
className="px-4 py-2 rounded-pill"
|
||||||
|
>
|
||||||
|
<i className="bi bi-camera-video-off me-2"></i>
|
||||||
|
Detener cámara
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-5">
|
||||||
|
<div className="mb-4">
|
||||||
|
<i
|
||||||
|
className="bi bi-camera-video-off-fill text-muted"
|
||||||
|
style={{ fontSize: '4rem' }}
|
||||||
|
></i>
|
||||||
|
</div>
|
||||||
|
<h4 className="text-muted mb-3">Cámara desactivada</h4>
|
||||||
|
<p className="text-muted mb-4 lead">
|
||||||
|
La cámara está detenida. Actívala para continuar escaneando
|
||||||
|
códigos QR.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => setEnableScan(true)}
|
||||||
|
variant="primary"
|
||||||
|
className="px-4 py-2 rounded-pill"
|
||||||
|
>
|
||||||
|
<i className="bi bi-camera-video me-2"></i>
|
||||||
|
Activar cámara
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{statusMessage && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<div
|
||||||
|
className={`alert border-0 rounded-3 d-flex align-items-center ${
|
||||||
|
statusMessage.includes('❌')
|
||||||
|
? 'alert-danger'
|
||||||
|
: statusMessage.includes('⚠️')
|
||||||
|
? 'alert-warning'
|
||||||
|
: 'alert-success'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="me-2">
|
||||||
|
{statusMessage.includes('❌') && (
|
||||||
|
<i className="bi bi-exclamation-triangle-fill"></i>
|
||||||
|
)}
|
||||||
|
{statusMessage.includes('⚠️') && (
|
||||||
|
<i className="bi bi-exclamation-circle-fill"></i>
|
||||||
|
)}
|
||||||
|
{!statusMessage.includes('❌') &&
|
||||||
|
!statusMessage.includes('⚠️') && (
|
||||||
|
<i className="bi bi-check-circle-fill"></i>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span>{statusMessage}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isVisible={showModal}
|
||||||
|
size="md"
|
||||||
|
onClose={() => {
|
||||||
|
setShowModal(false);
|
||||||
|
setEnableScan(true);
|
||||||
|
setTokenValid(false);
|
||||||
|
}}
|
||||||
|
closeButton
|
||||||
|
className={{
|
||||||
|
content: 'border-0 rounded-3',
|
||||||
|
body: 'text-center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="modal-header bg-success text-white border-0 rounded-top-3">
|
||||||
|
<h5 className="modal-title mb-0 fw-bold">
|
||||||
|
<i className="bi bi-check-circle-fill me-2"></i>
|
||||||
|
QR Escaneado Correctamente
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-body p-4">
|
||||||
|
{tokenValid ? (
|
||||||
|
// Modal de confirmación de asistencia
|
||||||
|
<>
|
||||||
|
<p className="text-muted mb-3">
|
||||||
|
Contador del usuario: <strong>{contador}</strong>
|
||||||
|
</p>
|
||||||
|
<div className="d-grid gap-2 d-md-flex justify-content-center">
|
||||||
|
<Button
|
||||||
|
variant="success"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
className="px-4 py-2 rounded-pill fw-semibold"
|
||||||
|
>
|
||||||
|
<i className="bi bi-check2 me-2"></i>
|
||||||
|
Confirmar Asistencia
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline-secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setShowModal(false);
|
||||||
|
setEnableScan(true);
|
||||||
|
setTokenValid(false);
|
||||||
|
setToken('');
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 rounded-pill"
|
||||||
|
>
|
||||||
|
<i className="bi bi-x-lg me-2"></i>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// Modal de error
|
||||||
|
<div className="py-4">
|
||||||
|
<div className="mb-3">
|
||||||
|
<i
|
||||||
|
className="bi bi-exclamation-triangle-fill text-warning"
|
||||||
|
style={{ fontSize: '2.5rem' }}
|
||||||
|
></i>
|
||||||
|
</div>
|
||||||
|
<h6 className="text-danger mb-0">{statusMessage}</h6>
|
||||||
|
<div className="mt-3">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setShowModal(false);
|
||||||
|
setEnableScan(true);
|
||||||
|
setStatusMessage('');
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 rounded-pill"
|
||||||
|
>
|
||||||
|
Cerrar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
'use client';
|
||||||
|
import Button from '@/components/button';
|
||||||
|
import Table, { Header } from '@/components/table';
|
||||||
|
const Modal = dynamic(() => import('@/components/modal'), { ssr: false });
|
||||||
|
import { useGetApi } from '@/hooks/use-get-api';
|
||||||
|
import { Administrador } from '@/types/administradores';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import axiosInstance from '@/utils/api-config';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const { data, setData } = useGetApi<Administrador[]>('/administrador');
|
||||||
|
|
||||||
|
// Estado del modal
|
||||||
|
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||||
|
const [modalMode, setModalMode] = useState<'add' | 'edit'>('add');
|
||||||
|
const [selectedUser, setSelectedUser] = useState<Administrador | null>(null);
|
||||||
|
|
||||||
|
// Estado del modal de eliminación
|
||||||
|
const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
|
||||||
|
const [userToDelete, setUserToDelete] = useState<Administrador | null>(null);
|
||||||
|
|
||||||
|
// Estado del formulario
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
nombre_usuario: '',
|
||||||
|
correo: '',
|
||||||
|
password: '',
|
||||||
|
id_tipo_user: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handlers del modal
|
||||||
|
const handleOpenAddModal = () => {
|
||||||
|
setModalMode('add');
|
||||||
|
setSelectedUser(null);
|
||||||
|
setFormData({
|
||||||
|
nombre_usuario: '',
|
||||||
|
correo: '',
|
||||||
|
password: '',
|
||||||
|
id_tipo_user: '',
|
||||||
|
});
|
||||||
|
setIsModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEditModal = (user: Administrador) => {
|
||||||
|
setModalMode('edit');
|
||||||
|
setSelectedUser(user);
|
||||||
|
setFormData({
|
||||||
|
nombre_usuario: user.nombre_usuario,
|
||||||
|
correo: user.correo,
|
||||||
|
password: '',
|
||||||
|
id_tipo_user: user.tipoUser.id.toString(),
|
||||||
|
});
|
||||||
|
setIsModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setIsModalVisible(false);
|
||||||
|
setSelectedUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handlers del modal de eliminación
|
||||||
|
const handleOpenDeleteModal = (user: Administrador) => {
|
||||||
|
setUserToDelete(user);
|
||||||
|
setIsDeleteModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseDeleteModal = () => {
|
||||||
|
setIsDeleteModalVisible(false);
|
||||||
|
setUserToDelete(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDelete = async () => {
|
||||||
|
// Aquí iría la lógica para eliminar el usuario
|
||||||
|
try {
|
||||||
|
if (userToDelete) {
|
||||||
|
// Lógica para eliminar el usuario
|
||||||
|
const response = await axiosInstance.delete(
|
||||||
|
`/administrador/${userToDelete.id_administrador}`
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Usuario eliminado:', response.data);
|
||||||
|
// Actualizar la lista de usuarios en el estado
|
||||||
|
if (data) {
|
||||||
|
const updatedData = data.filter(
|
||||||
|
(user) => user.id_administrador !== userToDelete.id_administrador
|
||||||
|
);
|
||||||
|
setData(updatedData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al eliminar el usuario:', error);
|
||||||
|
}
|
||||||
|
handleCloseDeleteModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handler para envío del formulario
|
||||||
|
const handleSubmitForm = async () => {
|
||||||
|
try {
|
||||||
|
if (modalMode === 'add') {
|
||||||
|
// Crear nuevo usuario
|
||||||
|
const payload = {
|
||||||
|
nombre_usuario: formData.nombre_usuario,
|
||||||
|
correo: formData.correo,
|
||||||
|
password: formData.password,
|
||||||
|
id_tipo_user: parseInt(formData.id_tipo_user),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await axiosInstance.post('/administrador', payload);
|
||||||
|
console.log('Usuario creado:', response.data);
|
||||||
|
|
||||||
|
// Actualizar la lista de usuarios
|
||||||
|
if (data) {
|
||||||
|
setData([
|
||||||
|
...data,
|
||||||
|
{
|
||||||
|
correo: formData.correo,
|
||||||
|
id_administrador: response.data.id_administrador,
|
||||||
|
nombre_usuario: formData.nombre_usuario,
|
||||||
|
tipoUser: {
|
||||||
|
id: parseInt(formData.id_tipo_user),
|
||||||
|
tipo: formData.id_tipo_user === '1' ? 'Administrador' : 'Staff',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} else if (modalMode === 'edit' && selectedUser) {
|
||||||
|
// Editar usuario existente
|
||||||
|
const payload = {
|
||||||
|
nombre_usuario: formData.nombre_usuario,
|
||||||
|
correo: formData.correo,
|
||||||
|
id_tipo_user: parseInt(formData.id_tipo_user),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await axiosInstance.put(
|
||||||
|
`/administrador/${selectedUser.id_administrador}`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
console.log('Usuario actualizado:', response.data);
|
||||||
|
|
||||||
|
// Actualizar la lista de usuarios
|
||||||
|
if (data) {
|
||||||
|
const updatedData = data.map((user) =>
|
||||||
|
user.id_administrador === selectedUser.id_administrador
|
||||||
|
? { ...user, ...response.data }
|
||||||
|
: user
|
||||||
|
);
|
||||||
|
setData(updatedData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCloseModal();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al guardar el usuario:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const headers: Header<Administrador>[] = [
|
||||||
|
{ key: 'id_administrador', label: 'ID' },
|
||||||
|
{ key: 'nombre_usuario', label: 'Nombre de Usuario' },
|
||||||
|
{ key: 'correo', label: 'Correo' },
|
||||||
|
{
|
||||||
|
key: 'tipoUser',
|
||||||
|
render: (_, row) => row.tipoUser.tipo,
|
||||||
|
label: 'Tipo de Usuario',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tipoUser',
|
||||||
|
render: (_, row) => (
|
||||||
|
<div className="d-flex gap-2">
|
||||||
|
<Button className="w-100" onClick={() => handleOpenEditModal(row)}>
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
className="w-100"
|
||||||
|
onClick={() => handleOpenDeleteModal(row)}
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
label: 'Acciones',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="d-flex justify-content-between align-items-center my-3">
|
||||||
|
<h1>Administradores</h1>
|
||||||
|
<Button variant="primary" onClick={handleOpenAddModal}>
|
||||||
|
Agregar Administrador
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{data && <Table headers={headers} data={data} />}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isVisible={isModalVisible}
|
||||||
|
onClose={handleCloseModal}
|
||||||
|
size="lg"
|
||||||
|
closeButton={true}
|
||||||
|
>
|
||||||
|
<div className="p-3">
|
||||||
|
<h3 className="mb-4">
|
||||||
|
{modalMode === 'add'
|
||||||
|
? 'Agregar Nuevo Administrador'
|
||||||
|
: 'Editar Administrador'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{modalMode === 'edit' && selectedUser && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<p>
|
||||||
|
<strong>ID:</strong> {selectedUser.id_administrador}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Usuario actual:</strong> {selectedUser.nombre_usuario}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Correo actual:</strong> {selectedUser.correo}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Tipo:</strong> {selectedUser.tipoUser.tipo}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="nombre_usuario" className="form-label">
|
||||||
|
Nombre de Usuario
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
id="nombre_usuario"
|
||||||
|
value={formData.nombre_usuario}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
nombre_usuario: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="Ingrese el nombre de usuario"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="correo" className="form-label">
|
||||||
|
Correo Electrónico
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="form-control"
|
||||||
|
id="correo"
|
||||||
|
value={formData.correo}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
correo: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="Ingrese el correo electrónico"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{modalMode === 'add' && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="password" className="form-label">
|
||||||
|
Contraseña
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="form-control"
|
||||||
|
id="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
password: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="Ingrese la contraseña"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label htmlFor="tipoUser" className="form-label">
|
||||||
|
Tipo de Usuario
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
id="tipoUser"
|
||||||
|
value={formData.id_tipo_user}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
id_tipo_user: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">Seleccione un tipo</option>
|
||||||
|
<option value="1">Administrador</option>
|
||||||
|
<option value="2">Staff</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="d-flex gap-2 justify-content-end">
|
||||||
|
<Button variant="secondary" onClick={handleCloseModal}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={handleSubmitForm}>
|
||||||
|
{modalMode === 'add' ? 'Crear Administrador' : 'Guardar Cambios'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Modal de confirmación de eliminación */}
|
||||||
|
<Modal
|
||||||
|
isVisible={isDeleteModalVisible}
|
||||||
|
onClose={handleCloseDeleteModal}
|
||||||
|
size="md"
|
||||||
|
closeButton={true}
|
||||||
|
>
|
||||||
|
<div className="p-4 text-center">
|
||||||
|
<div className="mb-4">
|
||||||
|
<i className="bi bi-exclamation-triangle text-warning display-1"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 className="mb-3">¿Está seguro de eliminar al usuario?</h4>
|
||||||
|
|
||||||
|
{userToDelete && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="fs-5 fw-bold text-danger">
|
||||||
|
{userToDelete.nombre_usuario}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted">Esta acción no se puede deshacer</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="d-flex gap-3 justify-content-center">
|
||||||
|
<Button variant="secondary" onClick={handleCloseDeleteModal}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" onClick={handleConfirmDelete}>
|
||||||
|
Sí, Eliminar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return <div>Page</div>;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
'use client';
|
||||||
|
import SimpleInput from '@/components/input';
|
||||||
|
import PasswordInput from '@/components/password';
|
||||||
|
import React from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import axiosInstance from '@/utils/api-config';
|
||||||
|
|
||||||
|
export default function LoginForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [loginData, setLoginData] = React.useState({
|
||||||
|
nombre_usuario: '',
|
||||||
|
password: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [error, setError] = React.useState('');
|
||||||
|
const [isLoading, setIsLoading] = React.useState(false);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setLoginData((prevData) => ({ ...prevData, [name]: value }));
|
||||||
|
// Clear error when user starts typing
|
||||||
|
if (error) setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.post('/administrador/login', {
|
||||||
|
nombre_usuario: loginData.nombre_usuario,
|
||||||
|
password: loginData.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(response)
|
||||||
|
|
||||||
|
// Guardar el token en sessionStorage
|
||||||
|
if (response.data.token) {
|
||||||
|
sessionStorage.setItem('token', response.data.token);
|
||||||
|
|
||||||
|
// Opcional: guardar información adicional del usuario si viene en la respuesta
|
||||||
|
if (response.data.tipo_usuario) {
|
||||||
|
sessionStorage.setItem('axxxd', JSON.stringify(response.data.tipo_usuario));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redireccionar al dashboard
|
||||||
|
router.push('/user/eventos');
|
||||||
|
} else {
|
||||||
|
setError('No se recibió token de autenticación');
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('Error en login:', error);
|
||||||
|
|
||||||
|
// Type guard para axios error
|
||||||
|
if (error && typeof error === 'object' && 'response' in error) {
|
||||||
|
const axiosError = error as {
|
||||||
|
response?: { status?: number; data?: { message?: string } };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (axiosError.response?.status === 401) {
|
||||||
|
setError('Credenciales incorrectas');
|
||||||
|
} else if (axiosError.response?.data?.message) {
|
||||||
|
setError(axiosError.response.data.message);
|
||||||
|
} else {
|
||||||
|
setError('Error al iniciar sesión. Inténtalo de nuevo.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError('Error al iniciar sesión. Inténtalo de nuevo.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container my-5 my-md-0">
|
||||||
|
<div className="row justify-content-center">
|
||||||
|
<div className="col-xl-10">
|
||||||
|
<div className="card border-0">
|
||||||
|
<div className="card-body p-0">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-lg-6">
|
||||||
|
<div className="p-5">
|
||||||
|
<h3 className="fs-4 font-weight-bold text-azul mb-3 login">
|
||||||
|
Iniciar sesión
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<h6 className="h5 mb-0">
|
||||||
|
Bienvenido de vuelta al{' '}
|
||||||
|
<span className="text-dorado">Sistema de Eventos</span>
|
||||||
|
</h6>
|
||||||
|
<p className="text-muted mt-2 mb-4">
|
||||||
|
Ingresa tu correo electrónico y tu contraseña para acceder
|
||||||
|
a tu cuenta.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger mb-4">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<SimpleInput
|
||||||
|
label="Nombre de usuario"
|
||||||
|
type="text"
|
||||||
|
id="nombre_usuario"
|
||||||
|
name="nombre_usuario"
|
||||||
|
value={loginData.nombre_usuario}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<PasswordInput
|
||||||
|
label="Contraseña"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
value={loginData.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="justify-content-between align-items-center d-flex">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-azul"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="spinner-border spinner-border-sm me-2"
|
||||||
|
role="status"
|
||||||
|
aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
Ingresando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Ingresar'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-lg-6 d-none d-lg-inline-block">
|
||||||
|
<div className="position-relative h-100 p-2">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
backgroundImage: 'url(/assets/image.png)',
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
'use client';
|
|
||||||
import Button from '@/components/button';
|
|
||||||
import Input from '@/components/input';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const handleOnSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
// Aquí puedes agregar la lógica para manejar el inicio de sesión
|
|
||||||
// Por ejemplo, validar las credenciales y redirigir al usuario
|
|
||||||
router.push('/staff');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='d-flex flex-column justify-content-center align-items-center'>
|
|
||||||
<h1 className='text-dorado'>Iniciar sesión</h1>
|
|
||||||
<h2 className='text-azul'>Staff</h2>
|
|
||||||
|
|
||||||
<form className='w-300px' onSubmit={handleOnSubmit}>
|
|
||||||
<Input label='Usuario' />
|
|
||||||
<Input type='password' label='Contraseña' />
|
|
||||||
<div className='mb-3'>
|
|
||||||
<Button className='w-100' type='submit'>
|
|
||||||
Iniciar sesión
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// src/app/(public)/evento/[evento]/[cuestionario]/page.tsx
|
||||||
|
import FormularioPage from '@/containers/pages/formulario-page';
|
||||||
|
import { GetEventoWithCuestionario } from '@/types/evento';
|
||||||
|
import { fromParam } from '@/utils/slugify';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
type PageParams = {
|
||||||
|
evento: string; // "carrera-de-botargas-123"
|
||||||
|
cuestionario: string; // "formulario-inscripcion-456"
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
params: Promise<PageParams>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const { evento, cuestionario } = await params; // <- await
|
||||||
|
const { id: id_evento } = fromParam(evento);
|
||||||
|
const { id: id_cuestionario } = fromParam(cuestionario);
|
||||||
|
|
||||||
|
if (!id_evento || !id_cuestionario) {
|
||||||
|
return { title: 'Evento', description: 'Descripción del evento' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}/evento/${id_evento}/cuestionario/${id_cuestionario}`,
|
||||||
|
{ next: { revalidate: 60 } }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return { title: 'Evento', description: 'Descripción del evento' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: GetEventoWithCuestionario = await res.json();
|
||||||
|
const title = data?.nombre_evento || 'Evento';
|
||||||
|
const description = data?.descripcion_evento || 'Descripción del evento';
|
||||||
|
const banner = data?.banner
|
||||||
|
? `${process.env.NEXT_PUBLIC_API_URL}/banners/${data.banner}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
openGraph: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
images: banner
|
||||||
|
? [{ url: banner, width: 800, height: 400, alt: title }]
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Page({ params }: Props) {
|
||||||
|
const { evento, cuestionario } = await params; // <- await
|
||||||
|
const { id: id_evento } = fromParam(evento);
|
||||||
|
const { id: id_cuestionario } = fromParam(cuestionario);
|
||||||
|
|
||||||
|
if (!id_evento || !id_cuestionario) {
|
||||||
|
// import { notFound } from 'next/navigation';
|
||||||
|
// return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormularioPage
|
||||||
|
id_evento={String(id_evento)}
|
||||||
|
id_cuestionario={String(id_cuestionario)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,64 +1,110 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import ClientCarousel from '@/client-components/client-carousel';
|
import ClientCarousel from '@/client-components/client-carousel';
|
||||||
import FormularioCard from '@/components/formulario-card';
|
import FormularioCardUser from '@/components/formulario/formulario-card-user';
|
||||||
import { GetCuestionario } from '@/types/cuestionario';
|
import EmptyEventsState from '@/components/empty-events-state';
|
||||||
|
import { useGetApi } from '@/hooks/use-get-api';
|
||||||
|
import { GetEventoWithCuestionariosWithCupos } from '@/types/evento';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
async function getEventos(): Promise<GetCuestionario[]> {
|
export default function Page() {
|
||||||
try {
|
const { loading, data } = useGetApi<GetEventoWithCuestionariosWithCupos[]>(
|
||||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/cuestionario`, {
|
'/evento/activos/cuestionarios'
|
||||||
method: 'GET',
|
);
|
||||||
cache: 'no-store', // para que no lo cachee ni en build ni entre requests
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`Error ${res.status}: ${res.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: GetCuestionario[] = await res.json();
|
|
||||||
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error en getEventos:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function Page() {
|
|
||||||
let eventos;
|
|
||||||
|
|
||||||
try {
|
|
||||||
eventos = await getEventos();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error al cargar los eventos:', error);
|
|
||||||
return <div>Error al cargar los eventos</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div>
|
||||||
<div className="mx-auto my-5">
|
{data && data.length > 0 && (
|
||||||
<ClientCarousel />
|
<div className="mx-auto mb-5 mt-3 fade-in-down-bounce">
|
||||||
</div>
|
<ClientCarousel
|
||||||
<div className="cards-1 mt-5">
|
images={
|
||||||
<div className="container">
|
data && data.length > 0
|
||||||
<div className="row">
|
? data.map((evento) => ({
|
||||||
{eventos.map((cuestionario) => (
|
src: `${process.env.NEXT_PUBLIC_API_URL}/banners/${evento.banner}`,
|
||||||
<FormularioCard
|
alt: `Banner de ${evento.nombre_evento}`,
|
||||||
key={cuestionario.id_cuestionario}
|
}))
|
||||||
cuestionario={cuestionario}
|
: [
|
||||||
link={`/registro/${cuestionario.id_cuestionario}`}
|
{
|
||||||
|
src: '/default-banner.png',
|
||||||
|
alt: 'Banner de eventos activos',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
</div>
|
||||||
{eventos.length === 0 && (
|
)}
|
||||||
<p className="text-center text-muted">
|
|
||||||
No hay formularios disponibles.
|
{loading && (
|
||||||
</p>
|
<div className="text-center py-5 fade-in-scale">
|
||||||
|
<div className="d-flex justify-content-center align-items-center mb-3">
|
||||||
|
<div
|
||||||
|
className="spinner-border text-primary me-3"
|
||||||
|
role="status"
|
||||||
|
style={{ width: '2rem', height: '2rem' }}
|
||||||
|
>
|
||||||
|
<span className="visually-hidden">Cargando...</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 className="mb-0 text-primary">Cargando eventos...</h5>
|
||||||
|
<small className="text-muted">
|
||||||
|
Esto solo tomará unos segundos
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && data && data.length === 0 && (
|
||||||
|
<div className="flex-grow-1 d-flex flex-column justify-content-center align-items-center">
|
||||||
|
<EmptyEventsState />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/*
|
||||||
|
{!loading && data && data.length > 0 && (
|
||||||
|
<div className="row">
|
||||||
|
{data.flatMap((evento) =>
|
||||||
|
evento.cuestionarios
|
||||||
|
// Filtramos solo los cuestionarios con id_cuestionario >= 148
|
||||||
|
.filter((cuestionario) => !cuestionario.id_cuestionario > 10 && !cuestionario.id_cuestionario < 148)
|
||||||
|
// Luego mapeamos para renderizarlos
|
||||||
|
.map((cuestionario, index) => (
|
||||||
|
<div
|
||||||
|
className={`col-md-6 col-lg-4 my-3 fade-in-up-bounce delay-${(index % 5) + 1}`}
|
||||||
|
key={`${evento.id_evento}-${cuestionario.id_cuestionario}`}
|
||||||
|
>
|
||||||
|
<FormularioCardUser
|
||||||
|
evento={evento}
|
||||||
|
formulario={cuestionario}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
*/}
|
||||||
|
|
||||||
|
{!loading && data && data.length > 0 && (
|
||||||
|
<div className="row">
|
||||||
|
{data.flatMap((evento) =>
|
||||||
|
evento.cuestionarios.map((cuestionario, index) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`col-md-6 col-lg-4 my-3 fade-in-up-bounce delay-${
|
||||||
|
(index % 5) + 1
|
||||||
|
}`}
|
||||||
|
key={`${evento.id_evento}-${cuestionario.id_cuestionario}`}
|
||||||
|
>
|
||||||
|
<FormularioCardUser
|
||||||
|
evento={evento}
|
||||||
|
formulario={cuestionario}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,270 +0,0 @@
|
|||||||
'use client';
|
|
||||||
import Button from '@/components/button';
|
|
||||||
import CheckboxOptionGroup from '@/components/checkbox-option-group';
|
|
||||||
import Input from '@/components/input';
|
|
||||||
import RadioOptionGroup from '@/components/radio-option-group';
|
|
||||||
import { CuestionarioResponse } from '@/types/responder-formulario';
|
|
||||||
import axiosInstance from '@/utils/api-config';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import toast from 'react-hot-toast';
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
const router = useRouter();
|
|
||||||
const params = useParams<{ id_formulario: string }>();
|
|
||||||
const id_formulario = params?.id_formulario;
|
|
||||||
const [cuestionario, setCuestionario] = useState<CuestionarioResponse | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [respuestas, setRespuestas] = useState<
|
|
||||||
Record<number, string | number | Array<string | number>>
|
|
||||||
>({});
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false); // New state for submission
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchData = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const { data } = await axiosInstance.get<CuestionarioResponse>(
|
|
||||||
`/cuestionario/${id_formulario}/formulario`
|
|
||||||
);
|
|
||||||
if (data) {
|
|
||||||
console.log(data);
|
|
||||||
setCuestionario(data);
|
|
||||||
} else {
|
|
||||||
setError('Error fetching data');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching data:', error);
|
|
||||||
setError('Error de conexión');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (id_formulario) fetchData();
|
|
||||||
}, [id_formulario]);
|
|
||||||
|
|
||||||
const handleInputChange = (id: number, value: string) => {
|
|
||||||
setRespuestas((prev) => ({ ...prev, [id]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRadioChange = (id: number, opcion: { value: number }) => {
|
|
||||||
setRespuestas((prev) => ({ ...prev, [id]: opcion.value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCheckboxChange = (id: number, opcion: { value: number }) => {
|
|
||||||
setRespuestas((prev) => {
|
|
||||||
const actuales: number[] = Array.isArray(prev[id])
|
|
||||||
? (prev[id] as number[])
|
|
||||||
: [];
|
|
||||||
const existe = actuales.includes(opcion.value);
|
|
||||||
const nuevos = existe
|
|
||||||
? actuales.filter((v: number) => v !== opcion.value)
|
|
||||||
: [...actuales, opcion.value];
|
|
||||||
return { ...prev, [id]: nuevos };
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatearRespuestas = async () => {
|
|
||||||
if (!validarRespuestasObligatorias()) return;
|
|
||||||
|
|
||||||
const respuestasArray = Object.entries(respuestas).flatMap(
|
|
||||||
([id, valor]) => {
|
|
||||||
if (Array.isArray(valor)) {
|
|
||||||
return valor.map((v) => ({
|
|
||||||
id_pregunta: Number(id),
|
|
||||||
valor: v,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return [{ id_pregunta: Number(id), valor }];
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const correoEntry = respuestasArray.find(
|
|
||||||
(r) =>
|
|
||||||
typeof r.valor === 'string' &&
|
|
||||||
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(r.valor)
|
|
||||||
);
|
|
||||||
|
|
||||||
const resultado = {
|
|
||||||
id_formulario: Number(id_formulario),
|
|
||||||
correo: correoEntry?.valor || 'correo@no-encontrado.com',
|
|
||||||
respuestas: respuestasArray,
|
|
||||||
fecha_envio: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
setIsSubmitting(true); // Set submitting state to true
|
|
||||||
toast
|
|
||||||
.promise(
|
|
||||||
axiosInstance.post('/cuestionario-respondido/submit', resultado),
|
|
||||||
{
|
|
||||||
loading: 'Enviando formulario...',
|
|
||||||
success: 'Formulario enviado con éxito!',
|
|
||||||
error: 'Error al enviar el formulario.',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.then(() => {
|
|
||||||
router.push('/');
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Error al enviar el formulario:', error);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setIsSubmitting(false); // Reset submitting state
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const validarRespuestasObligatorias = (): boolean => {
|
|
||||||
const faltantes: number[] = [];
|
|
||||||
|
|
||||||
cuestionario?.cuestionario.secciones.forEach((seccion) => {
|
|
||||||
seccion.preguntas.forEach(({ pregunta }) => {
|
|
||||||
if (pregunta.obligatoria) {
|
|
||||||
const valor = respuestas[pregunta.id_pregunta];
|
|
||||||
const tipo = pregunta.tipo_pregunta.tipo_pregunta;
|
|
||||||
|
|
||||||
const estaRespondida =
|
|
||||||
(tipo === 'Abierta' &&
|
|
||||||
typeof valor === 'string' &&
|
|
||||||
valor.trim() !== '') ||
|
|
||||||
(tipo === 'Cerrada' && valor !== undefined) ||
|
|
||||||
(tipo === 'Multiple' && Array.isArray(valor) && valor.length > 0);
|
|
||||||
|
|
||||||
if (!estaRespondida) {
|
|
||||||
faltantes.push(pregunta.id_pregunta);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (faltantes.length > 0) {
|
|
||||||
alert(`Responde todas las preguntas obligatorias antes de enviar.`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) return <p>Cargando...</p>;
|
|
||||||
if (error) return <p className="text-danger">{error}</p>;
|
|
||||||
if (!cuestionario) return <p>No hay datos</p>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container py-4">
|
|
||||||
<Button icon="arrow-left" onClick={() => router.back()}>
|
|
||||||
Volver
|
|
||||||
</Button>
|
|
||||||
<div className="text-center mb-4">
|
|
||||||
<Image
|
|
||||||
src={'/banner1.png'}
|
|
||||||
width={1000}
|
|
||||||
height={300}
|
|
||||||
alt="Ejemplo de banner"
|
|
||||||
className="rounded-4 shadow-sm img-fluid"
|
|
||||||
style={{
|
|
||||||
objectFit: 'cover',
|
|
||||||
objectPosition: 'center',
|
|
||||||
height: 300,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="px-lg-5 mx-lg-5">
|
|
||||||
<h2>{cuestionario.cuestionario.nombre_form}</h2>
|
|
||||||
<p>{cuestionario.cuestionario.descripcion}</p>
|
|
||||||
|
|
||||||
{cuestionario.cuestionario.secciones.map((seccion, index) => (
|
|
||||||
<section key={index} className="mb-4">
|
|
||||||
<h4>{seccion.seccion.titulo}</h4>
|
|
||||||
<p>{seccion.seccion.descripcion}</p>
|
|
||||||
|
|
||||||
{seccion.preguntas.map((p) => {
|
|
||||||
const pregunta = p.pregunta;
|
|
||||||
const id = pregunta.id_pregunta;
|
|
||||||
const tipo = pregunta.tipo_pregunta.tipo_pregunta;
|
|
||||||
|
|
||||||
if (tipo === 'Abierta') {
|
|
||||||
return (
|
|
||||||
<Input
|
|
||||||
key={id}
|
|
||||||
label={pregunta.pregunta}
|
|
||||||
name={`pregunta_${id}`}
|
|
||||||
value={
|
|
||||||
Array.isArray(respuestas[id])
|
|
||||||
? respuestas[id].join(', ')
|
|
||||||
: respuestas[id] || ''
|
|
||||||
}
|
|
||||||
onChange={(e) => handleInputChange(id, e.target.value)}
|
|
||||||
required={pregunta.obligatoria}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tipo === 'Cerrada' || tipo === 'Multiple') {
|
|
||||||
const opciones = (pregunta.opciones ?? []).map((o) => ({
|
|
||||||
label: o.opcion.opcion,
|
|
||||||
value: o.opcion.id_opcion,
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (tipo === 'Multiple') {
|
|
||||||
return (
|
|
||||||
<div key={id} className="mb-3">
|
|
||||||
<p>{pregunta.pregunta}</p>
|
|
||||||
<CheckboxOptionGroup
|
|
||||||
name={`pregunta_${id}`}
|
|
||||||
options={opciones}
|
|
||||||
selectedValues={
|
|
||||||
Array.isArray(respuestas[id]) ? respuestas[id] : []
|
|
||||||
}
|
|
||||||
onChange={(opcion) => {
|
|
||||||
if (typeof opcion.value === 'number') {
|
|
||||||
handleCheckboxChange(id, { value: opcion.value });
|
|
||||||
} else {
|
|
||||||
console.error('Invalid value type:', opcion.value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={id} className="mb-3">
|
|
||||||
<p>{pregunta.pregunta}</p>
|
|
||||||
<RadioOptionGroup
|
|
||||||
name={`pregunta_${id}`}
|
|
||||||
options={opciones}
|
|
||||||
selectedValue={respuestas[id]}
|
|
||||||
onChange={(opcion) => {
|
|
||||||
if (typeof opcion.value === 'number') {
|
|
||||||
handleRadioChange(id, { value: opcion.value });
|
|
||||||
} else {
|
|
||||||
console.error('Invalid value type:', opcion.value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-primary"
|
|
||||||
onClick={formatearRespuestas}
|
|
||||||
disabled={isSubmitting} // Disable button while submitting
|
|
||||||
>
|
|
||||||
{isSubmitting ? 'Enviando...' : 'Enviar'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -12,13 +12,32 @@ const roboto = Roboto({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Sistema de formularios",
|
title: "Registros - Eventos FES Acatlán",
|
||||||
description: "",
|
description: "Plataforma para consultar y registrarse a eventos organizados dentro de la FES Acatlán. Completa un formulario para confirmar tu asistencia.",
|
||||||
icons: {
|
icons: {
|
||||||
icon: "/unam.png",
|
icon: "/unam.png",
|
||||||
},
|
},
|
||||||
|
keywords: ["eventos", "FES Acatlán", "UNAM", "registro", "formulario", "asistencia", "registros"],
|
||||||
|
authors: [{ name: "FES Acatlán" }],
|
||||||
|
creator: "FES Acatlán",
|
||||||
|
metadataBase: new URL("https://registro.acatlan.unam.mx/"), // cámbialo por el dominio real
|
||||||
|
openGraph: {
|
||||||
|
title: "Registros - Eventos FES Acatlán",
|
||||||
|
description: "Consulta los eventos disponibles en la FES Acatlán y regístrate para asistir.",
|
||||||
|
url: "https://registro.acatlan.unam.mx/",
|
||||||
|
siteName: "Registros FES Acatlán",
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: "/ogimage.png",
|
||||||
|
alt: "Logo UNAM",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
locale: "es_MX",
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import Footer from '@/components/layout/footer';
|
||||||
|
import Header from '@/components/layout/header';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<div className='d-flex flex-column min-vh-100'>
|
||||||
|
<Header small />
|
||||||
|
<div className="flex-grow-1 d-flex flex-column align-items-center justify-content-center">
|
||||||
|
<Image src={'/404.png'} width={350} height={350} alt="404" />
|
||||||
|
<p>
|
||||||
|
La página que buscas no existe. Por favor, verifica la URL o regresa
|
||||||
|
al <Link href="/">inicio</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
import { CarouselProps } from '@/components/carousel';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
const Carousel = dynamic(() => import('@/components/carousel'), {
|
const Carousel = dynamic(() => import('@/components/carousel'), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function ClientCarousel() {
|
export default function ClientCarousel(props: CarouselProps) {
|
||||||
return <Carousel />;
|
return <Carousel {...props} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Image from 'next/image';
|
||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import Button from './button';
|
||||||
|
|
||||||
|
interface BannerUploaderProps {
|
||||||
|
label?: string;
|
||||||
|
onChange?: (file: File | null) => void;
|
||||||
|
defaultBanner?: string;
|
||||||
|
className?: string;
|
||||||
|
showPreview?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ImageUploader({
|
||||||
|
label = 'Subir imagen',
|
||||||
|
onChange,
|
||||||
|
defaultBanner,
|
||||||
|
className = '',
|
||||||
|
showPreview = true,
|
||||||
|
}: BannerUploaderProps) {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [preview, setPreview] = useState<string | null>(defaultBanner || null);
|
||||||
|
|
||||||
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0] || null;
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
setPreview(reader.result as string);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
} else {
|
||||||
|
setPreview(null);
|
||||||
|
}
|
||||||
|
onChange?.(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveImage = () => {
|
||||||
|
setPreview(null);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
onChange?.(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{label && <label className="block mb-2 font-semibold">{label}</label>}
|
||||||
|
<div className="d-flex gap-2 align-items-start mb-2">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="form-control"
|
||||||
|
/>
|
||||||
|
{preview && (
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={handleRemoveImage}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
>
|
||||||
|
<i className="bi bi-trash"></i>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{preview && showPreview && (
|
||||||
|
<div className="d-flex justify-content-center">
|
||||||
|
<Image
|
||||||
|
src={preview}
|
||||||
|
alt="Previsualización"
|
||||||
|
className="img-fluid rounded"
|
||||||
|
width={600}
|
||||||
|
height={300}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface BreadcrumbItem {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BreadcrumbProps {
|
||||||
|
items: BreadcrumbItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Breadcrumb({ items }: BreadcrumbProps) {
|
||||||
|
return (
|
||||||
|
<nav aria-label="breadcrumb" className="mb-3">
|
||||||
|
<ol className="breadcrumb">
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const isLast = index === items.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={index}
|
||||||
|
className={`breadcrumb-item ${isLast ? 'active' : ''}`}
|
||||||
|
aria-current={isLast ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
{isLast ? (
|
||||||
|
item.label
|
||||||
|
) : (
|
||||||
|
<Link href={item.href || '#'} className="text-decoration-none">
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
interface CarouselProps {
|
export interface CarouselProps {
|
||||||
id?: string;
|
id?: string;
|
||||||
images?: {
|
images?: {
|
||||||
src: string;
|
src: string;
|
||||||
@@ -10,20 +10,11 @@ interface CarouselProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Carousel({
|
export default function Carousel({
|
||||||
id = 'carouselExample',
|
id = 'carousel-banners-eventos',
|
||||||
images = [
|
images,
|
||||||
{
|
|
||||||
src: '/banner1.png',
|
|
||||||
alt: 'Imagen de ejemplo',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: '/banner2.png',
|
|
||||||
alt: 'Imagen de ejemplo',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}: CarouselProps) {
|
}: CarouselProps) {
|
||||||
if (!images || images.length === 0) {
|
if (!images || images.length === 0) {
|
||||||
return <div>No hay imágenes para mostrar</div>;
|
return <div></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -35,14 +26,16 @@ export default function Carousel({
|
|||||||
className={`carousel-item ${index === 0 ? 'active' : ''}`}
|
className={`carousel-item ${index === 0 ? 'active' : ''}`}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={img.src}
|
src={img.src ? img.src : '/default-banner.png'}
|
||||||
width={1000}
|
width={1200}
|
||||||
height={400}
|
height={400}
|
||||||
alt="Ejemplo de banner"
|
alt="Ejemplo de banner"
|
||||||
className="d-block w-100 rounded-3"
|
className="d-block w-100 rounded-3 img-fluid"
|
||||||
style={{
|
style={{
|
||||||
|
height: '400px',
|
||||||
objectFit: 'cover',
|
objectFit: 'cover',
|
||||||
objectPosition: 'center',
|
objectPosition: 'center',
|
||||||
|
maxHeight: '400px',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
'use client';
|
||||||
|
import { GetEventoWithCuestionariosWithCupos } from '@/types/evento';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import MarkdownRenderer from './markdown-render';
|
||||||
|
|
||||||
|
export default function CuestionarioCard({
|
||||||
|
evento,
|
||||||
|
user = 'public',
|
||||||
|
}: {
|
||||||
|
evento: GetEventoWithCuestionariosWithCupos;
|
||||||
|
user?: 'public' | 'administrador' | 'staff';
|
||||||
|
}) {
|
||||||
|
const [verMas, setVerMas] = useState(false);
|
||||||
|
const descripcion = evento.descripcion_evento ?? '';
|
||||||
|
const limite = 100;
|
||||||
|
|
||||||
|
const descripcionRecortada =
|
||||||
|
descripcion.length > limite && !verMas
|
||||||
|
? `${descripcion.slice(0, limite)}...`
|
||||||
|
: descripcion;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card card-blog d-flex flex-column justify-content-between">
|
||||||
|
<div>
|
||||||
|
<div className="card-image scale-hover-1">
|
||||||
|
<Link
|
||||||
|
href={
|
||||||
|
user === 'administrador' || user === 'staff'
|
||||||
|
? `/${user}/evento/${evento.id_evento}`
|
||||||
|
: `/evento/${evento.nombre_evento.split(' ').join('_')}/${
|
||||||
|
evento.id_evento
|
||||||
|
}/${evento.cuestionarios[0].id_cuestionario}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
width={400}
|
||||||
|
height={300}
|
||||||
|
className="img-fluid"
|
||||||
|
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${evento.banner}`}
|
||||||
|
alt="Banner formulario"
|
||||||
|
/>
|
||||||
|
<div className="card-caption">{evento.nombre_evento}</div>
|
||||||
|
</Link>
|
||||||
|
<div className="ripple-cont"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="card-description mb-0">
|
||||||
|
{evento.cuestionarios[0]?.cupo_maximo === null ? (
|
||||||
|
<span className="badge bg-success mb-2">Sin límite</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge bg-primary mb-2">
|
||||||
|
{evento.cuestionarios[0]?.cupos_disponibles} cupos disponibles
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<MarkdownRenderer markdown={descripcionRecortada} />
|
||||||
|
{descripcion.length > limite && (
|
||||||
|
<button
|
||||||
|
onClick={() => setVerMas(!verMas)}
|
||||||
|
className="btn btn-link btn-sm p-0 ms-1 align-baseline"
|
||||||
|
style={{ fontSize: '0.875rem' }}
|
||||||
|
>
|
||||||
|
{verMas ? 'Ver menos' : 'Ver más'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{evento.cuestionarios.length > 0 &&
|
||||||
|
evento.cuestionarios.map((cuestionario) => (
|
||||||
|
<div key={cuestionario.id_cuestionario} className="">
|
||||||
|
{user === 'administrador' || user === 'staff' ? (
|
||||||
|
<Link
|
||||||
|
href={`/${user}/evento/${evento.id_evento}`}
|
||||||
|
className="btn mt-2 w-100 btn-outline-primary"
|
||||||
|
>
|
||||||
|
Ver evento
|
||||||
|
</Link>
|
||||||
|
) : cuestionario.cupos_disponibles === 0 &&
|
||||||
|
cuestionario.cupo_maximo !== null ? (
|
||||||
|
<button
|
||||||
|
className="btn mt-2 w-100 btn-outline-secondary"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
Cupo lleno
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href={`/evento/${evento.nombre_evento
|
||||||
|
.split(' ')
|
||||||
|
.join('_')}/${evento.id_evento}/${
|
||||||
|
cuestionario.id_cuestionario
|
||||||
|
}`}
|
||||||
|
className="btn mt-2 w-100 btn-outline-primary"
|
||||||
|
>
|
||||||
|
Registro
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import Button from './button';
|
||||||
|
|
||||||
|
export default function EmptyEventsState() {
|
||||||
|
return (
|
||||||
|
<div className="container py-5">
|
||||||
|
<div className="row justify-content-center">
|
||||||
|
<div className="col-lg-8">
|
||||||
|
<div className="text-center fade-in-scale">
|
||||||
|
{/* Ilustración principal con Bootstrap */}
|
||||||
|
<div className="mb-2">
|
||||||
|
<div
|
||||||
|
className="d-inline-flex align-items-center justify-content-center bg-dorado bg-opacity-10 rounded-circle mb-4"
|
||||||
|
style={{ width: '150px', height: '150px' }}
|
||||||
|
>
|
||||||
|
<i className="bi bi-calendar-x text-dorado display-1"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Título y mensaje principal */}
|
||||||
|
<div className="mb-5">
|
||||||
|
<h1 className="display-5 fw-bold text-azul mb-3 fade-in-up delay-1">
|
||||||
|
No hay eventos disponibles
|
||||||
|
</h1>
|
||||||
|
<p className="lead text-muted mb-0 fade-in-up delay-2">
|
||||||
|
En este momento no tenemos eventos activos para mostrar.
|
||||||
|
<br className="d-none d-md-block" />
|
||||||
|
<span className="text-azul fw-semibold">
|
||||||
|
¡No te preocupes!
|
||||||
|
</span>{' '}
|
||||||
|
Pronto habrá nuevas oportunidades de participación.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Botón de acción usando clases Bootstrap */}
|
||||||
|
<div className="fade-in-up delay-4">
|
||||||
|
<Button
|
||||||
|
icon="bi bi-arrow-clockwise"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
size="lg"
|
||||||
|
className="rounded-pill px-5"
|
||||||
|
>
|
||||||
|
Actualizar pagina
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mensaje adicional */}
|
||||||
|
<div className="mt-4 fade-in-up delay-5">
|
||||||
|
<small className="text-muted">
|
||||||
|
<i className="bi bi-info-circle me-1"></i>
|
||||||
|
Esta página se actualiza automáticamente para mostrar los
|
||||||
|
eventos más recientes
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { GetEventoWithCuestionariosWithCupos } from '@/types/evento';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import MarkdownRenderer from './markdown-render';
|
||||||
|
import { formatearRangoFechas } from '@/utils/date-utils';
|
||||||
|
|
||||||
|
export default function EventoCard({
|
||||||
|
evento,
|
||||||
|
user = 'public',
|
||||||
|
}: {
|
||||||
|
evento: GetEventoWithCuestionariosWithCupos;
|
||||||
|
user?: 'public' | 'administrador' | 'staff';
|
||||||
|
}) {
|
||||||
|
const [verMas, setVerMas] = useState(false);
|
||||||
|
const descripcion = evento.descripcion_evento ?? '';
|
||||||
|
const limite = 100;
|
||||||
|
|
||||||
|
const descripcionRecortada =
|
||||||
|
descripcion.length > limite && !verMas
|
||||||
|
? `${descripcion.slice(0, limite)}...`
|
||||||
|
: descripcion;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card card-blog d-flex flex-column justify-content-between">
|
||||||
|
<div>
|
||||||
|
<div className="card-image scale-hover-1">
|
||||||
|
<Link
|
||||||
|
href={
|
||||||
|
user === 'administrador' || user === 'staff'
|
||||||
|
? `/${user}/evento/${evento.id_evento}`
|
||||||
|
: `/evento/${evento.nombre_evento.split(' ').join('_')}/${
|
||||||
|
evento.id_evento
|
||||||
|
}/${evento.cuestionarios[0].id_cuestionario}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
width={400}
|
||||||
|
height={300}
|
||||||
|
className="img-fluid"
|
||||||
|
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${evento.banner}`}
|
||||||
|
alt="Banner formulario"
|
||||||
|
/>
|
||||||
|
<div className="card-caption">{evento.nombre_evento}</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="card-description mb-0">
|
||||||
|
<MarkdownRenderer markdown={descripcionRecortada} />
|
||||||
|
{descripcion.length > limite && (
|
||||||
|
<button
|
||||||
|
onClick={() => setVerMas(!verMas)}
|
||||||
|
className="btn btn-link btn-sm p-0 ms-1 align-baseline"
|
||||||
|
style={{ fontSize: '0.875rem' }}
|
||||||
|
>
|
||||||
|
{verMas ? 'Ver menos' : 'Ver más'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{evento.cuestionarios.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<h2 className="my-2 fs-3">Formularios</h2>
|
||||||
|
<table className="table table-white">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Nombre</th>
|
||||||
|
<th scope="col">Inscritos</th>
|
||||||
|
<th scope="col">Capacidad total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{evento.cuestionarios.map((cuestionario) => (
|
||||||
|
<tr key={cuestionario.id_cuestionario}>
|
||||||
|
<td>
|
||||||
|
<Link
|
||||||
|
href={
|
||||||
|
user === 'administrador' || user === 'staff'
|
||||||
|
? `/${user}/evento/${evento.id_evento}/formulario/${cuestionario.id_cuestionario}`
|
||||||
|
: `/evento/${evento.nombre_evento
|
||||||
|
.split(' ')
|
||||||
|
.join('_')}/${evento.id_evento}/${
|
||||||
|
cuestionario.id_cuestionario
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{cuestionario.nombre_form}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
{cuestionario.cupo_maximo ? (
|
||||||
|
<>
|
||||||
|
<td>{cuestionario.cupos_usados}</td>
|
||||||
|
<td>{cuestionario.cupo_maximo}</td>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<td colSpan={2} className="text-center">
|
||||||
|
Sin límite
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center px-4 py-3 border rounded bg-light my-3">
|
||||||
|
<i className="bi bi-clipboard-x fs-1 text-muted mb-3"></i>
|
||||||
|
<p className="text-muted mb-3">
|
||||||
|
Este evento no tiene formularios disponibles.
|
||||||
|
</p>
|
||||||
|
{(user === 'administrador' || user === 'staff') && (
|
||||||
|
<Link
|
||||||
|
href={`/${user}/evento/${evento.id_evento}/formularios/crear`}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
<i className="bi bi-plus-circle me-2"></i>
|
||||||
|
Crear formulario
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="badge fs-6 w-100 bg-primary">
|
||||||
|
Evento {formatearRangoFechas(evento.fecha_inicio, evento.fecha_fin)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Botón único del evento */}
|
||||||
|
<div className="mt-2">
|
||||||
|
{user === 'administrador' || user === 'staff' ? (
|
||||||
|
<Link
|
||||||
|
href={`/${user}/evento/${evento.id_evento}`}
|
||||||
|
className="btn w-100 btn-outline-primary"
|
||||||
|
>
|
||||||
|
Ver/Editar evento
|
||||||
|
</Link>
|
||||||
|
) : evento.cuestionarios.length === 0 ? (
|
||||||
|
<button className="btn w-100 btn-outline-secondary" disabled>
|
||||||
|
Sin formularios disponibles
|
||||||
|
</button>
|
||||||
|
) : evento.cuestionarios.some(
|
||||||
|
(cuestionario) =>
|
||||||
|
cuestionario.cupos_disponibles === 0 &&
|
||||||
|
cuestionario.cupo_maximo !== null
|
||||||
|
) &&
|
||||||
|
evento.cuestionarios.every(
|
||||||
|
(cuestionario) =>
|
||||||
|
cuestionario.cupos_disponibles === 0 &&
|
||||||
|
cuestionario.cupo_maximo !== null
|
||||||
|
) ? (
|
||||||
|
<button className="btn w-100 btn-outline-secondary" disabled>
|
||||||
|
Cupo lleno
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href={`/evento/${evento.nombre_evento.split(' ').join('_')}/${
|
||||||
|
evento.id_evento
|
||||||
|
}/${evento.cuestionarios[0]?.id_cuestionario || ''}`}
|
||||||
|
className="btn w-100 btn-outline-primary"
|
||||||
|
>
|
||||||
|
Registro
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { GetEventoWithCuestionariosWithCupos } from '@/types/evento';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import MarkdownRenderer from '../markdown-render';
|
||||||
|
import { formatearFechaCard } from '@/utils/date-utils';
|
||||||
|
|
||||||
|
export default function EventoCardAdmin({
|
||||||
|
evento,
|
||||||
|
}: {
|
||||||
|
evento: GetEventoWithCuestionariosWithCupos;
|
||||||
|
}) {
|
||||||
|
const [verMas, setVerMas] = useState(false);
|
||||||
|
const descripcion = evento.descripcion_evento ?? '';
|
||||||
|
const limite = 100;
|
||||||
|
|
||||||
|
const descripcionRecortada =
|
||||||
|
descripcion.length > limite && !verMas
|
||||||
|
? `${descripcion.slice(0, limite)}...`
|
||||||
|
: descripcion;
|
||||||
|
|
||||||
|
// Obtener información de fecha usando la utilidad
|
||||||
|
const { mesTexto, diaTexto } = formatearFechaCard(
|
||||||
|
evento.fecha_inicio,
|
||||||
|
evento.fecha_fin
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="card shadow-sm border-0"
|
||||||
|
style={{ borderRadius: '12px', overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<div className="position-relative">
|
||||||
|
<Link href={`/user/evento/${evento.id_evento}`}>
|
||||||
|
<Image
|
||||||
|
width={400}
|
||||||
|
height={250}
|
||||||
|
className="img-fluid w-100"
|
||||||
|
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${evento.banner}`}
|
||||||
|
alt="Banner formulario"
|
||||||
|
style={{ objectFit: 'cover', height: '200px' }}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Badge de cantidad de formularios en la esquina superior derecha */}
|
||||||
|
<div className="position-absolute top-0 end-0 m-2">
|
||||||
|
{evento.cuestionarios.length > 0 ? (
|
||||||
|
<span className="badge bg-primary">
|
||||||
|
{evento.cuestionarios.length} formulario
|
||||||
|
{evento.cuestionarios.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge bg-secondary">Sin formularios</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body p-3 d-flex flex-column">
|
||||||
|
{/* Fecha del evento */}
|
||||||
|
<div className="d-flex align-items-center mb-2">
|
||||||
|
<div className="me-3" style={{ minWidth: '50px' }}>
|
||||||
|
<div
|
||||||
|
className="text-muted text-center small"
|
||||||
|
style={{
|
||||||
|
fontSize: mesTexto.includes(' - ') ? '0.65rem' : '0.75rem',
|
||||||
|
lineHeight: 1.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mesTexto}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="fw-bold mb-0 text-center"
|
||||||
|
style={{
|
||||||
|
lineHeight: 1,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
fontSize: diaTexto.toString().length > 2 ? '1rem' : '1.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{diaTexto}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 className="card-title mb-1 fw-bold">{evento.nombre_evento}</h5>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Descripción */}
|
||||||
|
<div className="card-description flex-grow-1 mb-3">
|
||||||
|
<MarkdownRenderer markdown={descripcionRecortada} />
|
||||||
|
{descripcion.length > limite && (
|
||||||
|
<button
|
||||||
|
onClick={() => setVerMas(!verMas)}
|
||||||
|
className="btn btn-link btn-sm p-0 ms-1 align-baseline text-decoration-none"
|
||||||
|
style={{ fontSize: '0.875rem' }}
|
||||||
|
>
|
||||||
|
{verMas ? 'Ver menos' : 'Ver más'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Formularios/sub eventos */}
|
||||||
|
{evento.cuestionarios.length > 0 ? (
|
||||||
|
<div className="mb-3">
|
||||||
|
<h6 className="fw-bold mb-2 text-muted">
|
||||||
|
Formularios disponibles:
|
||||||
|
</h6>
|
||||||
|
<div className="row row-cols-1 g-2">
|
||||||
|
{evento.cuestionarios.slice(0, 3).map((cuestionario) => (
|
||||||
|
<div key={cuestionario.id_cuestionario} className="col">
|
||||||
|
<div className="py-2 px-3 bg-light border-0">
|
||||||
|
<div className="d-flex justify-content-between align-items-center">
|
||||||
|
<Link
|
||||||
|
href={`/user/evento/${evento.id_evento}/formulario/${cuestionario.id_cuestionario}`}
|
||||||
|
className="text-decoration-none fw-semibold"
|
||||||
|
>
|
||||||
|
{cuestionario.nombre_form}
|
||||||
|
</Link>
|
||||||
|
<small className="text-muted">
|
||||||
|
{cuestionario.cupo_maximo
|
||||||
|
? `${cuestionario.cupos_usados}/${cuestionario.cupo_maximo}`
|
||||||
|
: 'Sin límite'}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center px-3 py-3 bg-light rounded mb-3">
|
||||||
|
<i className="bi bi-clipboard-x fs-4 text-muted mb-2 d-block"></i>
|
||||||
|
<p className="text-muted mb-2 small">Sin formularios disponibles</p>
|
||||||
|
<Link
|
||||||
|
href={`/user/evento/${evento.id_evento}/formularios/crear`}
|
||||||
|
className="btn btn-sm btn-outline-primary"
|
||||||
|
>
|
||||||
|
<i className="bi bi-plus-circle me-1"></i>
|
||||||
|
Crear formulario
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Botón principal */}
|
||||||
|
<Link
|
||||||
|
href={`/user/evento/${evento.id_evento}`}
|
||||||
|
className="btn btn-primary w-100 rounded-pill"
|
||||||
|
>
|
||||||
|
Ver/Editar evento
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { CreateEventoType } from '@/types/create-evento';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import MarkdownRenderer from '@/components/markdown-render';
|
||||||
|
import { formatearFechaCard } from '@/utils/date-utils';
|
||||||
|
import Button from '../button';
|
||||||
|
|
||||||
|
interface EventoPreviewProps {
|
||||||
|
evento: CreateEventoType;
|
||||||
|
eventoBanner?: File | null;
|
||||||
|
existingBanner?: string | null; // Banner existente desde la API
|
||||||
|
cuestionariosCount?: number; // Número de formularios existentes
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EventoCardPreview({
|
||||||
|
evento,
|
||||||
|
eventoBanner,
|
||||||
|
existingBanner,
|
||||||
|
cuestionariosCount = 0,
|
||||||
|
}: EventoPreviewProps) {
|
||||||
|
const [verMas, setVerMas] = useState(false);
|
||||||
|
const [bannerUrl, setBannerUrl] = useState<string>('/default-banner.png');
|
||||||
|
|
||||||
|
const descripcion = evento.descripcion_evento ?? '';
|
||||||
|
const limite = 100;
|
||||||
|
|
||||||
|
const descripcionRecortada =
|
||||||
|
descripcion.length > limite && !verMas
|
||||||
|
? `${descripcion.slice(0, limite)}...`
|
||||||
|
: descripcion;
|
||||||
|
|
||||||
|
// Actualizar URL del banner cuando cambie el archivo o cuando haya un banner existente
|
||||||
|
useEffect(() => {
|
||||||
|
if (eventoBanner) {
|
||||||
|
// Si hay un nuevo banner seleccionado, usarlo
|
||||||
|
const url = URL.createObjectURL(eventoBanner);
|
||||||
|
setBannerUrl(url);
|
||||||
|
|
||||||
|
// Limpiar URL anterior cuando el componente se desmonte
|
||||||
|
return () => URL.revokeObjectURL(url);
|
||||||
|
} else if (existingBanner) {
|
||||||
|
// Si no hay nuevo banner pero sí hay uno existente desde la API, usarlo
|
||||||
|
setBannerUrl(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}/banners/${existingBanner}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Si no hay ningún banner, usar el banner por defecto
|
||||||
|
setBannerUrl('/default-banner.png');
|
||||||
|
}
|
||||||
|
}, [eventoBanner, existingBanner]);
|
||||||
|
|
||||||
|
// Obtener información de fecha usando la utilidad
|
||||||
|
const getDateInfo = () => {
|
||||||
|
if (!evento.fecha_inicio || !evento.fecha_fin) {
|
||||||
|
return { mesTexto: 'MES', diaTexto: 'DD' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatearFechaCard(evento.fecha_inicio, evento.fecha_fin);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mesTexto, diaTexto } = getDateInfo();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="card shadow-sm border-0 mb-2"
|
||||||
|
style={{ borderRadius: '12px', overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<div className="position-relative">
|
||||||
|
<Image
|
||||||
|
width={400}
|
||||||
|
height={250}
|
||||||
|
className="img-fluid w-100"
|
||||||
|
src={bannerUrl}
|
||||||
|
alt="Banner formulario"
|
||||||
|
style={{ objectFit: 'cover', height: '200px' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Badge de cantidad de formularios en la esquina superior derecha */}
|
||||||
|
<div className="position-absolute top-0 end-0 m-2">
|
||||||
|
{cuestionariosCount > 0 ? (
|
||||||
|
<span className="badge bg-primary">
|
||||||
|
{cuestionariosCount} formulario
|
||||||
|
{cuestionariosCount !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge bg-secondary">Sin formularios</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body p-3 d-flex flex-column">
|
||||||
|
{/* Fecha del evento */}
|
||||||
|
<div className="d-flex align-items-center mb-2">
|
||||||
|
<div className="me-3" style={{ minWidth: '50px' }}>
|
||||||
|
<div
|
||||||
|
className="text-muted text-center small"
|
||||||
|
style={{
|
||||||
|
fontSize: mesTexto.includes(' - ') ? '0.65rem' : '0.75rem',
|
||||||
|
lineHeight: 1.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mesTexto}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="fw-bold mb-0 text-center"
|
||||||
|
style={{
|
||||||
|
lineHeight: 1,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
fontSize: diaTexto.toString().length > 2 ? '1rem' : '1.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{diaTexto}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 className="card-title mb-1 fw-bold">
|
||||||
|
{evento.nombre_evento || 'Nombre del evento'}
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tipo de evento */}
|
||||||
|
{evento.tipo_evento && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<small className="text-muted">
|
||||||
|
<i className="bi bi-tag-fill me-1"></i>
|
||||||
|
{evento.tipo_evento}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Descripción */}
|
||||||
|
<div className="card-description flex-grow-1 mb-3">
|
||||||
|
{descripcion ? (
|
||||||
|
<>
|
||||||
|
<MarkdownRenderer markdown={descripcionRecortada} />
|
||||||
|
{descripcion.length > limite && (
|
||||||
|
<button
|
||||||
|
onClick={() => setVerMas(!verMas)}
|
||||||
|
className="btn btn-link btn-sm p-0 ms-1 align-baseline text-decoration-none"
|
||||||
|
style={{ fontSize: '0.875rem' }}
|
||||||
|
>
|
||||||
|
{verMas ? 'Ver menos' : 'Ver más'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted small">Descripción del evento...</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Botón principal - deshabilitado */}
|
||||||
|
<Button variant="primary" className="w-100 rounded-pill" disabled>
|
||||||
|
Ver/Editar evento
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { GetEventoWithCuestionariosWithCupos } from '@/types/evento';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import MarkdownRenderer from '../markdown-render';
|
||||||
|
import { formatearRangoFechas } from '@/utils/date-utils';
|
||||||
|
|
||||||
|
export default function EventoCard({
|
||||||
|
evento,
|
||||||
|
user = 'public',
|
||||||
|
}: {
|
||||||
|
evento: GetEventoWithCuestionariosWithCupos;
|
||||||
|
user?: 'public' | 'administrador' | 'staff';
|
||||||
|
}) {
|
||||||
|
const [verMas, setVerMas] = useState(false);
|
||||||
|
const descripcion = evento.descripcion_evento ?? '';
|
||||||
|
const limite = 100;
|
||||||
|
|
||||||
|
const descripcionRecortada =
|
||||||
|
descripcion.length > limite && !verMas
|
||||||
|
? `${descripcion.slice(0, limite)}...`
|
||||||
|
: descripcion;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card card-blog d-flex flex-column justify-content-between">
|
||||||
|
<div>
|
||||||
|
<div className="card-image scale-hover-1">
|
||||||
|
<Link
|
||||||
|
href={
|
||||||
|
user === 'administrador' || user === 'staff'
|
||||||
|
? `/${user}/evento/${evento.id_evento}`
|
||||||
|
: `/evento/${evento.nombre_evento.split(' ').join('_')}/${
|
||||||
|
evento.id_evento
|
||||||
|
}/${evento.cuestionarios[0].id_cuestionario}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
width={400}
|
||||||
|
height={300}
|
||||||
|
className="img-fluid"
|
||||||
|
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${evento.banner}`}
|
||||||
|
alt="Banner formulario"
|
||||||
|
/>
|
||||||
|
<div className="card-caption">{evento.nombre_evento}</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="card-description mb-0">
|
||||||
|
<MarkdownRenderer markdown={descripcionRecortada} />
|
||||||
|
{descripcion.length > limite && (
|
||||||
|
<button
|
||||||
|
onClick={() => setVerMas(!verMas)}
|
||||||
|
className="btn btn-link btn-sm p-0 ms-1 align-baseline"
|
||||||
|
style={{ fontSize: '0.875rem' }}
|
||||||
|
>
|
||||||
|
{verMas ? 'Ver menos' : 'Ver más'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{evento.cuestionarios.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<h2 className="my-2 fs-3">Formularios</h2>
|
||||||
|
<table className="table table-white">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Nombre</th>
|
||||||
|
<th scope="col">Inscritos</th>
|
||||||
|
<th scope="col">Capacidad total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{evento.cuestionarios.map((cuestionario) => (
|
||||||
|
<tr key={cuestionario.id_cuestionario}>
|
||||||
|
<td>
|
||||||
|
<Link
|
||||||
|
href={
|
||||||
|
user === 'administrador' || user === 'staff'
|
||||||
|
? `/${user}/evento/${evento.id_evento}/formulario/${cuestionario.id_cuestionario}`
|
||||||
|
: `/evento/${evento.nombre_evento
|
||||||
|
.split(' ')
|
||||||
|
.join('_')}/${evento.id_evento}/${
|
||||||
|
cuestionario.id_cuestionario
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{cuestionario.nombre_form}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
{cuestionario.cupo_maximo ? (
|
||||||
|
<>
|
||||||
|
<td>{cuestionario.cupos_usados}</td>
|
||||||
|
<td>{cuestionario.cupo_maximo}</td>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<td colSpan={2} className="text-center">
|
||||||
|
Sin límite
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center px-4 py-3 border rounded bg-light my-3">
|
||||||
|
<i className="bi bi-clipboard-x fs-1 text-muted mb-3"></i>
|
||||||
|
<p className="text-muted mb-3">
|
||||||
|
Este evento no tiene formularios disponibles.
|
||||||
|
</p>
|
||||||
|
{(user === 'administrador' || user === 'staff') && (
|
||||||
|
<Link
|
||||||
|
href={`/${user}/evento/${evento.id_evento}/formularios/crear`}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
<i className="bi bi-plus-circle me-2"></i>
|
||||||
|
Crear formulario
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="badge fs-6 w-100 bg-primary">
|
||||||
|
Evento {formatearRangoFechas(evento.fecha_inicio, evento.fecha_fin)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Botón único del evento */}
|
||||||
|
<div className="mt-2">
|
||||||
|
{user === 'administrador' || user === 'staff' ? (
|
||||||
|
<Link
|
||||||
|
href={`/${user}/evento/${evento.id_evento}`}
|
||||||
|
className="btn w-100 btn-outline-primary"
|
||||||
|
>
|
||||||
|
Ver/Editar evento
|
||||||
|
</Link>
|
||||||
|
) : evento.cuestionarios.length === 0 ? (
|
||||||
|
<button className="btn w-100 btn-outline-secondary" disabled>
|
||||||
|
Sin formularios disponibles
|
||||||
|
</button>
|
||||||
|
) : evento.cuestionarios.some(
|
||||||
|
(cuestionario) =>
|
||||||
|
cuestionario.cupos_disponibles === 0 &&
|
||||||
|
cuestionario.cupo_maximo !== null
|
||||||
|
) &&
|
||||||
|
evento.cuestionarios.every(
|
||||||
|
(cuestionario) =>
|
||||||
|
cuestionario.cupos_disponibles === 0 &&
|
||||||
|
cuestionario.cupo_maximo !== null
|
||||||
|
) ? (
|
||||||
|
<button className="btn w-100 btn-outline-secondary" disabled>
|
||||||
|
Cupo lleno
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href={`/evento/${evento.nombre_evento.split(' ').join('_')}/${
|
||||||
|
evento.id_evento
|
||||||
|
}/${evento.cuestionarios[0]?.id_cuestionario || ''}`}
|
||||||
|
className="btn w-100 btn-outline-primary"
|
||||||
|
>
|
||||||
|
Registro
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,13 +4,21 @@ import Link from 'next/link';
|
|||||||
import { GetCuestionario } from '@/types/cuestionario';
|
import { GetCuestionario } from '@/types/cuestionario';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
import Button from './button';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
cuestionario: GetCuestionario;
|
cuestionario: GetCuestionario;
|
||||||
link: string;
|
link: string;
|
||||||
|
button_message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FormularioCard({ cuestionario, link }: Props) {
|
export default function FormularioCard({
|
||||||
|
cuestionario,
|
||||||
|
link,
|
||||||
|
button_message,
|
||||||
|
}: Props) {
|
||||||
|
const router = useRouter();
|
||||||
const [verMas, setVerMas] = useState(false);
|
const [verMas, setVerMas] = useState(false);
|
||||||
const descripcion = cuestionario.descripcion;
|
const descripcion = cuestionario.descripcion;
|
||||||
const limite = 100;
|
const limite = 100;
|
||||||
@@ -21,12 +29,17 @@ export default function FormularioCard({ cuestionario, link }: Props) {
|
|||||||
: descripcion;
|
: descripcion;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="col-md-4 my-4">
|
|
||||||
<div className="card card-blog d-flex flex-column justify-content-between">
|
<div className="card card-blog d-flex flex-column justify-content-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="card-image">
|
<div className="card-image scale-hover-1">
|
||||||
<Link href={link}>
|
<Link href={link}>
|
||||||
<Image width={400} height={300} className="img" src="/banner1.png" alt="Banner formulario" />
|
<Image
|
||||||
|
width={400}
|
||||||
|
height={300}
|
||||||
|
className="img"
|
||||||
|
src="/feriasex.png"
|
||||||
|
alt="Banner formulario"
|
||||||
|
/>
|
||||||
<div className="card-caption">{cuestionario.nombre_form}</div>
|
<div className="card-caption">{cuestionario.nombre_form}</div>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="ripple-cont"></div>
|
<div className="ripple-cont"></div>
|
||||||
@@ -48,7 +61,16 @@ export default function FormularioCard({ cuestionario, link }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
{button_message && (
|
||||||
|
<Button
|
||||||
|
onClick={() => router.push(link)}
|
||||||
|
outline
|
||||||
|
className="mt-3 w-100 py-1 rounded"
|
||||||
|
variant="azul"
|
||||||
|
>
|
||||||
|
{button_message}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { CuestionarioWithCupo, GetEvento } from '@/types/evento';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import MarkdownRenderer from '../markdown-render';
|
||||||
|
import { formatearFechaCard } from '@/utils/date-utils';
|
||||||
|
import Button from '../button';
|
||||||
|
|
||||||
|
export default function FormularioCardAdmin({
|
||||||
|
cuestionario,
|
||||||
|
evento,
|
||||||
|
handleDownload,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
cuestionario: CuestionarioWithCupo;
|
||||||
|
evento: GetEvento;
|
||||||
|
handleDownload: (
|
||||||
|
id_cuestionario: number,
|
||||||
|
nombre_formulario: string,
|
||||||
|
nombre_evento: string
|
||||||
|
) => void;
|
||||||
|
disabled: boolean;
|
||||||
|
}) {
|
||||||
|
const [verMas, setVerMas] = useState(false);
|
||||||
|
const descripcion = cuestionario.descripcion ?? '';
|
||||||
|
const limite = 100;
|
||||||
|
|
||||||
|
const descripcionRecortada =
|
||||||
|
descripcion.length > limite && !verMas
|
||||||
|
? `${descripcion.slice(0, limite)}...`
|
||||||
|
: descripcion;
|
||||||
|
|
||||||
|
// Obtener información de fecha usando la utilidad
|
||||||
|
const { mesTexto, diaTexto } = formatearFechaCard(
|
||||||
|
cuestionario.fecha_inicio,
|
||||||
|
cuestionario.fecha_fin
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="card shadow-sm border-0"
|
||||||
|
style={{ borderRadius: '12px', overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<div className="position-relative">
|
||||||
|
<Link
|
||||||
|
href={`/user/evento/${evento.id_evento}/formulario/${cuestionario.id_cuestionario}`}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
width={400}
|
||||||
|
height={250}
|
||||||
|
className="img-fluid w-100"
|
||||||
|
src={
|
||||||
|
cuestionario.banner
|
||||||
|
? `${process.env.NEXT_PUBLIC_API_URL}/banners/${cuestionario.banner}`
|
||||||
|
: evento.banner
|
||||||
|
? `${process.env.NEXT_PUBLIC_API_URL}/banners/${evento.banner}`
|
||||||
|
: `/default-banner.png`
|
||||||
|
}
|
||||||
|
alt="Banner formulario"
|
||||||
|
style={{ objectFit: 'cover', height: '200px' }}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Mensaje cuando se usa la imagen del evento */}
|
||||||
|
{!cuestionario.banner && evento.banner && (
|
||||||
|
<div className="position-absolute top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center bg-black bg-opacity-25">
|
||||||
|
<div className="bg-white bg-opacity-90 px-3 py-2 rounded-pill text-center small fw-bold">
|
||||||
|
<i className="bi bi-info-circle-fill me-1 text-primary"></i>
|
||||||
|
Usando imagen del evento
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Badge de tipo de cuestionario en la esquina superior izquierda */}
|
||||||
|
<div className="position-absolute top-0 start-0 m-2">
|
||||||
|
<span className="badge bg-info">
|
||||||
|
{cuestionario.tipoCuestionario?.tipo_cuestionario || 'Formulario'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body p-3 d-flex flex-column">
|
||||||
|
{/* Fecha del formulario */}
|
||||||
|
<div className="d-flex align-items-center mb-2">
|
||||||
|
<div className="me-3" style={{ minWidth: '50px' }}>
|
||||||
|
<div
|
||||||
|
className="text-muted text-center small"
|
||||||
|
style={{
|
||||||
|
fontSize: mesTexto.includes(' - ') ? '0.65rem' : '0.75rem',
|
||||||
|
lineHeight: 1.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mesTexto}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="fw-bold mb-0 text-center"
|
||||||
|
style={{
|
||||||
|
lineHeight: 1,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
fontSize: diaTexto.toString().length > 2 ? '1rem' : '1.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{diaTexto}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 className="card-title mb-1 fw-bold">
|
||||||
|
{cuestionario.nombre_form}
|
||||||
|
</h5>
|
||||||
|
{evento && (
|
||||||
|
<small className="text-muted">
|
||||||
|
<i className="bi bi-calendar-event me-1"></i>
|
||||||
|
{evento.nombre_evento}
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Descripción */}
|
||||||
|
<div className="card-description flex-grow-1 mb-3">
|
||||||
|
<MarkdownRenderer markdown={descripcionRecortada} />
|
||||||
|
{descripcion.length > limite && (
|
||||||
|
<button
|
||||||
|
onClick={() => setVerMas(!verMas)}
|
||||||
|
className="btn btn-link btn-sm p-0 ms-1 align-baseline text-decoration-none"
|
||||||
|
style={{ fontSize: '0.875rem' }}
|
||||||
|
>
|
||||||
|
{verMas ? 'Ver menos' : 'Ver más'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Información de cupos y estadísticas */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="py-2 px-3 bg-light rounded">
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
<i className="bi bi-people-fill me-2 text-primary"></i>
|
||||||
|
<div>
|
||||||
|
<small className="text-muted d-block">Cupos</small>
|
||||||
|
<span className="fw-semibold">
|
||||||
|
{cuestionario.cupo_maximo !== null
|
||||||
|
? `${cuestionario.cupos_usados} / ${cuestionario.cupo_maximo}`
|
||||||
|
: 'Sin límite'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Botones de acción */}
|
||||||
|
<div className="d-grid gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/user/evento/${evento.id_evento}/formulario/${cuestionario.id_cuestionario}`}
|
||||||
|
className="btn btn-primary rounded-pill"
|
||||||
|
>
|
||||||
|
Ver/Editar formulario
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Botón secundario para ver respuestas */}
|
||||||
|
<Button
|
||||||
|
outline
|
||||||
|
variant="success"
|
||||||
|
size="sm"
|
||||||
|
className="rounded-pill"
|
||||||
|
onClick={() =>
|
||||||
|
handleDownload(
|
||||||
|
cuestionario.id_cuestionario,
|
||||||
|
cuestionario.nombre_form,
|
||||||
|
evento.nombre_evento
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<i className="bi bi-download me-2"></i>
|
||||||
|
Descargar respuestas
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import { GetCuestionario } from '@/types/cuestionario';
|
||||||
|
import { FormularioCreacion } from '@/types/create-formulario';
|
||||||
|
import { GetEvento, GetEventoWithCuestionariosWithCupos } from '@/types/evento';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import MarkdownRenderer from '../markdown-render';
|
||||||
|
import { formatearFechaCard } from '@/utils/date-utils';
|
||||||
|
|
||||||
|
interface FormularioCardPreviewProps {
|
||||||
|
// Para formularios existentes
|
||||||
|
cuestionario?: GetCuestionario;
|
||||||
|
// Para formularios en creación
|
||||||
|
formulario?: FormularioCreacion;
|
||||||
|
// Evento asociado
|
||||||
|
evento?: GetEvento | GetEventoWithCuestionariosWithCupos | null;
|
||||||
|
// Banner personalizado
|
||||||
|
nuevoBanner?: File | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FormularioCardPreview({
|
||||||
|
cuestionario,
|
||||||
|
formulario,
|
||||||
|
evento,
|
||||||
|
nuevoBanner,
|
||||||
|
}: FormularioCardPreviewProps) {
|
||||||
|
const [verMas, setVerMas] = useState(false);
|
||||||
|
const [bannerUrl, setBannerUrl] = useState<string>('/default-banner.png');
|
||||||
|
|
||||||
|
// Determinar qué datos usar (formulario existente o en creación)
|
||||||
|
const isCreating = !!formulario && !cuestionario;
|
||||||
|
const data = cuestionario || formulario;
|
||||||
|
|
||||||
|
// Manejar la imagen del banner
|
||||||
|
useEffect(() => {
|
||||||
|
if (nuevoBanner) {
|
||||||
|
// Si hay un nuevo banner seleccionado, usarlo
|
||||||
|
const url = URL.createObjectURL(nuevoBanner);
|
||||||
|
setBannerUrl(url);
|
||||||
|
return () => URL.revokeObjectURL(url);
|
||||||
|
} else if (!isCreating && cuestionario?.banner) {
|
||||||
|
// Si es un formulario existente y tiene banner propio
|
||||||
|
setBannerUrl(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}/banners/${cuestionario.banner}`
|
||||||
|
);
|
||||||
|
} else if (evento?.banner) {
|
||||||
|
// Si hay banner del evento, usarlo
|
||||||
|
setBannerUrl(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}/banners/${evento.banner}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Banner por defecto
|
||||||
|
setBannerUrl('/default-banner.png');
|
||||||
|
}
|
||||||
|
}, [nuevoBanner, evento?.banner, cuestionario?.banner, isCreating]);
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return null; // No hay datos para mostrar
|
||||||
|
}
|
||||||
|
|
||||||
|
const descripcion = data.descripcion ?? '';
|
||||||
|
const limite = 100;
|
||||||
|
|
||||||
|
const descripcionRecortada =
|
||||||
|
descripcion.length > limite && !verMas
|
||||||
|
? `${descripcion.slice(0, limite)}...`
|
||||||
|
: descripcion;
|
||||||
|
|
||||||
|
// Obtener información de fecha usando la utilidad
|
||||||
|
const getDateInfo = () => {
|
||||||
|
if (!data.fecha_inicio || !data.fecha_fin) {
|
||||||
|
return { mesTexto: 'MES', diaTexto: 'DD' };
|
||||||
|
}
|
||||||
|
|
||||||
|
let fechaInicio: Date, fechaFin: Date;
|
||||||
|
|
||||||
|
if (isCreating) {
|
||||||
|
// Para formularios en creación, las fechas vienen como string
|
||||||
|
fechaInicio = new Date(data.fecha_inicio);
|
||||||
|
fechaFin = new Date(data.fecha_fin);
|
||||||
|
} else {
|
||||||
|
// Para formularios existentes, convertir a Date también
|
||||||
|
fechaInicio = new Date(data.fecha_inicio);
|
||||||
|
fechaFin = new Date(data.fecha_fin);
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatearFechaCard(fechaInicio, fechaFin);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mesTexto, diaTexto } = getDateInfo();
|
||||||
|
|
||||||
|
// Determinar si se está usando la imagen del evento
|
||||||
|
const isUsingEventImage =
|
||||||
|
!nuevoBanner && (isCreating || !cuestionario?.banner) && evento?.banner;
|
||||||
|
|
||||||
|
// Obtener el tipo de cuestionario
|
||||||
|
const getTipoCuestionario = () => {
|
||||||
|
const tipoId = isCreating
|
||||||
|
? (formulario as FormularioCreacion).id_tipo_cuestionario
|
||||||
|
: (cuestionario as GetCuestionario).id_tipo_cuestionario;
|
||||||
|
|
||||||
|
if (tipoId === 1) return 'Registro';
|
||||||
|
if (tipoId === 2) return 'Evaluación';
|
||||||
|
return 'Formulario';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtener nombre del formulario
|
||||||
|
const getNombreFormulario = () => {
|
||||||
|
return data.nombre_form || 'Nombre del formulario';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtener información de cupos
|
||||||
|
const getCuposInfo = () => {
|
||||||
|
if (isCreating) {
|
||||||
|
const form = formulario as FormularioCreacion;
|
||||||
|
return form.cupo_maximo && form.cupo_maximo > 0
|
||||||
|
? `0 / ${form.cupo_maximo}`
|
||||||
|
: 'Sin límite';
|
||||||
|
} else {
|
||||||
|
const cuest = cuestionario as GetCuestionario;
|
||||||
|
return cuest.cupo_maximo !== null && cuest.cupo_maximo !== undefined
|
||||||
|
? `0 / ${cuest.cupo_maximo}`
|
||||||
|
: 'Sin límite';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtener información de secciones y preguntas (solo para formularios en creación)
|
||||||
|
const getSeccionesInfo = () => {
|
||||||
|
if (!isCreating || !formulario) return null;
|
||||||
|
|
||||||
|
const totalSecciones = formulario.secciones.length;
|
||||||
|
const totalPreguntas = formulario.secciones.reduce(
|
||||||
|
(total, seccion) => total + seccion.preguntas.length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
return { totalSecciones, totalPreguntas };
|
||||||
|
};
|
||||||
|
|
||||||
|
const seccionesInfo = getSeccionesInfo();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="card shadow-sm border-0 mb-2"
|
||||||
|
style={{ borderRadius: '12px', overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<div className="position-relative">
|
||||||
|
<Image
|
||||||
|
width={400}
|
||||||
|
height={250}
|
||||||
|
className="img-fluid w-100"
|
||||||
|
src={bannerUrl}
|
||||||
|
alt="Banner formulario"
|
||||||
|
style={{ objectFit: 'cover', height: '200px' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Mensaje cuando se usa la imagen del evento */}
|
||||||
|
{isUsingEventImage && (
|
||||||
|
<div className="position-absolute top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center bg-black bg-opacity-25">
|
||||||
|
<div className="bg-white bg-opacity-90 px-3 py-2 rounded-pill text-center small fw-bold">
|
||||||
|
<i className="bi bi-info-circle-fill me-1 text-primary"></i>
|
||||||
|
Usando imagen del evento
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Badge de tipo de cuestionario en la esquina superior izquierda */}
|
||||||
|
<div className="position-absolute top-0 start-0 m-2">
|
||||||
|
<span className="badge bg-info">{getTipoCuestionario()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body p-3 d-flex flex-column">
|
||||||
|
{/* Fecha del formulario */}
|
||||||
|
<div className="d-flex align-items-center mb-2">
|
||||||
|
<div className="me-3" style={{ minWidth: '50px' }}>
|
||||||
|
<div
|
||||||
|
className="text-muted text-center small"
|
||||||
|
style={{
|
||||||
|
fontSize: mesTexto.includes(' - ') ? '0.65rem' : '0.75rem',
|
||||||
|
lineHeight: 1.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mesTexto}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="fw-bold mb-0 text-center"
|
||||||
|
style={{
|
||||||
|
lineHeight: 1,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
fontSize: diaTexto.toString().length > 2 ? '1rem' : '1.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{diaTexto}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 className="card-title mb-1 fw-bold">{getNombreFormulario()}</h5>
|
||||||
|
{evento && (
|
||||||
|
<small className="text-muted">
|
||||||
|
<i className="bi bi-calendar-event me-1"></i>
|
||||||
|
{evento.nombre_evento}
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Descripción */}
|
||||||
|
<div className="card-description flex-grow-1 mb-3">
|
||||||
|
{descripcion ? (
|
||||||
|
<>
|
||||||
|
<MarkdownRenderer markdown={descripcionRecortada} />
|
||||||
|
{descripcion.length > limite && (
|
||||||
|
<button
|
||||||
|
onClick={() => setVerMas(!verMas)}
|
||||||
|
className="btn btn-link btn-sm p-0 ms-1 align-baseline text-decoration-none"
|
||||||
|
style={{ fontSize: '0.875rem' }}
|
||||||
|
>
|
||||||
|
{verMas ? 'Ver menos' : 'Ver más'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted small">Descripción del formulario...</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Información de cupos */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="py-2 px-3 bg-light rounded">
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
<i className="bi bi-people-fill me-2 text-primary"></i>
|
||||||
|
<div>
|
||||||
|
<small className="text-muted d-block">Cupos</small>
|
||||||
|
<span className="fw-semibold">{getCuposInfo()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Información de secciones y preguntas (solo para formularios en creación) */}
|
||||||
|
{seccionesInfo && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="py-2 px-3 bg-light rounded">
|
||||||
|
<div className="d-flex justify-content-between align-items-center text-sm">
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
<i className="bi bi-list-task me-2 text-success"></i>
|
||||||
|
<span className="fw-semibold">
|
||||||
|
{seccionesInfo.totalSecciones} sección
|
||||||
|
{seccionesInfo.totalSecciones !== 1 ? 'es' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
<i className="bi bi-question-circle me-2 text-warning"></i>
|
||||||
|
<span className="fw-semibold">
|
||||||
|
{seccionesInfo.totalPreguntas} pregunta
|
||||||
|
{seccionesInfo.totalPreguntas !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Botón de acción */}
|
||||||
|
<div className="d-grid gap-2">
|
||||||
|
<button className="btn btn-primary rounded-pill" disabled>
|
||||||
|
Ver/Editar formulario
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-success btn-sm rounded-pill"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<i className="bi bi-download me-2"></i>
|
||||||
|
Descargar respuestas
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
'use client';
|
||||||
|
import { CuestionarioWithCupo, GetEvento } from '@/types/evento';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import MarkdownRenderer from '../markdown-render';
|
||||||
|
import { toParam } from '@/utils/slugify';
|
||||||
|
import { formatearFechaCard, formatearHorario } from '@/utils/date-utils';
|
||||||
|
import Button from '../button';
|
||||||
|
|
||||||
|
export default function FormularioCardUser({
|
||||||
|
evento,
|
||||||
|
formulario,
|
||||||
|
}: {
|
||||||
|
evento: GetEvento;
|
||||||
|
formulario: CuestionarioWithCupo;
|
||||||
|
}) {
|
||||||
|
const [verMas, setVerMas] = useState(false);
|
||||||
|
const descripcion = formulario.descripcion ?? '';
|
||||||
|
const limite = 100;
|
||||||
|
|
||||||
|
// Determinar si hay cupos disponibles
|
||||||
|
const sinCupos =
|
||||||
|
formulario.cupo_maximo !== null && formulario.cupos_disponibles <= 0;
|
||||||
|
|
||||||
|
// Verificar si el evento es el mismo día
|
||||||
|
const fechaInicio = new Date(formulario.fecha_inicio);
|
||||||
|
const fechaFin = new Date(formulario.fecha_fin);
|
||||||
|
const esElMismoDia = fechaInicio.toDateString() === fechaFin.toDateString();
|
||||||
|
|
||||||
|
// Obtener información de fecha formateada usando las utilidades
|
||||||
|
const { mesTexto, diaTexto } = formatearFechaCard(
|
||||||
|
formulario.fecha_inicio,
|
||||||
|
formulario.fecha_fin
|
||||||
|
);
|
||||||
|
|
||||||
|
// Obtener horario formateado si es el mismo día
|
||||||
|
const horarioFormateado = esElMismoDia
|
||||||
|
? formatearHorario(formulario.fecha_inicio, formulario.fecha_fin)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const descripcionRecortada =
|
||||||
|
descripcion.length > limite && !verMas
|
||||||
|
? `${descripcion.slice(0, limite)}...`
|
||||||
|
: descripcion;
|
||||||
|
|
||||||
|
const imgSrc = formulario.banner
|
||||||
|
? `${process.env.NEXT_PUBLIC_API_URL}/banners/${formulario.banner}`
|
||||||
|
: evento.banner
|
||||||
|
? `${process.env.NEXT_PUBLIC_API_URL}/banners/${evento.banner}`
|
||||||
|
: `/default-banner.png`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="card shadow-sm border-0"
|
||||||
|
style={{ borderRadius: '12px', overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<div className="position-relative">
|
||||||
|
{sinCupos ? (
|
||||||
|
<div className="position-relative">
|
||||||
|
<Image
|
||||||
|
width={400}
|
||||||
|
height={250}
|
||||||
|
className="img-fluid w-100"
|
||||||
|
src={imgSrc}
|
||||||
|
alt="Banner formulario"
|
||||||
|
style={{
|
||||||
|
objectFit: 'cover',
|
||||||
|
height: '200px',
|
||||||
|
filter: 'grayscale(50%) opacity(0.7)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="position-absolute top-50 start-50 translate-middle bg-gradient bg-black bg-opacity-75 text-white px-3 py-2 rounded-pill">
|
||||||
|
Sin cupos disponibles
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href={`/evento/${toParam(
|
||||||
|
evento.nombre_evento,
|
||||||
|
evento.id_evento
|
||||||
|
)}/${toParam(formulario.nombre_form, formulario.id_cuestionario)}`}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
width={400}
|
||||||
|
height={250}
|
||||||
|
className="img-fluid w-100"
|
||||||
|
src={imgSrc}
|
||||||
|
alt="Banner formulario"
|
||||||
|
style={{ objectFit: 'cover', height: '200px' }}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Badge de cupos en la esquina superior derecha */}
|
||||||
|
<div className="position-absolute top-0 end-0 m-2">
|
||||||
|
<span className="badge bg-primary">
|
||||||
|
{formulario.tipoCuestionario.tipo_cuestionario}{' '}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body p-3 d-flex flex-column">
|
||||||
|
{/* Fecha del evento */}
|
||||||
|
<div className="d-flex align-items-center mb-2">
|
||||||
|
<div className="me-3">
|
||||||
|
<div className="text-muted text-center small">{mesTexto}</div>
|
||||||
|
<div
|
||||||
|
className="fw-bold h4 mb-0"
|
||||||
|
style={{ lineHeight: 1, whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
{diaTexto}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted fw-bold">{evento.nombre_evento}</span>
|
||||||
|
<h5 className="card-title mb-1 fw-bold">
|
||||||
|
{formulario.nombre_form}
|
||||||
|
</h5>
|
||||||
|
{formulario.tipoEvento && (
|
||||||
|
<h6 className="small text-muted">
|
||||||
|
{formulario.tipoEvento.tipo_evento}
|
||||||
|
</h6>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Descripción */}
|
||||||
|
<div className="card-description flex-grow-1 mb-2">
|
||||||
|
<MarkdownRenderer markdown={descripcionRecortada} />
|
||||||
|
{descripcion.length > limite && (
|
||||||
|
<button
|
||||||
|
onClick={() => setVerMas(!verMas)}
|
||||||
|
className="btn btn-link btn-sm p-0 ms-1 align-baseline text-decoration-none"
|
||||||
|
style={{ fontSize: '0.875rem' }}
|
||||||
|
>
|
||||||
|
{verMas ? 'Ver menos' : 'Ver más'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Información de cupos y estadísticas */}
|
||||||
|
<div className="row mb-3">
|
||||||
|
{/* Columna de horario - solo visible si es el mismo día */}
|
||||||
|
{esElMismoDia && (
|
||||||
|
<div className="col-7">
|
||||||
|
<div
|
||||||
|
className={`py-2 px-3 rounded ${
|
||||||
|
sinCupos ? 'bg-primary-subtle' : 'bg-light'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
<i className={`bi bi-clock me-2 text-azul`}></i>
|
||||||
|
<div>
|
||||||
|
<small className="text-muted d-block">Horario</small>
|
||||||
|
<span className={`fw-semibold text-azul`}>
|
||||||
|
{horarioFormateado} horas.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Columna de cupos - siempre visible */}
|
||||||
|
<div className={esElMismoDia ? 'col-4' : 'col-12'}>
|
||||||
|
<div
|
||||||
|
className={`py-2 px-3 rounded ${
|
||||||
|
sinCupos ? 'bg-primary-subtle' : 'bg-light'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
<i className={`bi bi-people-fill me-2 text-primary`}></i>
|
||||||
|
<div>
|
||||||
|
<small className="text-muted d-block">Registro</small>
|
||||||
|
<span className={`fw-semibold text-primary`}>
|
||||||
|
{formulario.cupo_maximo !== null
|
||||||
|
? `${formulario.cupos_usados} / ${formulario.cupo_maximo}`
|
||||||
|
: 'Sin límite'}
|
||||||
|
</span>
|
||||||
|
{sinCupos && (
|
||||||
|
<div className="small text-primary mt-1">
|
||||||
|
<i className="bi bi-exclamation-circle me-1"></i>
|
||||||
|
Cupos agotados
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Botón de registro */}
|
||||||
|
{sinCupos ? (
|
||||||
|
<Button disabled className="rounded-pill">
|
||||||
|
Sin cupos disponibles
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href={`/evento/${toParam(
|
||||||
|
evento.nombre_evento,
|
||||||
|
evento.id_evento
|
||||||
|
)}/${toParam(formulario.nombre_form, formulario.id_cuestionario)}`}
|
||||||
|
className="btn btn-primary w-100 rounded-pill"
|
||||||
|
>
|
||||||
|
Registro
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,79 +1,42 @@
|
|||||||
import React, { useState } from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export interface InputProps
|
export interface SimpleInputProps
|
||||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'className'> {
|
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'className'> {
|
||||||
|
/** Etiqueta visible asociada al campo de entrada */
|
||||||
label?: string;
|
label?: string;
|
||||||
|
/** Clases CSS personalizables para distintos elementos */
|
||||||
className?: {
|
className?: {
|
||||||
|
/** Clases para el contenedor del input */
|
||||||
container?: string;
|
container?: string;
|
||||||
|
/** Clases para la etiqueta */
|
||||||
label?: string;
|
label?: string;
|
||||||
|
/** Clases para el input */
|
||||||
input?: string;
|
input?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Componente de entrada (`Input`) reutilizable en React.
|
* Componente de entrada (`Input`) sin lógica de mostrar/ocultar contraseña.
|
||||||
*
|
|
||||||
* Este componente renderiza un campo de entrada (`<input>`) con una etiqueta opcional y
|
|
||||||
* funcionalidad para mostrar u ocultar contraseñas cuando el tipo es `password`.
|
|
||||||
* También permite personalizar clases CSS y es accesible mediante `aria-label` y `htmlFor`.
|
|
||||||
*
|
|
||||||
* @component
|
|
||||||
* @param {Object} props - Propiedades del componente.
|
|
||||||
* @param {string} [props.label] - Etiqueta visible asociada al campo de entrada.
|
|
||||||
* @param {Object} [props.className] - Clases CSS personalizables para distintos elementos.
|
|
||||||
* @param {string} [props.className.container] - Clases para el contenedor del input.
|
|
||||||
* @param {string} [props.className.label] - Clases para la etiqueta del input.
|
|
||||||
* @param {string} [props.className.input] - Clases para el input en sí.
|
|
||||||
* @param {string} [props.type] - Tipo del input (`text`, `password`, `email`, etc.).
|
|
||||||
* @param {string} [props.name] - Nombre del input (útil para formularios).
|
|
||||||
* @param {string} [props.id] - ID personalizado para el input (para accesibilidad).
|
|
||||||
* @param {string} [props.value] - Valor del input (para uso controlado).
|
|
||||||
* @param {function} [props.onChange] - Manejador del cambio de valor del input.
|
|
||||||
* @param {React.InputHTMLAttributes<HTMLInputElement>} [rest] - Atributos adicionales compatibles con `<input>`.
|
|
||||||
*/
|
*/
|
||||||
export default function Input(props: InputProps) {
|
export default function SimpleInput(props: SimpleInputProps) {
|
||||||
const { className, label, type, name, id, ...rest } = props;
|
const { label, name, id, className, ...rest } = props;
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
|
|
||||||
const isPassword = type === 'password';
|
|
||||||
const inputType = isPassword && showPassword ? 'text' : type;
|
|
||||||
const inputId =
|
const inputId =
|
||||||
id || name || (label ? `input-${label.replace(/\s+/g, '-')}` : undefined);
|
id || name || (label ? `input-${label.replace(/\s+/g, '-')}` : undefined);
|
||||||
|
|
||||||
const togglePasswordVisibility = () => setShowPassword(!showPassword);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className?.container || 'mb-3'}>
|
<div className={className?.container || 'mb-3'}>
|
||||||
{label && inputId && (
|
{label && inputId && (
|
||||||
<label
|
<label htmlFor={inputId} className={className?.label || 'form-label'}>
|
||||||
htmlFor={inputId}
|
|
||||||
className={`form-label ${className?.label || ''}`}
|
|
||||||
>
|
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
<div className='input-group'>
|
|
||||||
<input
|
<input
|
||||||
id={inputId}
|
id={inputId}
|
||||||
name={name}
|
name={name}
|
||||||
type={inputType}
|
className={className?.input || 'form-control'}
|
||||||
className={`form-control bg-white ${className?.input || ''}`}
|
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
{isPassword && (
|
|
||||||
<button
|
|
||||||
type='button'
|
|
||||||
className='btn btn-light border'
|
|
||||||
onClick={togglePasswordVisibility}
|
|
||||||
aria-label={
|
|
||||||
showPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'
|
|
||||||
}
|
|
||||||
title={showPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'}
|
|
||||||
>
|
|
||||||
<i className={`bi ${showPassword ? 'bi-eye-slash' : 'bi-eye'}`}></i>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,15 @@ export default function Footer() {
|
|||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className='bg-azul p-3 text-center text-white small'>
|
<footer className="bg-azul p-3 text-center text-white small">
|
||||||
<p className='m-0'>
|
<p className="m-0">
|
||||||
Hecho en México. Todos los derechos reservados {year}.
|
Hecho en México. Todos los derechos reservados {year}.
|
||||||
</p>
|
</p>
|
||||||
<p className='m-0'>
|
<p className="m-0">
|
||||||
Esta página puede ser reproducida con fines no lucrativos,
|
Esta página puede ser reproducida con fines no lucrativos, siempre y
|
||||||
siempre y cuando no se mutile, se cite la fuente completa y su
|
cuando no se mutile, se cite la fuente completa y su dirección
|
||||||
dirección electrónica. De otra forma, requiere permiso previo
|
electrónica. De otra forma, requiere permiso previo por escrito de la
|
||||||
por escrito de la institución.
|
institución.
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,24 +4,24 @@ import Link from 'next/link';
|
|||||||
|
|
||||||
export default function Header({ small = false }: { small?: boolean }) {
|
export default function Header({ small = false }: { small?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<header className='bg-azul p-3'>
|
<header className="bg-azul p-3">
|
||||||
<div className='container flex-center justify-content-sm-between'>
|
<div className="container flex-center justify-content-sm-between">
|
||||||
<Link
|
<Link
|
||||||
href={'https://www.unam.mx/'}
|
href={'https://www.unam.mx/'}
|
||||||
target='_blank'
|
target="_blank"
|
||||||
className='d-none d-sm-block'
|
className="d-none d-sm-block"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src='/logo-unam.png'
|
src="/logo-unam.png"
|
||||||
alt='Logo de la UNAM'
|
alt="Logo de la UNAM"
|
||||||
width={small ? 200 : 280}
|
width={small ? 200 : 280}
|
||||||
height={small ? 60 : 84}
|
height={small ? 60 : 84}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={'https://acatlan.unam.mx/'} target='_blank'>
|
<Link href={'https://acatlan.unam.mx/'} target="_blank">
|
||||||
<Image
|
<Image
|
||||||
src='/logo-fes.png'
|
src="/logo-fes.png"
|
||||||
alt='Logo de la fes Acatlàn'
|
alt="Logo de la fes Acatlàn"
|
||||||
width={small ? 200 : 250}
|
width={small ? 200 : 250}
|
||||||
height={small ? 48 : 60}
|
height={small ? 48 : 60}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
|
||||||
|
type MarkdownRendererProps = {
|
||||||
|
markdown: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ markdown }) => (
|
||||||
|
<ReactMarkdown
|
||||||
|
components={{
|
||||||
|
p: ({ ...props }) => <p style={{ margin: 0 }} {...props} />,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{markdown}
|
||||||
|
</ReactMarkdown>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default MarkdownRenderer;
|
||||||
@@ -1,9 +1,55 @@
|
|||||||
import React from "react";
|
'use client';
|
||||||
import Link from "next/link";
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
type NavChild = {
|
||||||
|
href: string; // siempre string
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NavItem =
|
||||||
|
| { href: string; label: string } // items normales
|
||||||
|
| { label: string; children: NavChild[] }; // items con submenu
|
||||||
|
|
||||||
const Navbar: React.FC = () => {
|
const Navbar: React.FC = () => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const tipo_usuario = sessionStorage.getItem('axxxd')?.replace(/"/g, '') || '';
|
||||||
|
|
||||||
|
const navItemsAdmin: NavItem[] = [
|
||||||
|
{ href: '/', label: 'Inicio' },
|
||||||
|
{ href: '/user/eventos', label: 'Eventos' },
|
||||||
|
{ href: '/user/qr', label: 'Lector de QRs' },
|
||||||
|
{ href: '/user/eventos/crear', label: 'Crear Evento' },
|
||||||
|
{
|
||||||
|
label: 'Carga Masiva',
|
||||||
|
children: [
|
||||||
|
{ href: '/user/eventos/carga_masiva', label: 'Carga Masiva Eventos' },
|
||||||
|
{
|
||||||
|
href: '/user/eventos/carga_masiva_banner',
|
||||||
|
label: 'Carga Masiva Imagenes',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/user/carga_masiva_usuarios',
|
||||||
|
label: 'Carga Masiva Usuarios',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ href: '/user/usuarios', label: 'Usuarios' },
|
||||||
|
{ href: '/login', label: 'Cerrar sesión' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const navItemsUser: NavItem[] = [
|
||||||
|
{ href: '/user/eventos', label: 'Eventos' },
|
||||||
|
{ href: '/user/qr', label: 'Lector de QRs' },
|
||||||
|
{ href: '/login', label: 'Cerrar sesión' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const navItems =
|
||||||
|
tipo_usuario === 'administrador' ? navItemsAdmin : navItemsUser;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="navbar navbar-expand-lg navbar-light bg-transparent fixed-top">
|
<nav className="navbar navbar-expand-lg navbar-dark bg-azul">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<button
|
<button
|
||||||
className="navbar-toggler border-0 ms-auto"
|
className="navbar-toggler border-0 ms-auto"
|
||||||
@@ -20,7 +66,7 @@ const Navbar: React.FC = () => {
|
|||||||
id="offcanvasNavbar"
|
id="offcanvasNavbar"
|
||||||
aria-labelledby="offcanvasNavbarLabel"
|
aria-labelledby="offcanvasNavbarLabel"
|
||||||
>
|
>
|
||||||
<div className="offcanvas-header">
|
<div className="offcanvas-header bg-azul text-white">
|
||||||
<h5 className="offcanvas-title" id="offcanvasNavbarLabel">
|
<h5 className="offcanvas-title" id="offcanvasNavbarLabel">
|
||||||
Menu
|
Menu
|
||||||
</h5>
|
</h5>
|
||||||
@@ -31,38 +77,50 @@ const Navbar: React.FC = () => {
|
|||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
></button>
|
></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="offcanvas-body justify-content-center">
|
<div className="offcanvas-body align-items-center justify-content-between bg-azul">
|
||||||
|
<h2 className="h5 mb-0 text-white">
|
||||||
|
Sistema de Registro de Eventos
|
||||||
|
</h2>
|
||||||
<ul className="navbar-nav gap-lg-5">
|
<ul className="navbar-nav gap-lg-5">
|
||||||
<li className="nav-item" data-bs-dismiss="offcanvas">
|
{navItems.map((item, index) => (
|
||||||
<Link className="nav-link" href="/">
|
<li
|
||||||
Inicio
|
key={index}
|
||||||
|
className="nav-item"
|
||||||
|
data-bs-dismiss="offcanvas"
|
||||||
|
>
|
||||||
|
{'href' in item ? (
|
||||||
|
<Link
|
||||||
|
className={`nav-link ${
|
||||||
|
pathname === item.href ? 'text-white' : ''
|
||||||
|
}`}
|
||||||
|
href={item.href}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="nav-item dropdown">
|
||||||
|
<a
|
||||||
|
className="nav-link dropdown-toggle"
|
||||||
|
href="#"
|
||||||
|
role="button"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
<ul className="dropdown-menu">
|
||||||
|
{item.children.map((child: NavChild, idx: number) => (
|
||||||
|
<li key={idx}>
|
||||||
|
<Link className="dropdown-item" href={child.href}>
|
||||||
|
{child.label}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li className="nav-item" data-bs-dismiss="offcanvas">
|
))}
|
||||||
<Link className="nav-link" href="/table">
|
</ul>
|
||||||
Table
|
</div>
|
||||||
</Link>
|
)}
|
||||||
</li>
|
|
||||||
<li className="nav-item" data-bs-dismiss="offcanvas">
|
|
||||||
<Link className="nav-link" href="/input">
|
|
||||||
Input
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li className="nav-item" data-bs-dismiss="offcanvas">
|
|
||||||
<Link className="nav-link" href="/pagination">
|
|
||||||
Pagination
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li className="nav-item" data-bs-dismiss="offcanvas">
|
|
||||||
<Link className="nav-link" href="/options">
|
|
||||||
Options
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li className="nav-item" data-bs-dismiss="offcanvas">
|
|
||||||
<Link className="nav-link" href="/styles">
|
|
||||||
Styles
|
|
||||||
</Link>
|
|
||||||
</li>
|
</li>
|
||||||
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
'use client';
|
||||||
|
import { CuestionarioWithCupo } from '@/types/evento';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import MarkdownRenderer from './markdown-render';
|
||||||
|
import { formatearRangoFechas } from '@/utils/date-utils';
|
||||||
|
|
||||||
|
export default function NewCuestionarioCard({
|
||||||
|
cuestionario,
|
||||||
|
user = 'public',
|
||||||
|
onDownload,
|
||||||
|
loading = false,
|
||||||
|
}: {
|
||||||
|
cuestionario: CuestionarioWithCupo;
|
||||||
|
user?: 'public' | 'administrador' | 'staff';
|
||||||
|
onDownload?: (id_cuestionario: number, nombre: string) => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}) {
|
||||||
|
const [verMas, setVerMas] = useState(false);
|
||||||
|
const descripcion = cuestionario.descripcion ?? '';
|
||||||
|
const limite = 100;
|
||||||
|
|
||||||
|
const descripcionRecortada =
|
||||||
|
descripcion.length > limite && !verMas
|
||||||
|
? `${descripcion.slice(0, limite)}...`
|
||||||
|
: descripcion;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card card-blog d-flex flex-column justify-content-between">
|
||||||
|
<div>
|
||||||
|
<div className="card-image scale-hover-1">
|
||||||
|
<Link
|
||||||
|
href={
|
||||||
|
user === 'administrador' || user === 'staff'
|
||||||
|
? `/${user}/evento/${cuestionario.id_evento}`
|
||||||
|
: `/evento/${cuestionario.nombre_form.split(' ').join('_')}/${
|
||||||
|
cuestionario.id_evento
|
||||||
|
}/${cuestionario.id_cuestionario}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
width={400}
|
||||||
|
height={300}
|
||||||
|
className="img-fluid"
|
||||||
|
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${cuestionario.banner}`}
|
||||||
|
alt="Banner formulario"
|
||||||
|
/>
|
||||||
|
<div className="card-caption">{cuestionario.nombre_form}</div>
|
||||||
|
</Link>
|
||||||
|
<div className="ripple-cont"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="card-description mb-0">
|
||||||
|
{cuestionario.cupo_maximo === null ? (
|
||||||
|
<span className="badge bg-success mb-2">Sin límite</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge bg-primary mb-2">
|
||||||
|
{cuestionario.cupos_disponibles} cupos disponibles
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<MarkdownRenderer markdown={descripcionRecortada} />
|
||||||
|
{descripcion.length > limite && (
|
||||||
|
<button
|
||||||
|
onClick={() => setVerMas(!verMas)}
|
||||||
|
className="btn btn-link btn-sm p-0 ms-1 align-baseline"
|
||||||
|
style={{ fontSize: '0.875rem' }}
|
||||||
|
>
|
||||||
|
{verMas ? 'Ver menos' : 'Ver más'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="badge fs-6 w-100 bg-primary">
|
||||||
|
Evento{' '}
|
||||||
|
{formatearRangoFechas(
|
||||||
|
cuestionario.fecha_inicio,
|
||||||
|
cuestionario.fecha_fin
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div className="">
|
||||||
|
{user === 'administrador' || user === 'staff' ? (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
href={`/${user}/evento/${cuestionario.id_evento}/formulario/${cuestionario.id_cuestionario}`}
|
||||||
|
className="btn mt-2 w-100 btn-outline-primary"
|
||||||
|
>
|
||||||
|
Ver formulario
|
||||||
|
</Link>
|
||||||
|
{user === 'administrador' && onDownload && (
|
||||||
|
<button
|
||||||
|
className="btn mt-2 w-100 btn-success"
|
||||||
|
onClick={() =>
|
||||||
|
onDownload(
|
||||||
|
cuestionario.id_cuestionario,
|
||||||
|
cuestionario.nombre_form
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="spinner-border spinner-border-sm me-2"
|
||||||
|
role="status"
|
||||||
|
aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
Descargando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<i className="bi bi-download me-2"></i>
|
||||||
|
Descargar respuestas
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : cuestionario.cupos_disponibles === 0 &&
|
||||||
|
cuestionario.cupo_maximo !== null ? (
|
||||||
|
<button className="btn mt-2 w-100 btn-outline-secondary" disabled>
|
||||||
|
Cupo lleno
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href={`/evento/${cuestionario.nombre_form
|
||||||
|
.split(' ')
|
||||||
|
.join('_')}/${cuestionario.id_evento}/${
|
||||||
|
cuestionario.id_cuestionario
|
||||||
|
}`}
|
||||||
|
className="btn mt-2 w-100 btn-outline-primary"
|
||||||
|
>
|
||||||
|
Registro
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
export interface PasswordInputProps
|
||||||
|
extends Omit<
|
||||||
|
React.InputHTMLAttributes<HTMLInputElement>,
|
||||||
|
'type' | 'className'
|
||||||
|
> {
|
||||||
|
/**
|
||||||
|
* Texto de la etiqueta asociada al input (opcional).
|
||||||
|
*/
|
||||||
|
label?: string;
|
||||||
|
/**
|
||||||
|
* Clases CSS personalizables para distintos elementos.
|
||||||
|
*/
|
||||||
|
className?: {
|
||||||
|
/** Clases para el contenedor del componente */
|
||||||
|
container?: string;
|
||||||
|
/** Clases para la etiqueta */
|
||||||
|
label?: string;
|
||||||
|
/** Clases para el campo input */
|
||||||
|
input?: string;
|
||||||
|
/** Clases para el botón de toggle */
|
||||||
|
button?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Componente especializado para inputs de contraseña con botón de mostrar/ocultar.
|
||||||
|
*/
|
||||||
|
export default function PasswordInput(props: PasswordInputProps) {
|
||||||
|
const { label, name, id, className, ...rest } = props;
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
// Determina el tipo de input según el estado de visibilidad
|
||||||
|
const inputType = visible ? 'text' : 'password';
|
||||||
|
const inputId =
|
||||||
|
id ||
|
||||||
|
name ||
|
||||||
|
(label ? `password-${label.replace(/\s+/g, '-')}` : undefined);
|
||||||
|
|
||||||
|
const toggleVisibility = () => setVisible((v) => !v);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className?.container || 'mb-3'}>
|
||||||
|
{label && inputId && (
|
||||||
|
<label htmlFor={inputId} className={className?.label || 'form-label'}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="input-group">
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
name={name}
|
||||||
|
type={inputType}
|
||||||
|
className={className?.input || 'form-control'}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={className?.button || 'btn btn-light border'}
|
||||||
|
onClick={toggleVisibility}
|
||||||
|
aria-label={visible ? 'Ocultar contraseña' : 'Mostrar contraseña'}
|
||||||
|
title={visible ? 'Ocultar contraseña' : 'Mostrar contraseña'}
|
||||||
|
>
|
||||||
|
<i className={`bi ${visible ? 'bi-eye-slash' : 'bi-eye'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useId } from 'react';
|
import React, { useId } from 'react';
|
||||||
|
|
||||||
interface Option {
|
export interface Option {
|
||||||
value: string | number;
|
value: string | number;
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
@@ -74,7 +74,10 @@ const Select: React.FC<SelectProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`mb-3 ${className?.container ?? ''}`}>
|
<div className={`mb-3 ${className?.container ?? ''}`}>
|
||||||
<label htmlFor={selectId} className={`form-label ${className?.label ?? ''}`}>
|
<label
|
||||||
|
htmlFor={selectId}
|
||||||
|
className={`form-label ${className?.label ?? ''}`}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// @components/table.tsx
|
||||||
import React, { useState, useMemo } from "react";
|
import React, { useState, useMemo } from "react";
|
||||||
import Pagination from "./pagination";
|
import Pagination from "./pagination";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
'use client';
|
||||||
|
import ImageUploader from '@/components/banner-uploader';
|
||||||
|
import Input from '@/components/input';
|
||||||
|
import Select from '@/components/select';
|
||||||
|
import { useGetApi } from '@/hooks/use-get-api';
|
||||||
|
import { CreateEventoType } from '@/types/create-evento';
|
||||||
|
import { TipoEvento } from '@/types/evento';
|
||||||
|
import { formatDateLocal } from '@/utils/date-utils';
|
||||||
|
import { commands } from '@uiw/react-md-editor';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
// Carga dinámica para evitar problemas con SSR
|
||||||
|
const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false });
|
||||||
|
const MAX_LENGTH = 500;
|
||||||
|
|
||||||
|
interface CreateEventoProps {
|
||||||
|
evento: CreateEventoType;
|
||||||
|
handleChange: (field: keyof CreateEventoType, value: string | Date) => void;
|
||||||
|
handleBannerChange: (file: File | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreateEvento({
|
||||||
|
evento,
|
||||||
|
handleChange,
|
||||||
|
handleBannerChange,
|
||||||
|
}: CreateEventoProps) {
|
||||||
|
const { data: tipo_eventos } = useGetApi<TipoEvento[]>('tipo-evento');
|
||||||
|
return (
|
||||||
|
<div className="mt-2">
|
||||||
|
<h4 className="mb-3">Detalles del Evento</h4>
|
||||||
|
<div className="mb-2">
|
||||||
|
<ImageUploader
|
||||||
|
label="Banner del evento"
|
||||||
|
onChange={handleBannerChange}
|
||||||
|
showPreview={false}
|
||||||
|
/>
|
||||||
|
<p className="text-muted mt-2 mb-0 small">
|
||||||
|
<strong>
|
||||||
|
<i className="bi bi-info-circle-fill me-2"></i>
|
||||||
|
Esta es la imagen que se verá en el carrusel del evento.
|
||||||
|
</strong>
|
||||||
|
<br />
|
||||||
|
<strong>
|
||||||
|
<i className="bi bi-image me-2"></i>
|
||||||
|
Dimensiones recomendadas: 1200x600 píxeles.
|
||||||
|
</strong>
|
||||||
|
<br />
|
||||||
|
<strong>
|
||||||
|
<i className="bi bi-file-earmark-image me-2"></i>
|
||||||
|
Formatos soportados: PNG, JPG, JPEG.
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Nombre del evento"
|
||||||
|
required
|
||||||
|
value={evento.nombre_evento}
|
||||||
|
onChange={(e) => handleChange('nombre_evento', e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="form-label">Descripción del evento</label>
|
||||||
|
<div data-color-mode="light">
|
||||||
|
<MDEditor
|
||||||
|
value={evento.descripcion_evento}
|
||||||
|
onChange={(value) => {
|
||||||
|
const texto = value || '';
|
||||||
|
if (texto.length <= MAX_LENGTH) {
|
||||||
|
handleChange('descripcion_evento', texto);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
height={200}
|
||||||
|
commands={[commands.bold, commands.italic, commands.hr]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tipo_eventos && (
|
||||||
|
<Select
|
||||||
|
label="Tipo de evento"
|
||||||
|
value={evento.tipo_evento}
|
||||||
|
placeholder="Selecciona un tipo de evento"
|
||||||
|
onChange={(e) => handleChange('tipo_evento', e.target.value)}
|
||||||
|
options={tipo_eventos.map((tipo) => ({
|
||||||
|
value: tipo.id_tipo_evento,
|
||||||
|
label: tipo.tipo_evento,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<Input
|
||||||
|
label="Fecha de inicio"
|
||||||
|
type="datetime-local"
|
||||||
|
className={{ container: 'mb-3' }}
|
||||||
|
value={formatDateLocal(evento.fecha_inicio)}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange('fecha_inicio', new Date(e.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-md-6">
|
||||||
|
<Input
|
||||||
|
label="Fecha de fin"
|
||||||
|
type="datetime-local"
|
||||||
|
className={{ container: 'mb-3' }}
|
||||||
|
value={formatDateLocal(evento.fecha_fin)}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange('fecha_fin', new Date(e.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,503 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
FormularioCreacion,
|
||||||
|
TipoPregunta,
|
||||||
|
TiposValidacion,
|
||||||
|
} from '@/types/create-formulario';
|
||||||
|
import SimpleInput from '@/components/input';
|
||||||
|
import Select, { Option } from '@/components/select';
|
||||||
|
import Button from '@/components/button';
|
||||||
|
import { GetEventoWithCuestionariosWithCupos } from '@/types/evento';
|
||||||
|
import { formatearRangoFechas } from '@/utils/date-utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
evento?: GetEventoWithCuestionariosWithCupos;
|
||||||
|
formulario: FormularioCreacion;
|
||||||
|
onChange: (formularioActualizado: FormularioCreacion) => void;
|
||||||
|
tipo_eventos?: Option[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const tipoPreguntaOpciones: TipoPregunta[] = [
|
||||||
|
'Abierta (Respuesta corta)',
|
||||||
|
'Abierta (Parrafo)',
|
||||||
|
'Cerrada',
|
||||||
|
'Multiple',
|
||||||
|
];
|
||||||
|
|
||||||
|
const tiposValidacion: TiposValidacion[] = [
|
||||||
|
'correo',
|
||||||
|
'correo_institucional',
|
||||||
|
'telefono',
|
||||||
|
'nombre',
|
||||||
|
'apellidos',
|
||||||
|
'genero',
|
||||||
|
'entero',
|
||||||
|
'decimal',
|
||||||
|
'comunidad_alumno',
|
||||||
|
'cuenta_alumno',
|
||||||
|
'comunidad_trabajador',
|
||||||
|
'cuenta_trabajador',
|
||||||
|
'rfc',
|
||||||
|
];
|
||||||
|
|
||||||
|
type CampoPreguntaSimple =
|
||||||
|
| 'titulo'
|
||||||
|
| 'tipo'
|
||||||
|
| 'validacion'
|
||||||
|
| 'obligatoria'
|
||||||
|
| 'limite';
|
||||||
|
type CampoPreguntaOpciones = 'opciones';
|
||||||
|
|
||||||
|
const tiposCuestionario = [
|
||||||
|
{ label: 'Registro', value: 1 },
|
||||||
|
{ label: 'Evaluación', value: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function FormularioEditor({
|
||||||
|
formulario,
|
||||||
|
onChange,
|
||||||
|
evento,
|
||||||
|
tipo_eventos,
|
||||||
|
}: Props) {
|
||||||
|
function actualizarCampo<K extends keyof FormularioCreacion>(
|
||||||
|
campo: K,
|
||||||
|
valor: FormularioCreacion[K]
|
||||||
|
): void {
|
||||||
|
const actualizado = { ...formulario, [campo]: valor };
|
||||||
|
onChange(actualizado);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdatePregunta(
|
||||||
|
seccionIdx: number,
|
||||||
|
preguntaIdx: number,
|
||||||
|
campo: CampoPreguntaSimple | CampoPreguntaOpciones,
|
||||||
|
valor:
|
||||||
|
| string
|
||||||
|
| boolean
|
||||||
|
| TipoPregunta
|
||||||
|
| TiposValidacion
|
||||||
|
| number
|
||||||
|
| { valor: string }[]
|
||||||
|
| undefined
|
||||||
|
) {
|
||||||
|
const actualizado = { ...formulario };
|
||||||
|
const pregunta = actualizado.secciones[seccionIdx].preguntas[preguntaIdx];
|
||||||
|
|
||||||
|
if (campo === 'opciones') {
|
||||||
|
pregunta.opciones = valor as { valor: string }[];
|
||||||
|
} else if (campo === 'titulo' && typeof valor === 'string') {
|
||||||
|
pregunta.titulo = valor;
|
||||||
|
} else if (campo === 'tipo' && typeof valor === 'string') {
|
||||||
|
pregunta.tipo = valor as TipoPregunta;
|
||||||
|
} else if (campo === 'validacion' || valor === undefined) {
|
||||||
|
pregunta.validacion = valor as TiposValidacion | undefined;
|
||||||
|
} else if (campo === 'obligatoria' && typeof valor === 'boolean') {
|
||||||
|
pregunta.obligatoria = valor;
|
||||||
|
} else if (campo === 'limite' && typeof valor === 'number') {
|
||||||
|
pregunta.limite = valor;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(actualizado);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateSeccion = (
|
||||||
|
index: number,
|
||||||
|
campo: 'titulo' | 'descripcion',
|
||||||
|
valor: string
|
||||||
|
) => {
|
||||||
|
const actualizado = { ...formulario };
|
||||||
|
actualizado.secciones = actualizado.secciones.map((seccion, idx) =>
|
||||||
|
idx === index ? { ...seccion, [campo]: valor } : seccion
|
||||||
|
);
|
||||||
|
onChange(actualizado);
|
||||||
|
};
|
||||||
|
|
||||||
|
const agregarSeccion = () => {
|
||||||
|
const nuevaSeccion = {
|
||||||
|
titulo: '',
|
||||||
|
descripcion: '',
|
||||||
|
preguntas: [],
|
||||||
|
};
|
||||||
|
onChange({
|
||||||
|
...formulario,
|
||||||
|
secciones: [...formulario.secciones, nuevaSeccion],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const eliminarSeccion = (index: number) => {
|
||||||
|
const nuevas = [...formulario.secciones];
|
||||||
|
nuevas.splice(index, 1);
|
||||||
|
onChange({ ...formulario, secciones: nuevas });
|
||||||
|
};
|
||||||
|
|
||||||
|
const agregarPregunta = (seccionIdx: number) => {
|
||||||
|
const nuevaPregunta = {
|
||||||
|
titulo: '',
|
||||||
|
tipo: 'Abierta (Respuesta corta)' as TipoPregunta,
|
||||||
|
obligatoria: false,
|
||||||
|
opciones: [],
|
||||||
|
};
|
||||||
|
const actualizado = { ...formulario };
|
||||||
|
actualizado.secciones[seccionIdx].preguntas.push(nuevaPregunta);
|
||||||
|
onChange(actualizado);
|
||||||
|
};
|
||||||
|
|
||||||
|
const eliminarPregunta = (seccionIdx: number, preguntaIdx: number) => {
|
||||||
|
const actualizado = { ...formulario };
|
||||||
|
actualizado.secciones[seccionIdx].preguntas.splice(preguntaIdx, 1);
|
||||||
|
onChange(actualizado);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Renderizar solo información básica del formulario
|
||||||
|
const renderInformacionBasica = () => (
|
||||||
|
<div className="p-4">
|
||||||
|
<h4 className="mb-3">Información del Formulario</h4>
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-md-8">
|
||||||
|
<SimpleInput
|
||||||
|
label="Nombre del Formulario"
|
||||||
|
value={formulario.nombre_form}
|
||||||
|
onChange={(e) => actualizarCampo('nombre_form', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-md-4">
|
||||||
|
<SimpleInput
|
||||||
|
label="Cupo máximo"
|
||||||
|
type="number"
|
||||||
|
placeholder="Opcional"
|
||||||
|
value={formulario.cupo_maximo || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
actualizarCampo('cupo_maximo', Number(e.target.value) || 0)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SimpleInput
|
||||||
|
label="Descripción del Formulario"
|
||||||
|
value={formulario.descripcion}
|
||||||
|
onChange={(e) => actualizarCampo('descripcion', e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<SimpleInput
|
||||||
|
label="Fecha de Inicio"
|
||||||
|
type="datetime-local"
|
||||||
|
value={formulario.fecha_inicio.slice(0, 16)}
|
||||||
|
onChange={(e) => actualizarCampo('fecha_inicio', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<SimpleInput
|
||||||
|
label="Fecha de Fin"
|
||||||
|
type="datetime-local"
|
||||||
|
value={formulario.fecha_fin.slice(0, 16)}
|
||||||
|
onChange={(e) => actualizarCampo('fecha_fin', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{evento && (
|
||||||
|
<div className="alert alert-primary d-flex align-items-center">
|
||||||
|
<i className="bi bi-info-circle-fill me-2"></i>
|
||||||
|
<div>
|
||||||
|
<strong>Evento:</strong> {evento.nombre_evento}
|
||||||
|
<br />
|
||||||
|
<small>
|
||||||
|
Se realizará{' '}
|
||||||
|
{formatearRangoFechas(evento.fecha_inicio, evento.fecha_fin)}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SimpleInput
|
||||||
|
label="Mensaje de correo"
|
||||||
|
value={formulario.mensaje_correo || ''}
|
||||||
|
onChange={(e) => actualizarCampo('mensaje_correo', e.target.value)}
|
||||||
|
placeholder="Mensaje que se enviará al correo del participante al registrarse"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<Select
|
||||||
|
label="Tipo de Cuestionario"
|
||||||
|
value={formulario.id_tipo_cuestionario}
|
||||||
|
onChange={(e) =>
|
||||||
|
actualizarCampo('id_tipo_cuestionario', Number(e.target.value))
|
||||||
|
}
|
||||||
|
options={tiposCuestionario}
|
||||||
|
placeholder="Selecciona un tipo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<Select
|
||||||
|
label="Tipo de evento"
|
||||||
|
value={formulario.id_tipo_evento}
|
||||||
|
onChange={(e) =>
|
||||||
|
actualizarCampo('id_tipo_evento', Number(e.target.value))
|
||||||
|
}
|
||||||
|
options={tipo_eventos!}
|
||||||
|
placeholder="Selecciona un tipo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Renderizar secciones y preguntas
|
||||||
|
const renderSeccionesYPreguntas = () => (
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 className="mb-0">Secciones y Preguntas</h4>
|
||||||
|
<Button
|
||||||
|
className="btn btn-success"
|
||||||
|
onClick={agregarSeccion}
|
||||||
|
icon="plus"
|
||||||
|
>
|
||||||
|
Agregar sección
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formulario.secciones.map((seccion, seccionIdx) => (
|
||||||
|
<div key={seccionIdx} className="mb-4 p-3 border rounded bg-light">
|
||||||
|
<div className="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<h5 className="mb-0">Sección {seccionIdx + 1}</h5>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
icon="trash"
|
||||||
|
size="sm"
|
||||||
|
outline
|
||||||
|
onClick={() => eliminarSeccion(seccionIdx)}
|
||||||
|
>
|
||||||
|
Eliminar sección
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SimpleInput
|
||||||
|
label="Título de la sección"
|
||||||
|
value={seccion.titulo}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleUpdateSeccion(seccionIdx, 'titulo', e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SimpleInput
|
||||||
|
label="Descripción de la sección"
|
||||||
|
value={seccion.descripcion}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleUpdateSeccion(seccionIdx, 'descripcion', e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Preguntas */}
|
||||||
|
{seccion.preguntas.map((pregunta, preguntaIdx) => (
|
||||||
|
<div key={preguntaIdx} className="mb-3 p-3 border rounded bg-white">
|
||||||
|
<div className="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<h6 className="mb-0">Pregunta {preguntaIdx + 1}</h6>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
icon="trash"
|
||||||
|
size="sm"
|
||||||
|
outline
|
||||||
|
onClick={() => eliminarPregunta(seccionIdx, preguntaIdx)}
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SimpleInput
|
||||||
|
label="Título de la pregunta"
|
||||||
|
value={pregunta.titulo}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleUpdatePregunta(
|
||||||
|
seccionIdx,
|
||||||
|
preguntaIdx,
|
||||||
|
'titulo',
|
||||||
|
e.target.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<Select
|
||||||
|
label="Tipo de pregunta"
|
||||||
|
value={pregunta.tipo}
|
||||||
|
onChange={(e) => {
|
||||||
|
const nuevoTipo = e.target.value as TipoPregunta;
|
||||||
|
handleUpdatePregunta(
|
||||||
|
seccionIdx,
|
||||||
|
preguntaIdx,
|
||||||
|
'tipo',
|
||||||
|
nuevoTipo
|
||||||
|
);
|
||||||
|
|
||||||
|
// Si cambia a un tipo cerrado y no tiene opciones, agregar opciones por defecto
|
||||||
|
if (
|
||||||
|
(nuevoTipo === 'Cerrada' || nuevoTipo === 'Multiple') &&
|
||||||
|
(!pregunta.opciones || pregunta.opciones.length === 0)
|
||||||
|
) {
|
||||||
|
handleUpdatePregunta(
|
||||||
|
seccionIdx,
|
||||||
|
preguntaIdx,
|
||||||
|
'opciones',
|
||||||
|
[{ valor: '' }, { valor: '' }]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
options={tipoPreguntaOpciones.map((tipo) => ({
|
||||||
|
label: tipo,
|
||||||
|
value: tipo,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-md-6">
|
||||||
|
<Select
|
||||||
|
label="Validación"
|
||||||
|
value={pregunta.validacion || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleUpdatePregunta(
|
||||||
|
seccionIdx,
|
||||||
|
preguntaIdx,
|
||||||
|
'validacion',
|
||||||
|
e.target.value || undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
options={[
|
||||||
|
{ label: 'Ninguna', value: '' },
|
||||||
|
...tiposValidacion.map((v) => ({
|
||||||
|
label: v,
|
||||||
|
value: v,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-check mb-2">
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
checked={pregunta.obligatoria}
|
||||||
|
id={`obligatoria-${seccionIdx}-${preguntaIdx}`}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleUpdatePregunta(
|
||||||
|
seccionIdx,
|
||||||
|
preguntaIdx,
|
||||||
|
'obligatoria',
|
||||||
|
e.target.checked
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
className="form-check-label"
|
||||||
|
htmlFor={`obligatoria-${seccionIdx}-${preguntaIdx}`}
|
||||||
|
>
|
||||||
|
¿Es obligatoria?
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(pregunta.tipo === 'Cerrada' ||
|
||||||
|
pregunta.tipo === 'Multiple') && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<div className="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<label className="form-label mb-0">Opciones</label>
|
||||||
|
<Button
|
||||||
|
variant="success"
|
||||||
|
size="sm"
|
||||||
|
icon="plus"
|
||||||
|
outline
|
||||||
|
onClick={() => {
|
||||||
|
const nuevasOpciones = [...(pregunta.opciones || [])];
|
||||||
|
nuevasOpciones.push({ valor: '' });
|
||||||
|
handleUpdatePregunta(
|
||||||
|
seccionIdx,
|
||||||
|
preguntaIdx,
|
||||||
|
'opciones',
|
||||||
|
nuevasOpciones
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Agregar opción
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{(pregunta.opciones || []).map((op, opIdx) => (
|
||||||
|
<div key={opIdx} className="d-flex align-items-center mb-2">
|
||||||
|
<SimpleInput
|
||||||
|
value={op.valor}
|
||||||
|
onChange={(e) => {
|
||||||
|
const nuevasOpciones = [...(pregunta.opciones || [])];
|
||||||
|
nuevasOpciones[opIdx].valor = e.target.value;
|
||||||
|
handleUpdatePregunta(
|
||||||
|
seccionIdx,
|
||||||
|
preguntaIdx,
|
||||||
|
'opciones',
|
||||||
|
nuevasOpciones
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
placeholder={`Opción ${opIdx + 1}`}
|
||||||
|
className={{
|
||||||
|
container: 'me-2 flex-grow-1 mb-0',
|
||||||
|
input: 'form-control',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
icon="trash"
|
||||||
|
outline
|
||||||
|
onClick={() => {
|
||||||
|
const nuevasOpciones = [...(pregunta.opciones || [])];
|
||||||
|
nuevasOpciones.splice(opIdx, 1);
|
||||||
|
handleUpdatePregunta(
|
||||||
|
seccionIdx,
|
||||||
|
preguntaIdx,
|
||||||
|
'opciones',
|
||||||
|
nuevasOpciones
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
disabled={pregunta.opciones?.length === 1}
|
||||||
|
title={
|
||||||
|
pregunta.opciones?.length === 1
|
||||||
|
? 'Una pregunta cerrada debe tener al menos una opción'
|
||||||
|
: 'Eliminar opción'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(!pregunta.opciones || pregunta.opciones.length === 0) && (
|
||||||
|
<div className="text-muted small">
|
||||||
|
<i className="bi bi-info-circle me-1"></i>
|
||||||
|
Esta pregunta cerrada no tiene opciones. Agrega al menos
|
||||||
|
una opción.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
outline
|
||||||
|
className="mt-2"
|
||||||
|
size="sm"
|
||||||
|
icon="plus"
|
||||||
|
onClick={() => agregarPregunta(seccionIdx)}
|
||||||
|
>
|
||||||
|
Agregar pregunta
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
informacionBasica: renderInformacionBasica,
|
||||||
|
seccionesYPreguntas: renderSeccionesYPreguntas,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
'use client';
|
||||||
|
import ImageUploader from '@/components/banner-uploader';
|
||||||
|
import Button from '@/components/button';
|
||||||
|
import Input from '@/components/input';
|
||||||
|
import Select from '@/components/select';
|
||||||
|
import EventoCardPreview from '@/components/evento/evento-card-preview';
|
||||||
|
import { CreateEventoType } from '@/types/create-evento';
|
||||||
|
import { GetEventoWithCuestionariosWithCupos } from '@/types/evento';
|
||||||
|
import axiosInstance from '@/utils/api-config';
|
||||||
|
import { tipoEventos } from '@/utils/arrays';
|
||||||
|
import { formatDateLocal } from '@/utils/date-utils';
|
||||||
|
import { getAxiosError } from '@/utils/errors-utils';
|
||||||
|
import { commands } from '@uiw/react-md-editor';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false });
|
||||||
|
|
||||||
|
const MAX_LENGTH = 500;
|
||||||
|
|
||||||
|
interface EditEventoProps {
|
||||||
|
evento: GetEventoWithCuestionariosWithCupos;
|
||||||
|
handleChange: (field: keyof CreateEventoType, value: string | Date) => void;
|
||||||
|
handleOnChange: (
|
||||||
|
eventoActualizado: GetEventoWithCuestionariosWithCupos
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditEvento({
|
||||||
|
evento,
|
||||||
|
handleChange,
|
||||||
|
handleOnChange,
|
||||||
|
}: EditEventoProps) {
|
||||||
|
const tipo_usuario = sessionStorage.getItem('axxxd')?.replace(/"/g, '') || '';
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [nuevoBanner, setNuevoBanner] = useState<File | null>(null);
|
||||||
|
|
||||||
|
const handleOnSave = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// 1. Subir nuevo banner si fue cambiado
|
||||||
|
if (nuevoBanner) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('banner', nuevoBanner);
|
||||||
|
|
||||||
|
await axiosInstance.post(
|
||||||
|
`/evento/${evento.id_evento}/banner`,
|
||||||
|
formData,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Actualizar el evento (sin incluir 'banner')
|
||||||
|
const res = await axiosInstance.patch(`/evento/${evento.id_evento}`, {
|
||||||
|
nombre_evento: evento.nombre_evento,
|
||||||
|
descripcion_evento: evento.descripcion_evento,
|
||||||
|
tipo_evento: evento.tipo_evento,
|
||||||
|
fecha_inicio: evento.fecha_inicio,
|
||||||
|
fecha_fin: evento.fecha_fin,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success('Evento actualizado correctamente');
|
||||||
|
handleOnChange(res.data); // Actualiza en el padre
|
||||||
|
} catch (error) {
|
||||||
|
const msg = getAxiosError(error);
|
||||||
|
toast.error(`Error al actualizar el evento: ${msg.message}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convertir evento a CreateEventoType para el preview
|
||||||
|
const eventoPreview: CreateEventoType = {
|
||||||
|
tipo_evento: evento.tipo_evento,
|
||||||
|
nombre_evento: evento.nombre_evento,
|
||||||
|
descripcion_evento: evento.descripcion_evento,
|
||||||
|
fecha_inicio: new Date(evento.fecha_inicio),
|
||||||
|
fecha_fin: new Date(evento.fecha_fin),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container-fluid my-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="row mb-4">
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="p-4 border rounded bg-light">
|
||||||
|
<h2 className="mb-2">Editar Información del Evento</h2>
|
||||||
|
<p className="mb-0 text-muted">
|
||||||
|
Modifica los datos del evento que desees actualizar y confirmalos.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
{/* Columna Izquierda - Formulario */}
|
||||||
|
<div className="col-xl-7 col-lg-6">
|
||||||
|
<div className="pe-lg-4">
|
||||||
|
<div className="p-4">
|
||||||
|
{/* Banner */}
|
||||||
|
<div className="mb-4">
|
||||||
|
{tipo_usuario === 'administrador' && (
|
||||||
|
<>
|
||||||
|
<ImageUploader
|
||||||
|
label="Banner del evento"
|
||||||
|
defaultBanner={
|
||||||
|
evento.banner
|
||||||
|
? `${process.env.NEXT_PUBLIC_API_URL}/banners/${evento.banner}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onChange={(file) => {
|
||||||
|
setNuevoBanner(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="text-muted mt-2 small">
|
||||||
|
<strong>
|
||||||
|
<i className="bi bi-info-circle-fill me-2"></i>
|
||||||
|
Esta es la imagen que se verá en el carrusel del evento.
|
||||||
|
</strong>
|
||||||
|
<br />
|
||||||
|
<strong>
|
||||||
|
<i className="bi bi-image me-2"></i>
|
||||||
|
Dimensiones recomendadas: 1200x600 píxeles.
|
||||||
|
</strong>
|
||||||
|
<br />
|
||||||
|
<strong>
|
||||||
|
<i className="bi bi-file-earmark-image me-2"></i>
|
||||||
|
Formatos soportados: PNG, JPG, JPEG.
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detalles del evento */}
|
||||||
|
<div className="col-12">
|
||||||
|
<h4 className="mb-3">Detalles del Evento</h4>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Nombre del evento"
|
||||||
|
required
|
||||||
|
value={evento.nombre_evento}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange('nombre_evento', e.target.value)
|
||||||
|
}
|
||||||
|
disabled={tipo_usuario !== 'administrador'}
|
||||||
|
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="form-label">Descripción del evento</label>
|
||||||
|
<div data-color-mode="light">
|
||||||
|
<MDEditor
|
||||||
|
value={evento.descripcion_evento ?? ''}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (tipo_usuario !== 'administrador') return;
|
||||||
|
const texto = value || '';
|
||||||
|
if (texto.length <= MAX_LENGTH) {
|
||||||
|
handleChange('descripcion_evento', texto);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
height={200}
|
||||||
|
commands={[commands.bold, commands.italic, commands.hr]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Tipo de evento"
|
||||||
|
value={evento.tipo_evento}
|
||||||
|
placeholder="Selecciona un tipo de evento"
|
||||||
|
onChange={(e) => handleChange('tipo_evento', e.target.value)}
|
||||||
|
options={tipoEventos}
|
||||||
|
disabled={tipo_usuario !== 'administrador'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<Input
|
||||||
|
label="Fecha de inicio"
|
||||||
|
type="datetime-local"
|
||||||
|
className={{ container: 'mb-3' }}
|
||||||
|
value={formatDateLocal(new Date(evento.fecha_inicio))}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange('fecha_inicio', new Date(e.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-md-6">
|
||||||
|
<Input
|
||||||
|
label="Fecha de fin"
|
||||||
|
type="datetime-local"
|
||||||
|
className={{ container: 'mb-3' }}
|
||||||
|
value={formatDateLocal(new Date(evento.fecha_fin))}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange('fecha_fin', new Date(e.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Botón de guardar */}
|
||||||
|
{tipo_usuario === "administrador" && <div className="d-flex justify-content-end mt-4">
|
||||||
|
<Button icon="save" disabled={loading} onClick={handleOnSave}>
|
||||||
|
{loading ? 'Guardando...' : 'Guardar Cambios'}
|
||||||
|
</Button>
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Columna Derecha - Preview */}
|
||||||
|
<div className="col-xl-5 col-lg-6">
|
||||||
|
<div className="ps-lg-4">
|
||||||
|
<div className="sticky-top" style={{ top: '20px' }}>
|
||||||
|
<h4 className="mb-3 d-none d-lg-block">Preview del Evento</h4>
|
||||||
|
<div className="d-lg-none mt-4">
|
||||||
|
<h4 className="mb-3">Preview del Evento</h4>
|
||||||
|
</div>
|
||||||
|
<EventoCardPreview
|
||||||
|
evento={eventoPreview}
|
||||||
|
eventoBanner={nuevoBanner}
|
||||||
|
existingBanner={evento.banner}
|
||||||
|
cuestionariosCount={evento.cuestionarios?.length || 0}
|
||||||
|
/>
|
||||||
|
<div className="alert alert-info text-center mt-3">
|
||||||
|
<strong>Vista previa:</strong> Así se verá tu evento en las
|
||||||
|
listas de eventos.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import ImageUploader from '@/components/banner-uploader';
|
||||||
|
import Button from '@/components/button';
|
||||||
|
import SimpleInput from '@/components/input';
|
||||||
|
import FormularioCardPreview from '@/components/formulario/formulario-card-preview';
|
||||||
|
import { GetCuestionario } from '@/types/cuestionario';
|
||||||
|
import { GetEvento } from '@/types/evento';
|
||||||
|
import axiosInstance from '@/utils/api-config';
|
||||||
|
import { formatDateLocal } from '@/utils/date-utils';
|
||||||
|
import { getAxiosError } from '@/utils/errors-utils';
|
||||||
|
import MDEditor, { commands } from '@uiw/react-md-editor';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface EditFormularioProps {
|
||||||
|
cuestionario: GetCuestionario;
|
||||||
|
evento: GetEvento | null;
|
||||||
|
handleChange: (field: keyof GetCuestionario, value: string | Date) => void;
|
||||||
|
handleOnChange: (eventoActualizado: GetCuestionario) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditFormulario({
|
||||||
|
cuestionario,
|
||||||
|
evento,
|
||||||
|
handleChange,
|
||||||
|
}: EditFormularioProps) {
|
||||||
|
const tipo_usuario = sessionStorage.getItem('axxxd')?.replace(/"/g, '') || '';
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [nuevoBanner, setNuevoBanner] = useState<File | null>(null);
|
||||||
|
|
||||||
|
const handleOnSave = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// 1. Subir nuevo banner si fue cambiado
|
||||||
|
if (nuevoBanner) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('banner', nuevoBanner);
|
||||||
|
|
||||||
|
await axiosInstance.post(
|
||||||
|
`/cuestionario/${cuestionario.id_cuestionario}/banner`,
|
||||||
|
formData,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Actualizar el evento (sin incluir 'banner')
|
||||||
|
const res = await axiosInstance.patch(
|
||||||
|
`/cuestionario/${cuestionario.id_cuestionario}`,
|
||||||
|
{
|
||||||
|
nombre_form: cuestionario.nombre_form,
|
||||||
|
descripcion: cuestionario.descripcion,
|
||||||
|
fecha_inicio: cuestionario.fecha_inicio,
|
||||||
|
fecha_fin: cuestionario.fecha_fin,
|
||||||
|
cupo_maximo: Number(cuestionario.cupo_maximo) || null,
|
||||||
|
mensaje_correo: cuestionario.mensaje_correo || null,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Evento actualizado:', res.data);
|
||||||
|
|
||||||
|
toast.success('Evento actualizado correctamente');
|
||||||
|
} catch (error) {
|
||||||
|
const msg = getAxiosError(error);
|
||||||
|
toast.error(`Error al actualizar el evento: ${msg.message}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container-fluid my-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="row mb-4">
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="p-4 border rounded bg-light">
|
||||||
|
<h2 className="mb-2">Editar Información del Formulario</h2>
|
||||||
|
<p className="mb-0 text-muted">
|
||||||
|
Modifica los datos del formulario que desees actualizar y
|
||||||
|
confirmalos.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
{/* Columna Izquierda - Formulario */}
|
||||||
|
<div className="col-xl-7 col-lg-6">
|
||||||
|
<div className="pe-lg-4">
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="row">
|
||||||
|
{/* Banner */}
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="mb-4">
|
||||||
|
<ImageUploader
|
||||||
|
label="Banner del formulario"
|
||||||
|
defaultBanner={
|
||||||
|
cuestionario.banner
|
||||||
|
? `${process.env.NEXT_PUBLIC_API_URL}/banners/${cuestionario.banner}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onChange={(file) => {
|
||||||
|
setNuevoBanner(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="text-muted mt-2 small">
|
||||||
|
<strong>
|
||||||
|
<i className="bi bi-info-circle-fill me-2"></i>
|
||||||
|
Esta es la imagen que se usara para la carta de
|
||||||
|
presentación del formulario.
|
||||||
|
</strong>
|
||||||
|
<br />
|
||||||
|
<strong>
|
||||||
|
<i className="bi bi-image me-2"></i>
|
||||||
|
Dimensiones recomendadas: 1200x600 píxeles.
|
||||||
|
</strong>
|
||||||
|
<br />
|
||||||
|
<strong>
|
||||||
|
<i className="bi bi-file-earmark-image me-2"></i>
|
||||||
|
Formatos soportados: PNG, JPG, JPEG.
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Alert sobre imagen por defecto */}
|
||||||
|
{!cuestionario.banner && !nuevoBanner && evento?.banner && (
|
||||||
|
<div className="alert alert-info mt-3">
|
||||||
|
<i className="bi bi-info-circle me-2"></i>
|
||||||
|
<strong>Imagen por defecto:</strong> Si no seleccionas
|
||||||
|
una imagen específica para este formulario, se utilizará
|
||||||
|
automáticamente la imagen del evento.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detalles del formulario */}
|
||||||
|
<div className="col-12">
|
||||||
|
<h4 className="mb-3">Detalles del Formulario</h4>
|
||||||
|
|
||||||
|
<SimpleInput
|
||||||
|
label="Nombre del formulario"
|
||||||
|
required
|
||||||
|
value={cuestionario.nombre_form}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange('nombre_form', e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SimpleInput
|
||||||
|
label="Mensaje de correo"
|
||||||
|
value={cuestionario.mensaje_correo ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange('mensaje_correo', e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SimpleInput
|
||||||
|
label="Cupo máximo (Si no se requiere, dejar en blanco)"
|
||||||
|
type="number"
|
||||||
|
value={cuestionario.cupo_maximo ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange(
|
||||||
|
'cupo_maximo',
|
||||||
|
e.target.value ? e.target.value : ''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="form-label">
|
||||||
|
Descripción del formulario
|
||||||
|
</label>
|
||||||
|
<div data-color-mode="light">
|
||||||
|
<MDEditor
|
||||||
|
value={cuestionario.descripcion ?? ''}
|
||||||
|
onChange={(value) => {
|
||||||
|
const texto = value || '';
|
||||||
|
if (texto.length <= 500) {
|
||||||
|
handleChange(
|
||||||
|
'descripcion' as keyof GetCuestionario,
|
||||||
|
texto
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
height={200}
|
||||||
|
commands={[commands.bold, commands.italic, commands.hr]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<SimpleInput
|
||||||
|
label="Fecha de inicio"
|
||||||
|
type="datetime-local"
|
||||||
|
className={{ container: 'mb-3' }}
|
||||||
|
value={formatDateLocal(
|
||||||
|
new Date(cuestionario.fecha_inicio)
|
||||||
|
)}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange('fecha_inicio', new Date(e.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-md-6">
|
||||||
|
<SimpleInput
|
||||||
|
label="Fecha de fin"
|
||||||
|
type="datetime-local"
|
||||||
|
className={{ container: 'mb-3' }}
|
||||||
|
value={formatDateLocal(
|
||||||
|
new Date(cuestionario.fecha_fin)
|
||||||
|
)}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange('fecha_fin', new Date(e.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Botón de guardar */}
|
||||||
|
{tipo_usuario === "administrador" && (
|
||||||
|
<div className="d-flex justify-content-end mt-4">
|
||||||
|
<Button icon="save" disabled={loading} onClick={handleOnSave}>
|
||||||
|
{loading ? 'Guardando...' : 'Guardar Cambios'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Columna Derecha - Preview */}
|
||||||
|
<div className="col-xl-5 col-lg-6">
|
||||||
|
<div className="ps-lg-4">
|
||||||
|
<div className="sticky-top" style={{ top: '20px' }}>
|
||||||
|
<h4 className="mb-3 d-none d-lg-block">Preview del Formulario</h4>
|
||||||
|
<div className="d-lg-none mt-4">
|
||||||
|
<h4 className="mb-3">Preview del Formulario</h4>
|
||||||
|
</div>
|
||||||
|
<FormularioCardPreview
|
||||||
|
cuestionario={cuestionario}
|
||||||
|
evento={evento}
|
||||||
|
nuevoBanner={nuevoBanner}
|
||||||
|
/>
|
||||||
|
<div className="alert alert-info text-center mt-3">
|
||||||
|
<strong>Vista previa:</strong> Así se verá tu formulario en las
|
||||||
|
listas de formularios.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
'use client';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { SubmitResponse } from '@/types/submit';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
response: SubmitResponse;
|
||||||
|
qrImageUrl: string | null;
|
||||||
|
nombreEvento?: string;
|
||||||
|
nombreFormulario?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FormularioExito({
|
||||||
|
response,
|
||||||
|
qrImageUrl,
|
||||||
|
nombreEvento,
|
||||||
|
nombreFormulario,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div className="container my-5">
|
||||||
|
<div className="row justify-content-center">
|
||||||
|
<div className="col-12 col-lg-10 col-xl-8">
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="row g-4">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<div className="h-100 d-flex flex-column justify-content-center">
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<div className="mb-3">
|
||||||
|
<div
|
||||||
|
className="d-inline-flex align-items-center justify-content-center bg-success rounded-circle"
|
||||||
|
style={{ width: '60px', height: '60px' }}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className="bi bi-check-lg text-white"
|
||||||
|
style={{ fontSize: '2rem' }}
|
||||||
|
></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 className="fw-bold text-success mb-2">¡Felicidades!</h2>
|
||||||
|
<p className="text-muted mb-0">
|
||||||
|
Has completado exitosamente el formulario
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{nombreEvento && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="d-flex align-items-center mb-2">
|
||||||
|
<i
|
||||||
|
className="bi bi-calendar-event text-primary me-3"
|
||||||
|
style={{ fontSize: '1.3rem' }}
|
||||||
|
></i>
|
||||||
|
<h6 className="mb-0 text-primary">Evento</h6>
|
||||||
|
</div>
|
||||||
|
<p className="mb-0 fs-6 fw-semibold ms-5">{nombreEvento}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{nombreFormulario && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="d-flex align-items-center mb-2">
|
||||||
|
<i
|
||||||
|
className="bi bi-file-text text-info me-3"
|
||||||
|
style={{ fontSize: '1.3rem' }}
|
||||||
|
></i>
|
||||||
|
<h6 className="mb-0 text-info">Formulario</h6>
|
||||||
|
</div>
|
||||||
|
<p className="mb-0 fs-6 fw-semibold ms-5">{nombreFormulario}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{response.message && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="alert alert-info" role="alert">
|
||||||
|
<div className="d-flex align-items-start">
|
||||||
|
<i className="bi bi-info-circle me-2 mt-1"></i>
|
||||||
|
<div>
|
||||||
|
<strong>Mensaje:</strong>
|
||||||
|
<p className="mb-0 mt-1">{response.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-md-6">
|
||||||
|
<div className="h-100 d-flex flex-column justify-content-center align-items-center">
|
||||||
|
{qrImageUrl ? (
|
||||||
|
<div className="text-center">
|
||||||
|
<h5 className="mb-3 text-secondary">
|
||||||
|
<i className="bi bi-qr-code me-2"></i>
|
||||||
|
Código QR
|
||||||
|
</h5>
|
||||||
|
<div className="p-3 bg-white rounded shadow-sm">
|
||||||
|
<Image
|
||||||
|
src={qrImageUrl}
|
||||||
|
alt="Código QR"
|
||||||
|
width={250}
|
||||||
|
height={250}
|
||||||
|
className="rounded"
|
||||||
|
style={{ maxWidth: '100%', height: 'auto' }}
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-muted">
|
||||||
|
<i
|
||||||
|
className="bi bi-qr-code-scan"
|
||||||
|
style={{ fontSize: '4rem' }}
|
||||||
|
></i>
|
||||||
|
<p className="mt-3 mb-0">No hay código QR disponible</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center mt-4">
|
||||||
|
<Link href="/" className="btn btn-primary btn-lg me-3">
|
||||||
|
Ir al inicio
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,591 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useGetApi } from '@/hooks/use-get-api';
|
||||||
|
import { GetCuestionario } from '@/types/evento';
|
||||||
|
import SimpleInput from '@/components/input';
|
||||||
|
import RadioOptionGroup, { RadioOption } from '@/components/radio-option-group';
|
||||||
|
import axiosInstance from '@/utils/api-config';
|
||||||
|
import PrefetchAbiertaCorta from './prefetch-abierta-corta';
|
||||||
|
import Button from '@/components/button';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { getAxiosError } from '@/utils/errors-utils';
|
||||||
|
import { SubmitResponse } from '@/types/submit';
|
||||||
|
|
||||||
|
export type UsuarioDataResponse = {
|
||||||
|
cuenta: string | null;
|
||||||
|
nombre: string | null;
|
||||||
|
apellidos: string | null;
|
||||||
|
carrera: string | null;
|
||||||
|
genero: string | null;
|
||||||
|
rfc?: string | null; // Solo para trabajadores
|
||||||
|
};
|
||||||
|
|
||||||
|
type UsuarioData = {
|
||||||
|
nombre: string; // alumno o trabajador
|
||||||
|
apellidos: string; // alumno o trabajador
|
||||||
|
correo: string; // alumno o trabajador (en los alumnos se usa el id_ncuenta en trabajadores ellos deben escribirlo)
|
||||||
|
genero: 'M' | 'F'; // alumno o trabajador
|
||||||
|
carrera: string; // solo para alumnos
|
||||||
|
rfc?: string; // solo para trabajadores se usa para buscar información de cuenta
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmativeOption = ['Sí', 'Si', 'Si, claro', 'Claro que sí'];
|
||||||
|
const negativeOption = ['No', 'No, gracias', 'No, de momento'];
|
||||||
|
|
||||||
|
type RespuestaFormulario = Record<string, string | number>; // id_pregunta: respuesta
|
||||||
|
|
||||||
|
export default function FormularioRegistro({
|
||||||
|
id_cuestionario,
|
||||||
|
handleSubmitFormulario,
|
||||||
|
}: {
|
||||||
|
evento: string;
|
||||||
|
cuestionario: string;
|
||||||
|
id_evento: number;
|
||||||
|
id_cuestionario: number;
|
||||||
|
handleSubmitFormulario: (data: SubmitResponse) => void;
|
||||||
|
}) {
|
||||||
|
// ------------------------
|
||||||
|
// Estados
|
||||||
|
// ------------------------
|
||||||
|
const [isComunidad, setIsComunidad] = useState<RadioOption<number>>();
|
||||||
|
const [respuestas, setRespuestas] = useState<RespuestaFormulario>({});
|
||||||
|
const [cuentaInfo, setCuentaInfo] = useState<UsuarioData | null>(null);
|
||||||
|
const [isSending, setIsSending] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// ------------------------
|
||||||
|
// Carga del cuestionario
|
||||||
|
// ------------------------
|
||||||
|
const { data, error } = useGetApi<GetCuestionario>(
|
||||||
|
`/cuestionario/${id_cuestionario}/formulario`
|
||||||
|
);
|
||||||
|
// ------------------------
|
||||||
|
// Extracción de preguntas clave
|
||||||
|
// ------------------------
|
||||||
|
const preguntas = data?.cuestionario.secciones.flatMap((seccion) =>
|
||||||
|
seccion.preguntas.map((pregunta) => pregunta.pregunta)
|
||||||
|
);
|
||||||
|
|
||||||
|
const preguntaComunidad = preguntas?.find(
|
||||||
|
(pregunta) =>
|
||||||
|
pregunta.validacion === 'comunidad_alumno' ||
|
||||||
|
pregunta.validacion === 'comunidad_trabajador'
|
||||||
|
);
|
||||||
|
|
||||||
|
const preguntaCuenta = preguntas?.find(
|
||||||
|
(pregunta) =>
|
||||||
|
pregunta.validacion === 'cuenta_alumno' ||
|
||||||
|
pregunta.validacion === 'cuenta_trabajador' ||
|
||||||
|
pregunta.validacion === 'rfc'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Solución: Agregar estado para controlar si ya se hizo la búsqueda
|
||||||
|
const [cuentaBuscada, setCuentaBuscada] = useState<string>(''); // Nuevo estado
|
||||||
|
|
||||||
|
// ------------------------
|
||||||
|
// Efecto: buscar información de cuenta
|
||||||
|
// ------------------------
|
||||||
|
useEffect(() => {
|
||||||
|
const cuentaId = preguntaCuenta?.id_pregunta ?? '';
|
||||||
|
const cuenta = respuestas[cuentaId];
|
||||||
|
|
||||||
|
const esCuentaAlumno = preguntaCuenta?.validacion === 'cuenta_alumno';
|
||||||
|
const esCuentaTrabajador =
|
||||||
|
preguntaCuenta?.validacion === 'cuenta_trabajador' ||
|
||||||
|
preguntaCuenta?.validacion === 'rfc';
|
||||||
|
|
||||||
|
const puedeBuscar =
|
||||||
|
cuenta &&
|
||||||
|
typeof cuenta === 'string' &&
|
||||||
|
((esCuentaAlumno && cuenta.length === 9) ||
|
||||||
|
(esCuentaTrabajador && cuenta.length === 10));
|
||||||
|
|
||||||
|
const fetchCuentaInfo = async () => {
|
||||||
|
try {
|
||||||
|
const endpoint = esCuentaAlumno
|
||||||
|
? `/alumnos/${cuenta}`
|
||||||
|
: `/trabajadores/${cuenta}`;
|
||||||
|
|
||||||
|
const res = await axiosInstance.get<UsuarioDataResponse>(endpoint);
|
||||||
|
|
||||||
|
console.log('Datos de cuenta obtenidos:', res.data);
|
||||||
|
|
||||||
|
// Verificar si realmente hay datos válidos
|
||||||
|
const hayDatosValidos =
|
||||||
|
res.data.nombre ||
|
||||||
|
res.data.apellidos ||
|
||||||
|
res.data.carrera ||
|
||||||
|
res.data.genero;
|
||||||
|
|
||||||
|
if (hayDatosValidos) {
|
||||||
|
setCuentaInfo({
|
||||||
|
nombre: res.data.nombre ? res.data.nombre : '',
|
||||||
|
apellidos: res.data.apellidos ? res.data.apellidos : '',
|
||||||
|
correo: res.data.cuenta
|
||||||
|
? `${res.data.cuenta}@pcpuma.acatlan.unam.mx`
|
||||||
|
: '',
|
||||||
|
genero:
|
||||||
|
res.data.genero === 'M' || res.data.genero === 'F'
|
||||||
|
? res.data.genero
|
||||||
|
: 'M',
|
||||||
|
carrera: res.data.carrera ? res.data.carrera : '',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Si no hay datos válidos, establecer como null
|
||||||
|
setCuentaInfo(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marcar que ya se buscó esta cuenta
|
||||||
|
setCuentaBuscada(cuenta as string);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al obtener datos de cuenta:', error);
|
||||||
|
setCuentaInfo(null);
|
||||||
|
setCuentaBuscada(cuenta as string); // También marcar en caso de error
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Solo buscar si puede buscar Y no se ha buscado ya esta cuenta
|
||||||
|
if (puedeBuscar && cuentaBuscada !== cuenta) {
|
||||||
|
fetchCuentaInfo();
|
||||||
|
} else if (!puedeBuscar) {
|
||||||
|
setCuentaInfo(null);
|
||||||
|
setCuentaBuscada(''); // Reset cuando no se puede buscar
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
respuestas,
|
||||||
|
preguntaCuenta?.id_pregunta,
|
||||||
|
preguntaCuenta?.validacion,
|
||||||
|
cuentaBuscada,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// También resetear cuentaBuscada cuando cambie el número de cuenta
|
||||||
|
useEffect(() => {
|
||||||
|
const cuentaId = preguntaCuenta?.id_pregunta ?? '';
|
||||||
|
const cuenta = respuestas[cuentaId];
|
||||||
|
|
||||||
|
// Si la cuenta cambió, resetear el estado de búsqueda
|
||||||
|
if (cuentaBuscada && cuenta !== cuentaBuscada) {
|
||||||
|
setCuentaBuscada('');
|
||||||
|
}
|
||||||
|
}, [respuestas, preguntaCuenta?.id_pregunta, cuentaBuscada]);
|
||||||
|
|
||||||
|
// ------------------------
|
||||||
|
// Efecto: precargar respuestas si hay cuentaInfo
|
||||||
|
// ------------------------
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cuentaInfo) {
|
||||||
|
if (data?.cuestionario.secciones) {
|
||||||
|
const preguntas = data.cuestionario.secciones.flatMap((seccion) =>
|
||||||
|
seccion.preguntas.map((pregunta) => pregunta.pregunta)
|
||||||
|
);
|
||||||
|
|
||||||
|
const idsPrefetch = preguntas
|
||||||
|
.filter((pregunta) =>
|
||||||
|
[
|
||||||
|
'nombre',
|
||||||
|
'apellidos',
|
||||||
|
'correo',
|
||||||
|
'institucion',
|
||||||
|
'carrera',
|
||||||
|
'genero',
|
||||||
|
].includes(pregunta.validacion)
|
||||||
|
)
|
||||||
|
.map((pregunta) => pregunta.id_pregunta);
|
||||||
|
|
||||||
|
setRespuestas((prev) => {
|
||||||
|
const nuevas = { ...prev };
|
||||||
|
idsPrefetch.forEach((id) => {
|
||||||
|
delete nuevas[id];
|
||||||
|
});
|
||||||
|
return nuevas;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nuevasRespuestas: RespuestaFormulario = {};
|
||||||
|
|
||||||
|
if (data?.cuestionario.secciones) {
|
||||||
|
const preguntas = data.cuestionario.secciones.flatMap((seccion) =>
|
||||||
|
seccion.preguntas.map((pregunta) => pregunta.pregunta)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const pregunta of preguntas) {
|
||||||
|
const id = pregunta.id_pregunta;
|
||||||
|
switch (pregunta.validacion) {
|
||||||
|
case 'nombre':
|
||||||
|
nuevasRespuestas[id] = cuentaInfo.nombre;
|
||||||
|
break;
|
||||||
|
case 'apellidos':
|
||||||
|
nuevasRespuestas[id] = cuentaInfo.apellidos;
|
||||||
|
break;
|
||||||
|
case 'correo':
|
||||||
|
nuevasRespuestas[id] = cuentaInfo.correo;
|
||||||
|
break;
|
||||||
|
case 'institucion':
|
||||||
|
nuevasRespuestas[id] = 'FES Acatlán';
|
||||||
|
break;
|
||||||
|
case 'carrera':
|
||||||
|
nuevasRespuestas[id] = cuentaInfo.carrera;
|
||||||
|
break;
|
||||||
|
case 'genero':
|
||||||
|
const generoTexto =
|
||||||
|
cuentaInfo.genero === 'M' ? 'Masculino' : 'Femenino';
|
||||||
|
const opcion = pregunta.opciones?.find(
|
||||||
|
(op) =>
|
||||||
|
op.opcion.opcion.toLowerCase() === generoTexto.toLowerCase()
|
||||||
|
);
|
||||||
|
if (opcion) {
|
||||||
|
nuevasRespuestas[id] = String(opcion.id_opcion);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setRespuestas((prev) => ({
|
||||||
|
...prev,
|
||||||
|
...nuevasRespuestas,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [cuentaInfo, data?.cuestionario.secciones]);
|
||||||
|
|
||||||
|
// ------------------------
|
||||||
|
// Funciones auxiliares
|
||||||
|
// ------------------------
|
||||||
|
const actualizarRespuesta = (idPregunta: string, valor: string) => {
|
||||||
|
setRespuestas((prev) => ({ ...prev, [idPregunta]: valor }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOnSubmit = async () => {
|
||||||
|
setIsSending(true);
|
||||||
|
if (!data?.cuestionario.id_cuestionario) {
|
||||||
|
alert('No se ha cargado correctamente el formulario');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preguntasObligatorias =
|
||||||
|
data?.cuestionario.secciones.flatMap((seccion) =>
|
||||||
|
seccion.preguntas
|
||||||
|
.filter((pregunta) => pregunta.pregunta.obligatoria)
|
||||||
|
.map((pregunta) => pregunta.pregunta)
|
||||||
|
) || [];
|
||||||
|
|
||||||
|
const faltantes = preguntasObligatorias.filter(
|
||||||
|
(pregunta) =>
|
||||||
|
respuestas[pregunta.id_pregunta] === undefined ||
|
||||||
|
respuestas[pregunta.id_pregunta] === '' ||
|
||||||
|
respuestas[pregunta.id_pregunta] === null
|
||||||
|
);
|
||||||
|
|
||||||
|
const preguntaCorreo = preguntasObligatorias.find(
|
||||||
|
(pregunta) =>
|
||||||
|
pregunta.validacion === 'correo' ||
|
||||||
|
pregunta.validacion === 'correo_institucional'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (faltantes.length > 0) {
|
||||||
|
toast.error('Por favor responde todas las preguntas obligatorias.');
|
||||||
|
setIsSending(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar un posible correo en las respuestas si no hay cuentaInfo
|
||||||
|
let correo =
|
||||||
|
cuentaInfo?.correo === respuestas[preguntaCorreo?.id_pregunta ?? '']
|
||||||
|
? cuentaInfo?.correo
|
||||||
|
: respuestas[preguntaCorreo?.id_pregunta ?? ''];
|
||||||
|
|
||||||
|
if (!correo) {
|
||||||
|
// Buscar en las respuestas una que parezca correo
|
||||||
|
const correoRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
for (const valor of Object.values(respuestas)) {
|
||||||
|
if (typeof valor === 'string' && correoRegex.test(valor)) {
|
||||||
|
correo = valor;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
id_cuestionario: id_cuestionario,
|
||||||
|
correo: correo || '',
|
||||||
|
fecha_envio: new Date().toISOString(),
|
||||||
|
respuestas: Object.entries(respuestas).map(([id_pregunta, valor]) => ({
|
||||||
|
id_pregunta: Number(id_pregunta),
|
||||||
|
valor: isNaN(Number(valor)) ? valor : Number(valor),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await axiosInstance.post<SubmitResponse>(
|
||||||
|
'/cuestionario-respondido/submit',
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
toast(
|
||||||
|
() => (
|
||||||
|
<span
|
||||||
|
className="fs-4 pc-3 py-2"
|
||||||
|
style={{
|
||||||
|
maxWidth: '300px',
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
whiteSpace: 'normal',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{res.data.message.includes('ya ha respondido') ? (
|
||||||
|
<>
|
||||||
|
<i className="bi bi-exclamation-circle-fill text-warning me-2"></i>
|
||||||
|
Respuestas Enviadas Con Éxito
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<i className="bi bi-check-circle-fill text-success me-2"></i>
|
||||||
|
{res.data.message || '¡Formulario enviado!'}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
{
|
||||||
|
duration: 6000,
|
||||||
|
position: 'bottom-center',
|
||||||
|
style: {
|
||||||
|
maxWidth: '425px',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
handleSubmitFormulario(res.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(getAxiosError(error));
|
||||||
|
toast.error('Hubo un error al enviar el formulario', {
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
router.push('/');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------
|
||||||
|
// Condición para mostrar el formulario
|
||||||
|
// ------------------------
|
||||||
|
const mostrarFormularioRestante =
|
||||||
|
!preguntaComunidad || // Si no hay pregunta de comunidad, mostrar el formulario
|
||||||
|
(isComunidad &&
|
||||||
|
confirmativeOption.some((opt) =>
|
||||||
|
isComunidad.label?.toLowerCase().includes(opt.toLowerCase())
|
||||||
|
) &&
|
||||||
|
!!cuentaInfo) ||
|
||||||
|
(isComunidad &&
|
||||||
|
negativeOption.some((opt) =>
|
||||||
|
isComunidad.label?.toLowerCase().includes(opt.toLowerCase())
|
||||||
|
));
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error al cargar el cuestionario:', error);
|
||||||
|
return <div>Error al cargar el cuestionario</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{preguntaComunidad && (
|
||||||
|
<div className="my-4">
|
||||||
|
<label
|
||||||
|
className={`form-label ${
|
||||||
|
preguntaComunidad.obligatoria ? 'required' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{preguntaComunidad.pregunta}
|
||||||
|
</label>
|
||||||
|
<RadioOptionGroup
|
||||||
|
name="comunidad"
|
||||||
|
options={preguntaComunidad.opciones.map((op) => ({
|
||||||
|
label: op.opcion.opcion,
|
||||||
|
value: op.id_opcion,
|
||||||
|
}))}
|
||||||
|
selectedValue={isComunidad?.value}
|
||||||
|
onChange={(opt) => {
|
||||||
|
console.log('Opción comunidad seleccionada:', opt);
|
||||||
|
setIsComunidad(opt);
|
||||||
|
actualizarRespuesta(
|
||||||
|
preguntaComunidad.id_pregunta.toString(),
|
||||||
|
String(opt.value)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isComunidad &&
|
||||||
|
confirmativeOption.some((opt) =>
|
||||||
|
isComunidad.label?.toLowerCase().includes(opt.toLowerCase())
|
||||||
|
) &&
|
||||||
|
preguntaCuenta && (
|
||||||
|
<div className="my-4">
|
||||||
|
<label
|
||||||
|
className={`form-label ${
|
||||||
|
preguntaCuenta.obligatoria ? 'required' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{preguntaCuenta.pregunta}
|
||||||
|
</label>
|
||||||
|
<SimpleInput
|
||||||
|
type="text"
|
||||||
|
name="cuenta"
|
||||||
|
placeholder={
|
||||||
|
preguntaCuenta.validacion === 'cuenta_alumno'
|
||||||
|
? 'Ingrese su cuenta'
|
||||||
|
: preguntaCuenta?.validacion === 'rfc'
|
||||||
|
? 'Ingrese su RFC sin homoclave'
|
||||||
|
: preguntaCuenta?.validacion === 'cuenta_trabajador'
|
||||||
|
? 'Ingrese su numero de trabajador'
|
||||||
|
: 'Ingrese su ' + preguntaCuenta.pregunta
|
||||||
|
}
|
||||||
|
maxLength={10}
|
||||||
|
value={respuestas[preguntaCuenta.id_pregunta] || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRespuestas((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[preguntaCuenta.id_pregunta]: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mostrarFormularioRestante &&
|
||||||
|
data?.cuestionario.secciones.map((seccion, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
{seccion.preguntas.map((pregunta) => {
|
||||||
|
// Evitar renderizar las preguntas ya manejadas
|
||||||
|
if (
|
||||||
|
pregunta.pregunta.id_pregunta ===
|
||||||
|
preguntaComunidad?.id_pregunta ||
|
||||||
|
pregunta.pregunta.id_pregunta === preguntaCuenta?.id_pregunta
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
pregunta.pregunta.tipo_pregunta.tipo_pregunta ===
|
||||||
|
'Abierta (Respuesta corta)'
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<PrefetchAbiertaCorta
|
||||||
|
key={pregunta.pregunta.id_pregunta}
|
||||||
|
obligatorio={pregunta.pregunta.obligatoria}
|
||||||
|
idPregunta={pregunta.pregunta.id_pregunta}
|
||||||
|
enunciado={pregunta.pregunta.pregunta}
|
||||||
|
validacion={pregunta.pregunta.validacion}
|
||||||
|
respuestas={respuestas}
|
||||||
|
cuentaInfo={cuentaInfo}
|
||||||
|
isComunidad={isComunidad?.label}
|
||||||
|
onChange={actualizarRespuesta}
|
||||||
|
preguntaComunidad={preguntaComunidad?.validacion ?? ''}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
pregunta.pregunta.tipo_pregunta.tipo_pregunta ===
|
||||||
|
'Abierta (Parrafo)'
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div key={pregunta.pregunta.id_pregunta} className="my-4">
|
||||||
|
<label
|
||||||
|
className={`form-label ${
|
||||||
|
pregunta.pregunta.obligatoria ? 'required' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pregunta.pregunta.pregunta}
|
||||||
|
</label>
|
||||||
|
<SimpleInput
|
||||||
|
type="textarea"
|
||||||
|
name={`respuesta_${pregunta.pregunta.id_pregunta}`}
|
||||||
|
placeholder="Ingrese una respuesta"
|
||||||
|
value={respuestas[pregunta.pregunta.id_pregunta] || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRespuestas((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[pregunta.pregunta.id_pregunta]: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pregunta.pregunta.tipo_pregunta.tipo_pregunta === 'Cerrada') {
|
||||||
|
const opciones: RadioOption<number>[] =
|
||||||
|
pregunta.pregunta.opciones.map((op) => ({
|
||||||
|
label: op.opcion.opcion,
|
||||||
|
value: op.id_opcion,
|
||||||
|
}));
|
||||||
|
let respuestaActual =
|
||||||
|
respuestas[`${pregunta.pregunta.id_pregunta}`];
|
||||||
|
|
||||||
|
if (
|
||||||
|
(!preguntaComunidad ||
|
||||||
|
(confirmativeOption.includes(isComunidad?.label || '') &&
|
||||||
|
cuentaInfo)) &&
|
||||||
|
respuestaActual === undefined // solo si no ha respondido
|
||||||
|
) {
|
||||||
|
if (pregunta.pregunta.validacion === 'genero' && cuentaInfo) {
|
||||||
|
const generoTexto =
|
||||||
|
cuentaInfo.genero === 'M' ? 'Masculino' : 'Femenino';
|
||||||
|
|
||||||
|
const opcionGenero = pregunta.pregunta.opciones.find(
|
||||||
|
(op) =>
|
||||||
|
op.opcion.opcion.toLowerCase() ===
|
||||||
|
generoTexto.toLowerCase()
|
||||||
|
);
|
||||||
|
if (opcionGenero) {
|
||||||
|
respuestaActual = String(opcionGenero.id_opcion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={pregunta.pregunta.id_pregunta}>
|
||||||
|
<label
|
||||||
|
className={`form-label ${
|
||||||
|
pregunta.pregunta.obligatoria ? 'required' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pregunta.pregunta.pregunta}
|
||||||
|
</label>
|
||||||
|
<RadioOptionGroup
|
||||||
|
name={`pregunta_${pregunta.pregunta.id_pregunta}`}
|
||||||
|
options={opciones}
|
||||||
|
selectedValue={
|
||||||
|
respuestaActual ? Number(respuestaActual) : undefined
|
||||||
|
}
|
||||||
|
onChange={(opt) => {
|
||||||
|
actualizarRespuesta(
|
||||||
|
`${pregunta.pregunta.id_pregunta}`,
|
||||||
|
String(opt.value)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={pregunta.pregunta.id_pregunta} className="my-4">
|
||||||
|
<label
|
||||||
|
className={`form-label ${
|
||||||
|
pregunta.pregunta.obligatoria ? 'required' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pregunta.pregunta.pregunta}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleOnSubmit}
|
||||||
|
disabled={!mostrarFormularioRestante || isSending}
|
||||||
|
>
|
||||||
|
{isSending ? 'Enviando...' : 'Enviar Formulario'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { SubmitResponse } from '@/types/submit';
|
||||||
|
import SimpleInput from '@/components/input';
|
||||||
|
import RadioOptionGroup, { RadioOption } from '@/components/radio-option-group';
|
||||||
|
import Button from '@/components/button';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { useFormulario } from '@/context/formulario';
|
||||||
|
|
||||||
|
export type UsuarioDataResponse = {
|
||||||
|
cuenta: string | null;
|
||||||
|
nombre: string | null;
|
||||||
|
apellidos: string | null;
|
||||||
|
carrera: string | null;
|
||||||
|
genero: string | null;
|
||||||
|
rfc?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FormularioRegistro({
|
||||||
|
handleSubmitFormulario,
|
||||||
|
}: {
|
||||||
|
evento: string;
|
||||||
|
cuestionario: string;
|
||||||
|
id_evento: number;
|
||||||
|
id_cuestionario: number;
|
||||||
|
handleSubmitFormulario: (data: SubmitResponse) => void;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
state,
|
||||||
|
preguntaComunidad,
|
||||||
|
preguntaCuenta,
|
||||||
|
esComunidadSi,
|
||||||
|
esExclusivo,
|
||||||
|
mostrarFormularioCompleto,
|
||||||
|
setRespuesta,
|
||||||
|
submitFormulario,
|
||||||
|
} = useFormulario();
|
||||||
|
|
||||||
|
const { data, loadingData, errorData, respuestas, isSending, errorBusqueda, loadingBusqueda } = state;
|
||||||
|
|
||||||
|
const comunidadSeleccionada = preguntaComunidad
|
||||||
|
? respuestas[preguntaComunidad.id_pregunta]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const handleOnSubmit = async () => {
|
||||||
|
if (!data?.cuestionario.id_cuestionario) {
|
||||||
|
toast.error('No se ha cargado correctamente el formulario');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preguntasObligatorias = data.cuestionario.secciones.flatMap((seccion) =>
|
||||||
|
seccion.preguntas
|
||||||
|
.filter((p) => p.pregunta.obligatoria)
|
||||||
|
.map((p) => p.pregunta)
|
||||||
|
);
|
||||||
|
|
||||||
|
const faltantes = preguntasObligatorias.filter(
|
||||||
|
(p) =>
|
||||||
|
respuestas[p.id_pregunta] === undefined ||
|
||||||
|
respuestas[p.id_pregunta] === '' ||
|
||||||
|
respuestas[p.id_pregunta] === null
|
||||||
|
);
|
||||||
|
|
||||||
|
if (faltantes.length > 0) {
|
||||||
|
toast.error('Por favor responde todas las preguntas obligatorias.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await submitFormulario(data.cuestionario.id_cuestionario);
|
||||||
|
toast.success(res.message || '¡Formulario enviado exitosamente!');
|
||||||
|
handleSubmitFormulario(res);
|
||||||
|
} catch {
|
||||||
|
toast.error('Hubo un error al enviar el formulario');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (errorData) {
|
||||||
|
return <div>Error al cargar el cuestionario</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadingData || !data) {
|
||||||
|
return <div>Cargando...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Pregunta de comunidad */}
|
||||||
|
{preguntaComunidad && (
|
||||||
|
<div className="my-4">
|
||||||
|
<label className={`form-label ${preguntaComunidad.obligatoria ? 'required' : ''}`}>
|
||||||
|
{preguntaComunidad.pregunta}
|
||||||
|
</label>
|
||||||
|
<RadioOptionGroup
|
||||||
|
name="comunidad"
|
||||||
|
options={preguntaComunidad.opciones.map((op) => ({
|
||||||
|
label: op.opcion.opcion,
|
||||||
|
value: op.id_opcion,
|
||||||
|
}))}
|
||||||
|
selectedValue={comunidadSeleccionada ? Number(comunidadSeleccionada) : undefined}
|
||||||
|
onChange={(opt) =>
|
||||||
|
setRespuesta(preguntaComunidad.id_pregunta.toString(), String(opt.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pregunta de cuenta (si respondió "Sí" a comunidad, o en cuestionario exclusivo) */}
|
||||||
|
{(esComunidadSi || esExclusivo) && preguntaCuenta && (
|
||||||
|
<div className="my-4">
|
||||||
|
<label className={`form-label ${preguntaCuenta.obligatoria ? 'required' : ''}`}>
|
||||||
|
{preguntaCuenta.pregunta}
|
||||||
|
</label>
|
||||||
|
<SimpleInput
|
||||||
|
type="text"
|
||||||
|
name="cuenta"
|
||||||
|
placeholder={
|
||||||
|
preguntaCuenta.validacion === 'cuenta_alumno'
|
||||||
|
? 'Ingrese su número de cuenta (9 dígitos)'
|
||||||
|
: preguntaCuenta.validacion === 'cuenta_trabajador'
|
||||||
|
? 'Ingrese su número de trabajador (6 dígitos)'
|
||||||
|
: preguntaCuenta.validacion === 'rfc'
|
||||||
|
? 'Ingrese su RFC sin homoclave (10 caracteres)'
|
||||||
|
: `Ingrese su ${preguntaCuenta.pregunta}`
|
||||||
|
}
|
||||||
|
maxLength={
|
||||||
|
preguntaCuenta.validacion === 'rfc'
|
||||||
|
? 10
|
||||||
|
: preguntaCuenta.validacion === 'cuenta_alumno'
|
||||||
|
? 9
|
||||||
|
: 6
|
||||||
|
}
|
||||||
|
value={respuestas[preguntaCuenta.id_pregunta] || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRespuesta(preguntaCuenta.id_pregunta.toString(), e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{loadingBusqueda && (
|
||||||
|
<div className="text-muted small mt-1">
|
||||||
|
<span className="spinner-border spinner-border-sm me-1" />
|
||||||
|
Buscando...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{errorBusqueda && (
|
||||||
|
<div className="alert alert-danger py-2 mt-2" role="alert">
|
||||||
|
<i className="bi bi-exclamation-circle me-2"></i>
|
||||||
|
{errorBusqueda}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Resto del formulario */}
|
||||||
|
{mostrarFormularioCompleto &&
|
||||||
|
data.cuestionario.secciones.map((seccion, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
{seccion.preguntas.map((pregunta) => {
|
||||||
|
if (
|
||||||
|
pregunta.pregunta.id_pregunta === preguntaComunidad?.id_pregunta ||
|
||||||
|
pregunta.pregunta.id_pregunta === preguntaCuenta?.id_pregunta
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valor = respuestas[pregunta.pregunta.id_pregunta] || '';
|
||||||
|
|
||||||
|
if (pregunta.pregunta.tipo_pregunta.tipo_pregunta === 'Abierta (Respuesta corta)') {
|
||||||
|
return (
|
||||||
|
<div key={pregunta.pregunta.id_pregunta} className="my-4">
|
||||||
|
<label className={`form-label ${pregunta.pregunta.obligatoria ? 'required' : ''}`}>
|
||||||
|
{pregunta.pregunta.pregunta}
|
||||||
|
</label>
|
||||||
|
<SimpleInput
|
||||||
|
type="text"
|
||||||
|
name={`respuesta_${pregunta.pregunta.id_pregunta}`}
|
||||||
|
placeholder="Ingrese una respuesta"
|
||||||
|
value={valor}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRespuesta(pregunta.pregunta.id_pregunta.toString(), e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pregunta.pregunta.tipo_pregunta.tipo_pregunta === 'Abierta (Parrafo)') {
|
||||||
|
return (
|
||||||
|
<div key={pregunta.pregunta.id_pregunta} className="my-4">
|
||||||
|
<label className={`form-label ${pregunta.pregunta.obligatoria ? 'required' : ''}`}>
|
||||||
|
{pregunta.pregunta.pregunta}
|
||||||
|
</label>
|
||||||
|
<SimpleInput
|
||||||
|
type="textarea"
|
||||||
|
name={`respuesta_${pregunta.pregunta.id_pregunta}`}
|
||||||
|
placeholder="Ingrese una respuesta"
|
||||||
|
value={valor}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRespuesta(pregunta.pregunta.id_pregunta.toString(), e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
pregunta.pregunta.tipo_pregunta.tipo_pregunta === 'Cerrada' ||
|
||||||
|
pregunta.pregunta.tipo_pregunta.tipo_pregunta === 'Multiple'
|
||||||
|
) {
|
||||||
|
const opciones: RadioOption<number>[] = pregunta.pregunta.opciones.map((op) => ({
|
||||||
|
label: op.opcion.opcion,
|
||||||
|
value: op.id_opcion,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={pregunta.pregunta.id_pregunta} className="my-4">
|
||||||
|
<label className={`form-label ${pregunta.pregunta.obligatoria ? 'required' : ''}`}>
|
||||||
|
{pregunta.pregunta.pregunta}
|
||||||
|
</label>
|
||||||
|
<RadioOptionGroup
|
||||||
|
name={`pregunta_${pregunta.pregunta.id_pregunta}`}
|
||||||
|
options={opciones}
|
||||||
|
selectedValue={valor ? Number(valor) : undefined}
|
||||||
|
onChange={(opt) =>
|
||||||
|
setRespuesta(pregunta.pregunta.id_pregunta.toString(), String(opt.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Botón de envío */}
|
||||||
|
{mostrarFormularioCompleto && (
|
||||||
|
<Button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleOnSubmit}
|
||||||
|
disabled={isSending}
|
||||||
|
>
|
||||||
|
{isSending ? 'Enviando...' : 'Enviar Formulario'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import SimpleInput from '@/components/input';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
idPregunta: number;
|
||||||
|
enunciado: string;
|
||||||
|
obligatorio?: boolean;
|
||||||
|
validacion?: string;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
respuestas: Record<string, any>;
|
||||||
|
cuentaInfo: {
|
||||||
|
nombre: string;
|
||||||
|
apellidos: string;
|
||||||
|
correo: string;
|
||||||
|
genero: 'M' | 'F';
|
||||||
|
carrera: string;
|
||||||
|
} | null;
|
||||||
|
isComunidad: string | undefined;
|
||||||
|
onChange: (id: string, valor: string) => void;
|
||||||
|
preguntaComunidad: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmativeOption = ['Sí', 'Si', 'Si, claro', 'Claro que sí'];
|
||||||
|
|
||||||
|
export default function PrefetchAbiertaCorta({
|
||||||
|
idPregunta,
|
||||||
|
enunciado,
|
||||||
|
validacion,
|
||||||
|
respuestas,
|
||||||
|
cuentaInfo,
|
||||||
|
isComunidad,
|
||||||
|
obligatorio,
|
||||||
|
onChange,
|
||||||
|
preguntaComunidad,
|
||||||
|
}: Props) {
|
||||||
|
console.log(preguntaComunidad);
|
||||||
|
let defaultValue = '';
|
||||||
|
let disabled = false;
|
||||||
|
|
||||||
|
if (confirmativeOption.includes(isComunidad ?? '') && cuentaInfo) {
|
||||||
|
if (validacion === 'nombre' && cuentaInfo.nombre) {
|
||||||
|
defaultValue = cuentaInfo.nombre;
|
||||||
|
disabled = true;
|
||||||
|
}
|
||||||
|
if (validacion === 'apellidos' && cuentaInfo.apellidos) {
|
||||||
|
defaultValue = cuentaInfo.apellidos;
|
||||||
|
disabled = true;
|
||||||
|
}
|
||||||
|
if (validacion === 'correo' && cuentaInfo.correo) {
|
||||||
|
defaultValue = cuentaInfo.correo;
|
||||||
|
if (preguntaComunidad !== 'comunidad_trabajador') {
|
||||||
|
disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (validacion === 'institucion') {
|
||||||
|
defaultValue = 'FES Acatlán';
|
||||||
|
disabled = true;
|
||||||
|
}
|
||||||
|
if (validacion === 'carrera' && cuentaInfo.carrera) {
|
||||||
|
defaultValue = cuentaInfo.carrera;
|
||||||
|
disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-4">
|
||||||
|
<label className={`form-label ${obligatorio ? 'required' : ''}`}>
|
||||||
|
{enunciado}
|
||||||
|
</label>
|
||||||
|
<SimpleInput
|
||||||
|
type="text"
|
||||||
|
name={`respuesta_${idPregunta}`}
|
||||||
|
placeholder="Ingrese una respuesta"
|
||||||
|
value={respuestas[idPregunta] ?? defaultValue}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onChange(idPregunta.toString(), e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
'use client';
|
||||||
|
import MarkdownRenderer from '@/components/markdown-render';
|
||||||
|
import { useGetApi } from '@/hooks/use-get-api';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import FormularioRegistro from '../formulario/formulario-registro';
|
||||||
|
import FormularioExito from '../formulario/formulario-exito';
|
||||||
|
import { GetEventoWithCuestionario } from '@/types/evento';
|
||||||
|
import { formatearRangoFechas } from '@/utils/date-utils';
|
||||||
|
import { SubmitResponse } from '@/types/submit';
|
||||||
|
import { FormularioProvider } from '@/context/formulario';
|
||||||
|
|
||||||
|
export default function FormularioPage({
|
||||||
|
id_evento,
|
||||||
|
id_cuestionario,
|
||||||
|
}: {
|
||||||
|
id_evento: string;
|
||||||
|
id_cuestionario: string;
|
||||||
|
}) {
|
||||||
|
const [response, setResponse] = useState<SubmitResponse | null>(null);
|
||||||
|
const [qrImageUrl, setQrImageUrl] = useState<string | null>(null);
|
||||||
|
const { data, error } = useGetApi<GetEventoWithCuestionario>(
|
||||||
|
`/evento/${id_evento}/cuestionario/${id_cuestionario}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Procesar el QR buffer cuando se reciba la respuesta
|
||||||
|
useEffect(() => {
|
||||||
|
if (!response?.qr_buffer) {
|
||||||
|
setQrImageUrl(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const binaryString = atob(response.qr_buffer);
|
||||||
|
const bytes = new Uint8Array(binaryString.length);
|
||||||
|
for (let i = 0; i < binaryString.length; i++) {
|
||||||
|
bytes[i] = binaryString.charCodeAt(i);
|
||||||
|
}
|
||||||
|
const blob = new Blob([bytes], { type: 'image/png' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
setQrImageUrl(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al procesar el QR buffer:', error);
|
||||||
|
setQrImageUrl(null);
|
||||||
|
}
|
||||||
|
}, [response?.qr_buffer]);
|
||||||
|
|
||||||
|
// Cleanup del QR cuando el componente se desmonte o cambie la respuesta
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (qrImageUrl) {
|
||||||
|
URL.revokeObjectURL(qrImageUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [qrImageUrl]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div>Error al cargar el cuestionario: {error.message}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmitFormulario = async (res: SubmitResponse) => {
|
||||||
|
setResponse(res);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Si hay una respuesta exitosa, mostrar la vista de éxito
|
||||||
|
if (response && response.success) {
|
||||||
|
return (
|
||||||
|
<FormularioExito
|
||||||
|
response={response}
|
||||||
|
qrImageUrl={qrImageUrl}
|
||||||
|
nombreEvento={data?.nombre_evento}
|
||||||
|
nombreFormulario={data?.cuestionario.nombre_form}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container my-5">
|
||||||
|
{/* Sección de banners mejorada */}
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
{data?.banner && data?.cuestionario.banner ? (
|
||||||
|
/* Ambos banners disponibles */
|
||||||
|
<div className="d-flex align-items-center justify-content-center gap-3 flex-wrap">
|
||||||
|
<div className="position-relative">
|
||||||
|
<Image
|
||||||
|
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${data.banner}`}
|
||||||
|
alt={`Banner del evento: ${data.nombre_evento}`}
|
||||||
|
width={350}
|
||||||
|
height={200}
|
||||||
|
className="img-fluid rounded shadow-sm"
|
||||||
|
style={{ objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
<div className="position-absolute bottom-0 start-0 m-2">
|
||||||
|
<span className="badge bg-primary bg-opacity-90">
|
||||||
|
<i className="bi bi-calendar-event me-1"></i>
|
||||||
|
Evento
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="d-flex align-items-center justify-content-center">
|
||||||
|
<div
|
||||||
|
className="rounded-circle bg-light border d-flex align-items-center justify-content-center"
|
||||||
|
style={{ width: '50px', height: '50px' }}
|
||||||
|
>
|
||||||
|
<i className="bi bi-x-lg fs-4 text-muted"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="position-relative">
|
||||||
|
<Image
|
||||||
|
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${data.cuestionario.banner}`}
|
||||||
|
alt={`Banner del formulario: ${data.cuestionario.nombre_form}`}
|
||||||
|
width={350}
|
||||||
|
height={200}
|
||||||
|
className="img-fluid rounded shadow-sm"
|
||||||
|
style={{ objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
<div className="position-absolute bottom-0 start-0 m-2">
|
||||||
|
<span className="badge bg-success bg-opacity-90">
|
||||||
|
<i className="bi bi-file-earmark-text me-1"></i>
|
||||||
|
Formulario
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : data?.banner ? (
|
||||||
|
/* Solo banner del evento */
|
||||||
|
<div>
|
||||||
|
<Image
|
||||||
|
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${data.banner}`}
|
||||||
|
alt={data?.nombre_evento || ''}
|
||||||
|
width={800}
|
||||||
|
height={400}
|
||||||
|
className="img-fluid rounded shadow"
|
||||||
|
/>
|
||||||
|
<p className="text-muted mt-2 small">
|
||||||
|
<i className="bi bi-calendar-event me-1"></i>
|
||||||
|
Banner del evento
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : data?.cuestionario.banner ? (
|
||||||
|
/* Solo banner del formulario */
|
||||||
|
<div>
|
||||||
|
<Image
|
||||||
|
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${data.cuestionario.banner}`}
|
||||||
|
alt={data?.cuestionario.nombre_form || ''}
|
||||||
|
width={800}
|
||||||
|
height={400}
|
||||||
|
className="img-fluid rounded shadow"
|
||||||
|
/>
|
||||||
|
<p className="text-muted mt-2 small">
|
||||||
|
<i className="bi bi-file-earmark-text me-1"></i>
|
||||||
|
Banner del formulario
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Layout de dos columnas */}
|
||||||
|
<div className="row">
|
||||||
|
{/* Columna izquierda - Información */}
|
||||||
|
<div className="col-lg-5">
|
||||||
|
{/* Tarjeta: Evento principal */}
|
||||||
|
<section aria-labelledby="evento-heading" className="mb-1">
|
||||||
|
<div className="card border-0 shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<h2
|
||||||
|
id="evento-heading"
|
||||||
|
className="h4 mb-2 d-flex align-items-center gap-2"
|
||||||
|
>
|
||||||
|
<i className="bi bi-calendar-event text-primary"></i>
|
||||||
|
Evento
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<h3 className="h3 mb-2">{data?.nombre_evento}</h3>
|
||||||
|
|
||||||
|
{data?.descripcion_evento && (
|
||||||
|
<div className="text-muted mb-3">
|
||||||
|
<MarkdownRenderer markdown={data?.descripcion_evento} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data?.fecha_inicio && data?.fecha_fin && (
|
||||||
|
<p className="text-muted mb-0">
|
||||||
|
<i
|
||||||
|
className="bi bi-calendar-date me-1 text-primary"
|
||||||
|
aria-hidden="true"
|
||||||
|
></i>
|
||||||
|
{formatearRangoFechas(data.fecha_inicio, data.fecha_fin)}{' '}
|
||||||
|
horas.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Tarjeta: Sub-evento (Cuestionario) */}
|
||||||
|
{data?.cuestionario && (
|
||||||
|
<section aria-labelledby="cuestionario-heading">
|
||||||
|
<div className="card border-0 shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-flex justify-content-between align-items-start mb-2">
|
||||||
|
<h2
|
||||||
|
id="cuestionario-heading"
|
||||||
|
className="h4 mb-0 d-flex align-items-center gap-2"
|
||||||
|
>
|
||||||
|
<i className="bi bi-ui-checks-grid text-success"></i>
|
||||||
|
Registro a cuestionario
|
||||||
|
</h2>
|
||||||
|
<span className="badge bg-success-subtle text-success border border-success-subtle">
|
||||||
|
Sub-evento
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="h3 mb-1">{data.cuestionario.nombre_form}</h3>
|
||||||
|
|
||||||
|
{data.cuestionario.descripcion && (
|
||||||
|
<div className="text-muted mb-3">
|
||||||
|
<MarkdownRenderer
|
||||||
|
markdown={data.cuestionario.descripcion}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.cuestionario.fecha_inicio &&
|
||||||
|
data.cuestionario.fecha_fin && (
|
||||||
|
<p className="text-muted mb-0 fs-5 fw-bold text-decoration-underline">
|
||||||
|
<i
|
||||||
|
className="bi bi-clock me-1 text-primary"
|
||||||
|
aria-hidden="true"
|
||||||
|
></i>
|
||||||
|
{formatearRangoFechas(
|
||||||
|
data.cuestionario.fecha_inicio,
|
||||||
|
data.cuestionario.fecha_fin
|
||||||
|
)}{' '}
|
||||||
|
horas.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Columna derecha - Formulario */}
|
||||||
|
<div className="col-lg-7">
|
||||||
|
<div className="ps-lg-4">
|
||||||
|
{data && (
|
||||||
|
<FormularioProvider
|
||||||
|
id_evento={data.id_evento}
|
||||||
|
id_cuestionario={data.cuestionario.id_cuestionario}
|
||||||
|
>
|
||||||
|
<FormularioRegistro
|
||||||
|
evento={data.nombre_evento}
|
||||||
|
cuestionario={data.cuestionario.nombre_form}
|
||||||
|
id_evento={data.id_evento}
|
||||||
|
id_cuestionario={data.cuestionario.id_cuestionario}
|
||||||
|
handleSubmitFormulario={onSubmitFormulario}
|
||||||
|
/>
|
||||||
|
</FormularioProvider>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
'use client';
|
||||||
|
import { GetEventoWithCuestionariosWithCupos } from '@/types/evento';
|
||||||
|
import axiosInstance from '@/utils/api-config';
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
interface EventoContextType {
|
||||||
|
evento: GetEventoWithCuestionariosWithCupos | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
refetch: () => void;
|
||||||
|
updateEvento: (
|
||||||
|
eventoData: Partial<GetEventoWithCuestionariosWithCupos>
|
||||||
|
) => void;
|
||||||
|
setEventoData: (evento: GetEventoWithCuestionariosWithCupos) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EventoContext = createContext<EventoContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
interface EventoProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
id_evento: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventoProvider({ children, id_evento }: EventoProviderProps) {
|
||||||
|
const [evento, setEvento] =
|
||||||
|
useState<GetEventoWithCuestionariosWithCupos | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchEvento = useCallback(async () => {
|
||||||
|
console.log('Fetching evento with ID:', id_evento);
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const response =
|
||||||
|
await axiosInstance.get<GetEventoWithCuestionariosWithCupos>(
|
||||||
|
`/evento/${id_evento}/cuestionarios`
|
||||||
|
);
|
||||||
|
setEvento(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : 'Error al cargar el evento';
|
||||||
|
setError(errorMessage);
|
||||||
|
console.error('Error fetching evento:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [id_evento]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id_evento) {
|
||||||
|
fetchEvento();
|
||||||
|
}
|
||||||
|
}, [id_evento, fetchEvento]);
|
||||||
|
|
||||||
|
const refetch = () => {
|
||||||
|
fetchEvento();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateEvento = useCallback(
|
||||||
|
(eventoData: Partial<GetEventoWithCuestionariosWithCupos>) => {
|
||||||
|
setEvento((prev) => (prev ? { ...prev, ...eventoData } : null));
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setEventoData = useCallback(
|
||||||
|
(newEvento: GetEventoWithCuestionariosWithCupos) => {
|
||||||
|
setEvento(newEvento);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const value: EventoContextType = {
|
||||||
|
evento,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
updateEvento,
|
||||||
|
setEventoData,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EventoContext.Provider value={value}>{children}</EventoContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEvento() {
|
||||||
|
const context = useContext(EventoContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useEvento must be used within an EventoProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './evento-context';
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
'use client';
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useReducer,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useMemo,
|
||||||
|
useCallback,
|
||||||
|
ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
import axiosInstance from '@/utils/api-config';
|
||||||
|
import { getAxiosError } from '@/utils/errors-utils';
|
||||||
|
import { GetCuestionario } from '@/types/evento';
|
||||||
|
import { UsuarioDataResponse } from '@/containers/formulario/formulario-registro';
|
||||||
|
import {
|
||||||
|
formularioReducer,
|
||||||
|
initialState,
|
||||||
|
FormularioState,
|
||||||
|
RespuestaFormulario,
|
||||||
|
} from './formulario-reducer';
|
||||||
|
|
||||||
|
// ── Longitudes requeridas por tipo de validación ─────────────────────────────
|
||||||
|
|
||||||
|
const LONGITUDES: Record<string, number> = {
|
||||||
|
cuenta_alumno: 9,
|
||||||
|
cuenta_trabajador: 6,
|
||||||
|
rfc: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Tipos del contexto ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface FormularioContextType {
|
||||||
|
state: FormularioState;
|
||||||
|
|
||||||
|
// Datos derivados estables
|
||||||
|
preguntas: ReturnType<typeof getPreguntasFlat>;
|
||||||
|
preguntaComunidad: PreguntaFlat | undefined;
|
||||||
|
preguntaCuenta: PreguntaFlat | undefined;
|
||||||
|
esComunidadSi: boolean;
|
||||||
|
esExclusivo: boolean;
|
||||||
|
mostrarFormularioCompleto: boolean;
|
||||||
|
|
||||||
|
// Acciones
|
||||||
|
setRespuesta: (id: string, valor: string) => void;
|
||||||
|
submitFormulario: (id_cuestionario: number) => Promise<import('@/types/submit').SubmitResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper para aplanar preguntas
|
||||||
|
type PreguntaFlat = GetCuestionario['cuestionario']['secciones'][0]['preguntas'][0]['pregunta'];
|
||||||
|
|
||||||
|
function getPreguntasFlat(data: GetCuestionario | null): PreguntaFlat[] {
|
||||||
|
if (!data) return [];
|
||||||
|
return data.cuestionario.secciones.flatMap((s) =>
|
||||||
|
s.preguntas.map((p) => p.pregunta)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Context ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const FormularioContext = createContext<FormularioContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
interface FormularioProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
id_evento: number;
|
||||||
|
id_cuestionario: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormularioProvider({
|
||||||
|
children,
|
||||||
|
id_evento,
|
||||||
|
id_cuestionario,
|
||||||
|
}: FormularioProviderProps) {
|
||||||
|
const [state, dispatch] = useReducer(formularioReducer, initialState);
|
||||||
|
// Rastrea si ya se limpiaron los campos para el usuarioEncontrado actual
|
||||||
|
const yaLimpioRef = useRef(false);
|
||||||
|
|
||||||
|
// ── Carga del cuestionario ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch({ type: 'FETCH_DATA_START' });
|
||||||
|
axiosInstance
|
||||||
|
.get<GetCuestionario>(`/cuestionario/${id_cuestionario}/formulario`)
|
||||||
|
.then((res) => dispatch({ type: 'FETCH_DATA_SUCCESS', payload: res.data }))
|
||||||
|
.catch((err) => {
|
||||||
|
const { message } = getAxiosError(err);
|
||||||
|
dispatch({ type: 'FETCH_DATA_ERROR', payload: message });
|
||||||
|
});
|
||||||
|
}, [id_cuestionario]);
|
||||||
|
|
||||||
|
// ── Derivados estables ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const preguntas = useMemo(() => getPreguntasFlat(state.data), [state.data]);
|
||||||
|
|
||||||
|
const preguntaComunidad = useMemo(
|
||||||
|
() =>
|
||||||
|
preguntas.find(
|
||||||
|
(p) =>
|
||||||
|
p.validacion === 'comunidad_alumno' ||
|
||||||
|
p.validacion === 'comunidad_trabajador'
|
||||||
|
),
|
||||||
|
[preguntas]
|
||||||
|
);
|
||||||
|
|
||||||
|
const preguntaCuenta = useMemo(
|
||||||
|
() =>
|
||||||
|
preguntas.find(
|
||||||
|
(p) =>
|
||||||
|
p.validacion === 'cuenta_alumno' ||
|
||||||
|
p.validacion === 'cuenta_trabajador' ||
|
||||||
|
p.validacion === 'rfc'
|
||||||
|
),
|
||||||
|
[preguntas]
|
||||||
|
);
|
||||||
|
|
||||||
|
const comunidadSeleccionada = preguntaComunidad
|
||||||
|
? state.respuestas[preguntaComunidad.id_pregunta]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const esComunidadSi = useMemo(
|
||||||
|
() =>
|
||||||
|
!!(
|
||||||
|
comunidadSeleccionada &&
|
||||||
|
preguntaComunidad?.opciones.find(
|
||||||
|
(op) =>
|
||||||
|
op.id_opcion === Number(comunidadSeleccionada) &&
|
||||||
|
op.opcion.opcion.toLowerCase().includes('si')
|
||||||
|
)
|
||||||
|
),
|
||||||
|
[comunidadSeleccionada, preguntaComunidad]
|
||||||
|
);
|
||||||
|
|
||||||
|
const esExclusivo = useMemo(
|
||||||
|
() => !preguntaComunidad && !!preguntaCuenta,
|
||||||
|
[preguntaComunidad, preguntaCuenta]
|
||||||
|
);
|
||||||
|
|
||||||
|
const mostrarFormularioCompleto =
|
||||||
|
(esExclusivo && state.busquedaCompletada) ||
|
||||||
|
(!preguntaComunidad && !esExclusivo) ||
|
||||||
|
(esComunidadSi && state.busquedaCompletada) ||
|
||||||
|
(!!(esComunidadSi === false) && !!comunidadSeleccionada);
|
||||||
|
|
||||||
|
// ── Búsqueda de cuenta ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const debeIniciarBusqueda = preguntaCuenta && (esExclusivo || esComunidadSi);
|
||||||
|
|
||||||
|
if (!debeIniciarBusqueda) {
|
||||||
|
if (state.cuentaBuscada || state.busquedaCompletada || state.errorBusqueda) {
|
||||||
|
dispatch({ type: 'BUSQUEDA_RESET' });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cuenta = state.respuestas[preguntaCuenta.id_pregunta];
|
||||||
|
if (!cuenta || typeof cuenta !== 'string') {
|
||||||
|
if (state.cuentaBuscada || state.busquedaCompletada || state.errorBusqueda) {
|
||||||
|
dispatch({ type: 'BUSQUEDA_RESET' });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const longitudRequerida = LONGITUDES[preguntaCuenta.validacion ?? ''];
|
||||||
|
if (!longitudRequerida || cuenta.length !== longitudRequerida) {
|
||||||
|
if (state.cuentaBuscada) dispatch({ type: 'BUSQUEDA_RESET' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No re-buscar la misma cuenta
|
||||||
|
if (cuenta === state.cuentaBuscada) return;
|
||||||
|
|
||||||
|
let endpoint =
|
||||||
|
preguntaCuenta.validacion === 'cuenta_alumno'
|
||||||
|
? `/alumnos/${cuenta}`
|
||||||
|
: `/trabajadores/${cuenta}`;
|
||||||
|
|
||||||
|
if (esExclusivo) endpoint += `?id_evento=${id_evento}`;
|
||||||
|
|
||||||
|
dispatch({ type: 'BUSQUEDA_START', payload: cuenta });
|
||||||
|
|
||||||
|
axiosInstance
|
||||||
|
.get<UsuarioDataResponse>(endpoint)
|
||||||
|
.then((res) => {
|
||||||
|
const d = res.data;
|
||||||
|
if (d.nombre || d.apellidos || d.carrera || d.genero) {
|
||||||
|
dispatch({ type: 'BUSQUEDA_SUCCESS', payload: d });
|
||||||
|
} else {
|
||||||
|
dispatch({ type: 'BUSQUEDA_ERROR', payload: 'No se encontraron datos para este usuario' });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
const { message } = getAxiosError(err);
|
||||||
|
dispatch({ type: 'BUSQUEDA_ERROR', payload: message });
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
state.respuestas,
|
||||||
|
state.cuentaBuscada,
|
||||||
|
state.busquedaCompletada,
|
||||||
|
state.errorBusqueda,
|
||||||
|
preguntaCuenta,
|
||||||
|
esComunidadSi,
|
||||||
|
esExclusivo,
|
||||||
|
id_evento,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Autorelleno cuando llega usuarioEncontrado ─────────────────────────────
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!state.data) return;
|
||||||
|
|
||||||
|
if (!state.usuarioEncontrado) {
|
||||||
|
// Solo limpiar si aún no lo hicimos (evita loop infinito)
|
||||||
|
if (!yaLimpioRef.current) return;
|
||||||
|
yaLimpioRef.current = false;
|
||||||
|
const idsCampos = preguntas
|
||||||
|
.filter((p) =>
|
||||||
|
['nombre', 'apellidos', 'correo', 'carrera', 'genero'].includes(p.validacion ?? '')
|
||||||
|
)
|
||||||
|
.map((p) => p.id_pregunta);
|
||||||
|
if (idsCampos.length) {
|
||||||
|
dispatch({ type: 'LIMPIAR_CAMPOS_AUTORELLENO', payload: idsCampos });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const u = state.usuarioEncontrado;
|
||||||
|
const bulk: RespuestaFormulario = {};
|
||||||
|
|
||||||
|
preguntas.forEach((pregunta) => {
|
||||||
|
const id = pregunta.id_pregunta;
|
||||||
|
switch (pregunta.validacion) {
|
||||||
|
case 'nombre':
|
||||||
|
bulk[id] = u.nombre ?? '';
|
||||||
|
break;
|
||||||
|
case 'apellidos':
|
||||||
|
bulk[id] = u.apellidos ?? '';
|
||||||
|
break;
|
||||||
|
case 'correo':
|
||||||
|
bulk[id] = u.cuenta ? `${u.cuenta}@pcpuma.acatlan.unam.mx` : '';
|
||||||
|
break;
|
||||||
|
case 'carrera':
|
||||||
|
bulk[id] = u.carrera ?? '';
|
||||||
|
break;
|
||||||
|
case 'genero': {
|
||||||
|
const texto = u.genero === 'M' ? 'Masculino' : 'Femenino';
|
||||||
|
const opcion = pregunta.opciones?.find(
|
||||||
|
(op) => op.opcion.opcion.toLowerCase() === texto.toLowerCase()
|
||||||
|
);
|
||||||
|
if (opcion) bulk[id] = String(opcion.id_opcion);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Object.keys(bulk).length) {
|
||||||
|
yaLimpioRef.current = true;
|
||||||
|
dispatch({ type: 'SET_RESPUESTAS_BULK', payload: bulk });
|
||||||
|
}
|
||||||
|
}, [state.usuarioEncontrado, state.data, preguntas]);
|
||||||
|
|
||||||
|
// ── Acciones expuestas ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const setRespuesta = useCallback((id: string, valor: string) => {
|
||||||
|
dispatch({ type: 'SET_RESPUESTA', payload: { id, valor } });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const submitFormulario = useCallback(
|
||||||
|
async (id_cuestionario: number) => {
|
||||||
|
dispatch({ type: 'SEND_START' });
|
||||||
|
try {
|
||||||
|
const preguntaCorreo = preguntas.find(
|
||||||
|
(p) => p.validacion === 'correo' || p.validacion === 'correo_institucional'
|
||||||
|
);
|
||||||
|
let correo = preguntaCorreo
|
||||||
|
? String(state.respuestas[preguntaCorreo.id_pregunta] ?? '')
|
||||||
|
: '';
|
||||||
|
if (!correo) {
|
||||||
|
const rx = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
for (const v of Object.values(state.respuestas)) {
|
||||||
|
if (typeof v === 'string' && rx.test(v)) { correo = v; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await axiosInstance.post<import('@/types/submit').SubmitResponse>(
|
||||||
|
'/cuestionario-respondido/submit',
|
||||||
|
{
|
||||||
|
id_cuestionario,
|
||||||
|
correo,
|
||||||
|
fecha_envio: new Date().toISOString(),
|
||||||
|
respuestas: Object.entries(state.respuestas).map(([id, valor]) => ({
|
||||||
|
id_pregunta: Number(id),
|
||||||
|
valor: isNaN(Number(valor)) ? valor : Number(valor),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
} finally {
|
||||||
|
dispatch({ type: 'SEND_END' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[state.respuestas, preguntas]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Valor del contexto ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const value: FormularioContextType = {
|
||||||
|
state,
|
||||||
|
preguntas,
|
||||||
|
preguntaComunidad,
|
||||||
|
preguntaCuenta,
|
||||||
|
esComunidadSi,
|
||||||
|
esExclusivo,
|
||||||
|
mostrarFormularioCompleto,
|
||||||
|
setRespuesta,
|
||||||
|
submitFormulario,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormularioContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</FormularioContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFormulario() {
|
||||||
|
const ctx = useContext(FormularioContext);
|
||||||
|
if (!ctx) throw new Error('useFormulario debe usarse dentro de FormularioProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { GetCuestionario } from '@/types/evento';
|
||||||
|
import { UsuarioDataResponse } from '@/containers/formulario/formulario-registro';
|
||||||
|
|
||||||
|
export type RespuestaFormulario = Record<string, string | number>;
|
||||||
|
|
||||||
|
// ── Estado ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface FormularioState {
|
||||||
|
// Datos del cuestionario cargado desde la API
|
||||||
|
data: GetCuestionario | null;
|
||||||
|
loadingData: boolean;
|
||||||
|
errorData: string | null;
|
||||||
|
|
||||||
|
// Respuestas del usuario
|
||||||
|
respuestas: RespuestaFormulario;
|
||||||
|
|
||||||
|
// Búsqueda de cuenta/rfc
|
||||||
|
cuentaBuscada: string;
|
||||||
|
busquedaCompletada: boolean;
|
||||||
|
loadingBusqueda: boolean;
|
||||||
|
errorBusqueda: string | null;
|
||||||
|
usuarioEncontrado: UsuarioDataResponse | null;
|
||||||
|
|
||||||
|
// Envío
|
||||||
|
isSending: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const initialState: FormularioState = {
|
||||||
|
data: null,
|
||||||
|
loadingData: true,
|
||||||
|
errorData: null,
|
||||||
|
|
||||||
|
respuestas: {},
|
||||||
|
|
||||||
|
cuentaBuscada: '',
|
||||||
|
busquedaCompletada: false,
|
||||||
|
loadingBusqueda: false,
|
||||||
|
errorBusqueda: null,
|
||||||
|
usuarioEncontrado: null,
|
||||||
|
|
||||||
|
isSending: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Acciones ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type FormularioAction =
|
||||||
|
// Carga del cuestionario
|
||||||
|
| { type: 'FETCH_DATA_START' }
|
||||||
|
| { type: 'FETCH_DATA_SUCCESS'; payload: GetCuestionario }
|
||||||
|
| { type: 'FETCH_DATA_ERROR'; payload: string }
|
||||||
|
|
||||||
|
// Respuestas
|
||||||
|
| { type: 'SET_RESPUESTA'; payload: { id: string; valor: string } }
|
||||||
|
| { type: 'SET_RESPUESTAS_BULK'; payload: RespuestaFormulario }
|
||||||
|
| { type: 'LIMPIAR_CAMPOS_AUTORELLENO'; payload: number[] } // ids de pregunta
|
||||||
|
|
||||||
|
// Búsqueda de cuenta
|
||||||
|
| { type: 'BUSQUEDA_START'; payload: string } // cuenta buscada
|
||||||
|
| { type: 'BUSQUEDA_SUCCESS'; payload: UsuarioDataResponse }
|
||||||
|
| { type: 'BUSQUEDA_ERROR'; payload: string }
|
||||||
|
| { type: 'BUSQUEDA_RESET' }
|
||||||
|
|
||||||
|
// Envío
|
||||||
|
| { type: 'SEND_START' }
|
||||||
|
| { type: 'SEND_END' };
|
||||||
|
|
||||||
|
// ── Reducer ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function formularioReducer(
|
||||||
|
state: FormularioState,
|
||||||
|
action: FormularioAction
|
||||||
|
): FormularioState {
|
||||||
|
switch (action.type) {
|
||||||
|
// Carga del cuestionario
|
||||||
|
case 'FETCH_DATA_START':
|
||||||
|
return { ...state, loadingData: true, errorData: null };
|
||||||
|
|
||||||
|
case 'FETCH_DATA_SUCCESS':
|
||||||
|
return { ...state, loadingData: false, data: action.payload };
|
||||||
|
|
||||||
|
case 'FETCH_DATA_ERROR':
|
||||||
|
return { ...state, loadingData: false, errorData: action.payload };
|
||||||
|
|
||||||
|
// Respuestas
|
||||||
|
case 'SET_RESPUESTA':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
respuestas: { ...state.respuestas, [action.payload.id]: action.payload.valor },
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'SET_RESPUESTAS_BULK':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
respuestas: { ...state.respuestas, ...action.payload },
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'LIMPIAR_CAMPOS_AUTORELLENO': {
|
||||||
|
const limpio = { ...state.respuestas };
|
||||||
|
action.payload.forEach((id) => delete limpio[id]);
|
||||||
|
return { ...state, respuestas: limpio };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Búsqueda
|
||||||
|
case 'BUSQUEDA_START':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
loadingBusqueda: true,
|
||||||
|
errorBusqueda: null,
|
||||||
|
busquedaCompletada: false,
|
||||||
|
usuarioEncontrado: null,
|
||||||
|
cuentaBuscada: action.payload,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'BUSQUEDA_SUCCESS':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
loadingBusqueda: false,
|
||||||
|
busquedaCompletada: true,
|
||||||
|
errorBusqueda: null,
|
||||||
|
usuarioEncontrado: action.payload,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'BUSQUEDA_ERROR':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
loadingBusqueda: false,
|
||||||
|
busquedaCompletada: false,
|
||||||
|
errorBusqueda: action.payload,
|
||||||
|
usuarioEncontrado: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'BUSQUEDA_RESET':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
cuentaBuscada: '',
|
||||||
|
busquedaCompletada: false,
|
||||||
|
loadingBusqueda: false,
|
||||||
|
errorBusqueda: null,
|
||||||
|
usuarioEncontrado: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Envío
|
||||||
|
case 'SEND_START':
|
||||||
|
return { ...state, isSending: true };
|
||||||
|
|
||||||
|
case 'SEND_END':
|
||||||
|
return { ...state, isSending: false };
|
||||||
|
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { FormularioProvider, useFormulario } from './formulario-context';
|
||||||
|
export type { FormularioState, FormularioAction, RespuestaFormulario } from './formulario-reducer';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './evento';
|
||||||
@@ -1,16 +1,22 @@
|
|||||||
export const plantillasDisponibles = [
|
import { FormularioCreacion } from '@/types/create-formulario';
|
||||||
|
|
||||||
|
interface Plantilla {
|
||||||
|
nombre: string;
|
||||||
|
id: string;
|
||||||
|
datos: FormularioCreacion;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const plantillasDisponibles: Plantilla[] = [
|
||||||
{
|
{
|
||||||
imagen: '/banner1.png',
|
nombre: 'Registro General (con prellenado para Trabajadores)',
|
||||||
nombre: 'Registro para la Feria',
|
id: 'registro-comunidad-estudiantil',
|
||||||
id: 'feria-sexualidad',
|
|
||||||
datos: {
|
datos: {
|
||||||
nombre_form: 'Registro para la Feria de la Sexualidad - FES Acatlán',
|
nombre_form: 'Registro',
|
||||||
descripcion:
|
descripcion:
|
||||||
'La Feria de la Sexualidad es un espacio seguro e informativo dirigido a toda la comunidad universitaria. Este registro nos ayudará a organizar mejor las actividades, talleres y charlas, así como conocer tus intereses. Por favor, completa el siguiente formulario para confirmar tu participación.',
|
'Con este formulario estarás reservando tu lugar en el evento. Por favor, completa todos los campos obligatorios.',
|
||||||
fecha_inicio: '2025-03-07T00:00:01',
|
fecha_inicio: '2025-08-01T08:00:00',
|
||||||
fecha_fin: '2025-03-18T23:59:59',
|
fecha_fin: '2025-08-10T23:59:59',
|
||||||
id_tipo_cuestionario: 1,
|
id_tipo_cuestionario: 2,
|
||||||
evento: 'Feria de la Sexualidad',
|
|
||||||
secciones: [
|
secciones: [
|
||||||
{
|
{
|
||||||
titulo: 'Información Personal',
|
titulo: 'Información Personal',
|
||||||
@@ -22,26 +28,106 @@ export const plantillasDisponibles = [
|
|||||||
tipo: 'Cerrada',
|
tipo: 'Cerrada',
|
||||||
opciones: [{ valor: 'Si' }, { valor: 'No' }],
|
opciones: [{ valor: 'Si' }, { valor: 'No' }],
|
||||||
obligatoria: true,
|
obligatoria: true,
|
||||||
|
validacion: 'comunidad_trabajador',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
titulo: 'Numero de cuenta',
|
titulo: 'RFC',
|
||||||
tipo: 'Abierta',
|
tipo: 'Abierta (Respuesta corta)',
|
||||||
|
obligatoria: false,
|
||||||
|
validacion: 'rfc',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titulo: 'Correo electrónico',
|
||||||
|
tipo: 'Abierta (Respuesta corta)',
|
||||||
obligatoria: true,
|
obligatoria: true,
|
||||||
|
validacion: 'correo',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
titulo: 'Nombre(s)',
|
titulo: 'Nombre(s)',
|
||||||
tipo: 'Abierta',
|
tipo: 'Abierta (Respuesta corta)',
|
||||||
obligatoria: true,
|
obligatoria: true,
|
||||||
|
validacion: 'nombre',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
titulo: 'Apellidos',
|
titulo: 'Apellidos',
|
||||||
tipo: 'Abierta',
|
tipo: 'Abierta (Respuesta corta)',
|
||||||
obligatoria: true,
|
obligatoria: true,
|
||||||
|
validacion: 'apellidos',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
titulo: 'Género',
|
titulo: 'Género',
|
||||||
tipo: 'Cerrada',
|
tipo: 'Cerrada',
|
||||||
obligatoria: true,
|
obligatoria: true,
|
||||||
|
validacion: 'genero',
|
||||||
|
opciones: [
|
||||||
|
{ valor: 'Masculino' },
|
||||||
|
{ valor: 'Femenino' },
|
||||||
|
{ valor: 'Prefiero no decirlo' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titulo: 'Carrera',
|
||||||
|
tipo: 'Abierta (Respuesta corta)',
|
||||||
|
validacion: 'carrera',
|
||||||
|
obligatoria: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nombre: 'Registro General (con prellenado para Alumnos)',
|
||||||
|
id: 'registro-comunidad-alumnos',
|
||||||
|
datos: {
|
||||||
|
nombre_form: 'Registro',
|
||||||
|
descripcion:
|
||||||
|
'Con este formulario estarás reservando tu lugar en el evento. Por favor, completa todos los campos obligatorios.',
|
||||||
|
fecha_inicio: '2025-08-01T08:00:00',
|
||||||
|
fecha_fin: '2025-08-10T23:59:59',
|
||||||
|
id_tipo_cuestionario: 1,
|
||||||
|
secciones: [
|
||||||
|
{
|
||||||
|
titulo: 'Información Personal',
|
||||||
|
descripcion:
|
||||||
|
'Proporciona tus datos personales para poder contactarte y enviarte información relevante sobre la Feria de la Sexualidad.',
|
||||||
|
preguntas: [
|
||||||
|
{
|
||||||
|
titulo: '¿Eres parte de la comunidad de la FES Acatlán?',
|
||||||
|
tipo: 'Cerrada',
|
||||||
|
opciones: [{ valor: 'Si' }, { valor: 'No' }],
|
||||||
|
obligatoria: true,
|
||||||
|
validacion: 'comunidad_alumno',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titulo: 'Numero de cuenta',
|
||||||
|
tipo: 'Abierta (Respuesta corta)',
|
||||||
|
obligatoria: false,
|
||||||
|
validacion: 'cuenta_alumno',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titulo: 'Correo electrónico',
|
||||||
|
tipo: 'Abierta (Respuesta corta)',
|
||||||
|
obligatoria: true,
|
||||||
|
validacion: 'correo',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titulo: 'Nombre(s)',
|
||||||
|
tipo: 'Abierta (Respuesta corta)',
|
||||||
|
obligatoria: true,
|
||||||
|
validacion: 'nombre',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titulo: 'Apellidos',
|
||||||
|
tipo: 'Abierta (Respuesta corta)',
|
||||||
|
obligatoria: true,
|
||||||
|
validacion: 'apellidos',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titulo: 'Género',
|
||||||
|
tipo: 'Cerrada',
|
||||||
|
obligatoria: true,
|
||||||
|
validacion: 'genero',
|
||||||
opciones: [
|
opciones: [
|
||||||
{ valor: 'Masculino' },
|
{ valor: 'Masculino' },
|
||||||
{ valor: 'Femenino' },
|
{ valor: 'Femenino' },
|
||||||
@@ -50,12 +136,14 @@ export const plantillasDisponibles = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
titulo: 'Institución de procedencia',
|
titulo: 'Institución de procedencia',
|
||||||
tipo: 'Abierta',
|
tipo: 'Abierta (Respuesta corta)',
|
||||||
|
validacion: 'institucion',
|
||||||
obligatoria: true,
|
obligatoria: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
titulo: 'Correo electrónico',
|
titulo: 'Carrera',
|
||||||
tipo: 'Abierta',
|
tipo: 'Abierta (Respuesta corta)',
|
||||||
|
validacion: 'carrera',
|
||||||
obligatoria: true,
|
obligatoria: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -63,4 +151,185 @@ export const plantillasDisponibles = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
nombre: 'Registro General',
|
||||||
|
id: 'registro-general',
|
||||||
|
datos: {
|
||||||
|
nombre_form: 'Registro Para Asistencia General',
|
||||||
|
descripcion:
|
||||||
|
'Con este formulario estarás reservando tu lugar en el evento. Por favor, completa todos los campos obligatorios.',
|
||||||
|
fecha_inicio: '2025-08-01T08:00:00',
|
||||||
|
fecha_fin: '2025-08-10T23:59:59',
|
||||||
|
id_tipo_cuestionario: 1,
|
||||||
|
secciones: [
|
||||||
|
{
|
||||||
|
titulo: 'Información Personal',
|
||||||
|
descripcion:
|
||||||
|
'Proporciona tus datos personales para poder contactarte y enviarte información relevante sobre la Feria de la Sexualidad.',
|
||||||
|
preguntas: [
|
||||||
|
{
|
||||||
|
titulo: 'Número de cuenta / trabajador',
|
||||||
|
tipo: 'Abierta (Respuesta corta)',
|
||||||
|
obligatoria: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titulo: 'Correo electrónico',
|
||||||
|
tipo: 'Abierta (Respuesta corta)',
|
||||||
|
obligatoria: true,
|
||||||
|
validacion: 'correo',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titulo: 'Nombre(s)',
|
||||||
|
tipo: 'Abierta (Respuesta corta)',
|
||||||
|
obligatoria: true,
|
||||||
|
validacion: 'nombre',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titulo: 'Apellidos',
|
||||||
|
tipo: 'Abierta (Respuesta corta)',
|
||||||
|
obligatoria: true,
|
||||||
|
validacion: 'apellidos',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titulo: 'Tipo de Asistente',
|
||||||
|
tipo: 'Cerrada',
|
||||||
|
obligatoria: true,
|
||||||
|
opciones: [
|
||||||
|
{ valor: 'Alumno' },
|
||||||
|
{ valor: 'Profesor' },
|
||||||
|
{ valor: 'Trabajador' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nombre: 'Formulario Exclusivo Alumnos',
|
||||||
|
id: 'formulario-exclusivo-alumnos',
|
||||||
|
datos: {
|
||||||
|
nombre_form: 'Formulario Exclusivo Alumnos',
|
||||||
|
descripcion:
|
||||||
|
'Este formulario está destinado exclusivamente para alumnos. Por favor, completa todos los campos obligatorios.',
|
||||||
|
fecha_inicio: '2025-08-01T08:00:00',
|
||||||
|
fecha_fin: '2025-08-10T23:59:59',
|
||||||
|
id_tipo_cuestionario: 1,
|
||||||
|
secciones: [
|
||||||
|
{
|
||||||
|
titulo: 'Información Personal',
|
||||||
|
descripcion: 'Proporciona tus datos personales.',
|
||||||
|
preguntas: [
|
||||||
|
{
|
||||||
|
titulo: 'Número de cuenta',
|
||||||
|
tipo: 'Abierta (Respuesta corta)',
|
||||||
|
obligatoria: true,
|
||||||
|
validacion: 'cuenta_alumno',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titulo: 'Correo electrónico',
|
||||||
|
tipo: 'Abierta (Respuesta corta)',
|
||||||
|
obligatoria: true,
|
||||||
|
validacion: 'correo',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titulo: 'Nombre(s)',
|
||||||
|
tipo: 'Abierta (Respuesta corta)',
|
||||||
|
obligatoria: true,
|
||||||
|
validacion: 'nombre',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titulo: 'Apellidos',
|
||||||
|
tipo: 'Abierta (Respuesta corta)',
|
||||||
|
obligatoria: true,
|
||||||
|
validacion: 'apellidos',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titulo: 'Carrera',
|
||||||
|
tipo: 'Abierta (Respuesta corta)',
|
||||||
|
validacion: 'carrera',
|
||||||
|
obligatoria: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titulo: 'Género',
|
||||||
|
tipo: 'Cerrada',
|
||||||
|
obligatoria: true,
|
||||||
|
validacion: 'genero',
|
||||||
|
opciones: [
|
||||||
|
{ valor: 'Masculino' },
|
||||||
|
{ valor: 'Femenino' },
|
||||||
|
{ valor: 'Prefiero no decirlo' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nombre: 'Formulario Exclusivo Profesores',
|
||||||
|
id: 'formulario-exclusivo-profesores',
|
||||||
|
datos: {
|
||||||
|
nombre_form: 'Formulario Exclusivo Profesores',
|
||||||
|
descripcion:
|
||||||
|
'Este formulario está destinado exclusivamente para profesores. Por favor, completa todos los campos obligatorios.',
|
||||||
|
fecha_inicio: '2025-08-01T08:00:00',
|
||||||
|
fecha_fin: '2025-08-10T23:59:59',
|
||||||
|
id_tipo_cuestionario: 2,
|
||||||
|
secciones: [
|
||||||
|
{
|
||||||
|
titulo: 'Información Personal',
|
||||||
|
descripcion: 'Proporciona tus datos personales.',
|
||||||
|
preguntas: [
|
||||||
|
{
|
||||||
|
titulo: 'RFC',
|
||||||
|
tipo: 'Abierta (Respuesta corta)',
|
||||||
|
obligatoria: true,
|
||||||
|
validacion: 'rfc',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titulo: 'Correo electrónico',
|
||||||
|
tipo: 'Abierta (Respuesta corta)',
|
||||||
|
obligatoria: true,
|
||||||
|
validacion: 'correo',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titulo: 'Nombre(s)',
|
||||||
|
tipo: 'Abierta (Respuesta corta)',
|
||||||
|
obligatoria: true,
|
||||||
|
validacion: 'nombre',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titulo: 'Apellidos',
|
||||||
|
tipo: 'Abierta (Respuesta corta)',
|
||||||
|
obligatoria: true,
|
||||||
|
validacion: 'apellidos',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titulo: 'Género',
|
||||||
|
tipo: 'Cerrada',
|
||||||
|
obligatoria: true,
|
||||||
|
validacion: 'genero',
|
||||||
|
opciones: [
|
||||||
|
{ valor: 'Masculino' },
|
||||||
|
{ valor: 'Femenino' },
|
||||||
|
{ valor: 'Prefiero no decirlo' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/*
|
||||||
|
¡Bienvenidas y bienvenidos a la Facultad!
|
||||||
|
Inicia esta nueva etapa con un recorrido especial por nuestras instalaciones, pensado para que conozcas tu nueva casa universitaria. Te acompañaremos por aulas, laboratorios, bibliotecas, áreas comunes y servicios que estarán a tu disposición durante tu formación. Además, podrás resolver dudas y conocer a quienes serán parte de tu camino académico.
|
||||||
|
¡No faltes, esta actividad marcará el inicio de grandes experiencias!
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
Recorrido de bienvenida para nuevo personal
|
||||||
|
Damos la más cordial bienvenida al personal de nuevo ingreso a nuestra Facultad. Este recorrido está diseñado para familiarizarlos con las principales áreas administrativas, académicas y de servicios. También tendrán la oportunidad de conocer al equipo de trabajo y obtener información clave para integrarse con éxito a la comunidad universitaria.
|
||||||
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import axiosInstance from '@/utils/api-config';
|
||||||
|
import { getAxiosError } from '@/utils/errors-utils';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export const useGetApi = <T,>(url: string, params?: Record<string, any>) => {
|
||||||
|
const [data, setData] = useState<T>();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<ErrorState | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const res = await axiosInstance.get<T>(url, { params });
|
||||||
|
setData(res.data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
const message = getAxiosError(err);
|
||||||
|
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [url, params]);
|
||||||
|
|
||||||
|
return { data, setData, loading, error };
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
export function middleware(request: NextRequest) {
|
||||||
|
const token = request.cookies.get('token');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
console.log('No hay token, redirigiendo a la página de inicio de sesión');
|
||||||
|
return NextResponse.redirect(new URL('/', request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/staff/:path*', '/administrador/:path*'],
|
||||||
|
};
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInScale {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInLeft {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInRight {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInRotate {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: rotate(-5deg) scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: rotate(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUpBounce {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-5px); // se pasa ligeramente hacia arriba
|
||||||
|
}
|
||||||
|
80% {
|
||||||
|
transform: translateY(3px); // rebota hacia abajo
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(0); // posición final
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInDownBounce {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(5px); // se pasa un poco hacia abajo
|
||||||
|
}
|
||||||
|
80% {
|
||||||
|
transform: translateY(-3px); // rebota hacia arriba
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(0); // se estabiliza
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clase base para aplicar la animación
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeIn 1s ease-in-out;
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in-up {
|
||||||
|
animation: fadeInUp 0.8s ease-out;
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in-down {
|
||||||
|
animation: fadeInDown 0.8s ease-out;
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in-scale {
|
||||||
|
animation: fadeInScale 0.8s ease-out;
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-in-left {
|
||||||
|
animation: slideInLeft 0.8s ease-out;
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-in-right {
|
||||||
|
animation: slideInRight 0.8s ease-out;
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in-rotate {
|
||||||
|
animation: fadeInRotate 0.8s ease-out;
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in-up-bounce {
|
||||||
|
animation: fadeInUpBounce 0.8s ease-out;
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in-down-bounce {
|
||||||
|
animation: fadeInDownBounce 0.8s ease-out;
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variantes con delays más suaves
|
||||||
|
@for $i from 1 through 5 {
|
||||||
|
.delay-#{$i} {
|
||||||
|
animation-delay: #{($i * 0.3)}s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,6 @@
|
|||||||
|
|
||||||
thead {
|
thead {
|
||||||
tr {
|
tr {
|
||||||
box-shadow: var(--bs-box-shadow-sm);
|
|
||||||
th {
|
th {
|
||||||
background-color: var(--bg-header-table);
|
background-color: var(--bg-header-table);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
@@ -39,7 +38,6 @@
|
|||||||
tr {
|
tr {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: var(--bs-box-shadow-sm);
|
|
||||||
|
|
||||||
&.clickable-row {
|
&.clickable-row {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
// Archivo: _utilities.scss
|
// Archivo: _utilities.scss
|
||||||
|
|
||||||
$hover-scales: (
|
$hover-scales: (
|
||||||
1: 1.05,
|
1: 1.025,
|
||||||
2: 1.1,
|
2: 1.05,
|
||||||
3: 1.15,
|
3: 1.075,
|
||||||
4: 1.2,
|
4: 1.1,
|
||||||
5: 1.25,
|
5: 1.125,
|
||||||
);
|
);
|
||||||
|
|
||||||
@each $key, $value in $hover-scales {
|
@each $key, $value in $hover-scales {
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ $theme-colors: map-merge($theme-colors, $custom-colors);
|
|||||||
@import 'sidebar';
|
@import 'sidebar';
|
||||||
@import 'hover';
|
@import 'hover';
|
||||||
@import 'tabs';
|
@import 'tabs';
|
||||||
|
@import 'animation';
|
||||||
|
@import './global.scss';
|
||||||
|
|
||||||
|
.carousel-control-prev-icon,
|
||||||
|
.carousel-control-next-icon {
|
||||||
|
filter: invert(100%) sepia(100%) saturate(0%) hue-rotate(0deg)
|
||||||
|
brightness(200%);
|
||||||
|
}
|
||||||
|
|
||||||
//Add additional custom code here
|
//Add additional custom code here
|
||||||
|
|
||||||
@@ -71,10 +79,22 @@ ul.list-unstyled li {
|
|||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin: -1.5rem 1rem 0;
|
margin: -1.5rem 1rem 0;
|
||||||
height: 60%;
|
cursor: pointer;
|
||||||
@extend .rounded;
|
@extend .rounded;
|
||||||
@extend .shadow;
|
@extend .shadow;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 80%; // ajusta según el tamaño deseado
|
||||||
|
background: linear-gradient(to top, rgba(0, 0, 0, 0.5), transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
@extend .img-fluid, .rounded;
|
@extend .img-fluid, .rounded;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -85,11 +105,19 @@ ul.list-unstyled li {
|
|||||||
|
|
||||||
.card-caption {
|
.card-caption {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 15px;
|
bottom: 5px;
|
||||||
left: 15px;
|
left: 15px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
font-weight: bold;
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
text-shadow: 0 2px 5px rgba(33, 33, 33, 0.5);
|
text-shadow: 0 2px 5px rgba(33, 33, 33, 0.5);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-description {
|
||||||
|
> p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,3 +149,32 @@ ul.list-unstyled li {
|
|||||||
.cursor-pointer {
|
.cursor-pointer {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
--bs-body-bg: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
position: relative;
|
||||||
|
&::after {
|
||||||
|
content: '*';
|
||||||
|
position: absolute;
|
||||||
|
color: red;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preguntas {
|
||||||
|
@include media-breakpoint-up(md) {
|
||||||
|
margin-inline: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-breakpoint-up(lg) {
|
||||||
|
margin-inline: 14rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-white {
|
||||||
|
--bs-table-bg: #fff;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
.form-control,
|
||||||
|
.form-select {
|
||||||
|
--bs-body-bg: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-md-auto {
|
||||||
|
width: 100% !important;
|
||||||
|
@include media-breakpoint-up(md) {
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba($azul, 0.75) $white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav.nav-tabs {
|
||||||
|
border-bottom: 0cap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs.style-box {
|
||||||
|
.nav-item .nav-link {
|
||||||
|
@extend .box;
|
||||||
|
@extend .py-2;
|
||||||
|
@extend .text-black;
|
||||||
|
margin: 0 !important;
|
||||||
|
border: 0;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
@extend .text-primary;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
@extend .text-muted;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* HTML: <div class="loader"></div> */
|
||||||
|
.loader {
|
||||||
|
margin-top: 100px;
|
||||||
|
height: 20px;
|
||||||
|
aspect-ratio: 5;
|
||||||
|
-webkit-mask: linear-gradient(90deg, #0000, $azul 20% 80%, #0000);
|
||||||
|
background: radial-gradient(closest-side at 37.5% 50%, $azul 94%, #0000) 0 /
|
||||||
|
calc(80% / 3) 100%;
|
||||||
|
animation: l48 0.75s infinite linear;
|
||||||
|
}
|
||||||
|
@keyframes l48 {
|
||||||
|
100% {
|
||||||
|
background-position: 36.36%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
@extend .text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-dashboard {
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background-color: $primary;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 3px solid red;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include color-mode(dark) {
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background-color: $primary;
|
||||||
|
border: 3px solid blue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export interface Administrador {
|
||||||
|
id_administrador: number;
|
||||||
|
nombre_usuario: string;
|
||||||
|
correo: string;
|
||||||
|
tipoUser: {
|
||||||
|
id: number;
|
||||||
|
tipo: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
export interface FormularioCreacion {
|
|
||||||
nombre_form: string;
|
|
||||||
descripcion: string;
|
|
||||||
fecha_inicio: string; // ISO 8601 date string
|
|
||||||
fecha_fin: string;
|
|
||||||
id_tipo_cuestionario: number;
|
|
||||||
evento: string;
|
|
||||||
secciones: SeccionFormulario[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SeccionFormulario {
|
|
||||||
titulo: string;
|
|
||||||
descripcion: string;
|
|
||||||
preguntas: PreguntaFormulario[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PreguntaFormulario {
|
|
||||||
titulo: string;
|
|
||||||
tipo: 'Cerrada' | 'Abierta' | 'Multiple';
|
|
||||||
opciones?: OpcionFormulario[];
|
|
||||||
obligatoria: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OpcionFormulario {
|
|
||||||
valor: string;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export interface CreateEventoType {
|
||||||
|
tipo_evento: string;
|
||||||
|
nombre_evento: string;
|
||||||
|
descripcion_evento?: string; // Es opciona
|
||||||
|
fecha_inicio: Date;
|
||||||
|
fecha_fin: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
export type TiposValidacion =
|
||||||
|
| 'correo'
|
||||||
|
| 'correo_institucional'
|
||||||
|
| 'telefono'
|
||||||
|
| 'nombre'
|
||||||
|
| 'entero'
|
||||||
|
| 'apellidos'
|
||||||
|
| 'decimal'
|
||||||
|
| 'comunidad_alumno'
|
||||||
|
| 'cuenta_alumno'
|
||||||
|
| 'comunidad_trabajador'
|
||||||
|
| 'cuenta_trabajador'
|
||||||
|
| 'genero'
|
||||||
|
| 'carrera'
|
||||||
|
| 'institucion'
|
||||||
|
| 'rfc';
|
||||||
|
|
||||||
|
export type TipoPregunta =
|
||||||
|
| 'Abierta (Respuesta corta)'
|
||||||
|
| 'Abierta (Parrafo)'
|
||||||
|
| 'Cerrada'
|
||||||
|
| 'Multiple';
|
||||||
|
|
||||||
|
export interface FormularioCreacion {
|
||||||
|
nombre_form: string;
|
||||||
|
descripcion: string;
|
||||||
|
fecha_inicio: string; // ISO 8601 date string
|
||||||
|
fecha_fin: string;
|
||||||
|
id_tipo_cuestionario: number;
|
||||||
|
id_tipo_evento?: number;
|
||||||
|
cupo_maximo?: number; // Opcional, si no se requiere un cupo máximo
|
||||||
|
mensaje_correo?: string;
|
||||||
|
secciones: SeccionFormulario[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeccionFormulario {
|
||||||
|
titulo: string;
|
||||||
|
descripcion: string;
|
||||||
|
preguntas: PreguntaFormulario[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreguntaFormulario {
|
||||||
|
titulo: string;
|
||||||
|
tipo: TipoPregunta;
|
||||||
|
validacion?: TiposValidacion;
|
||||||
|
obligatoria: boolean;
|
||||||
|
opciones?: { valor: string }[]; // Opciones para preguntas cerradas o múltiples
|
||||||
|
limite?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpcionFormulario {
|
||||||
|
valor: string;
|
||||||
|
}
|
||||||
@@ -1,15 +1,25 @@
|
|||||||
|
import { GetEvento } from './evento';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Respuesta del endpoint GET /cuestionario
|
* Respuesta del endpoint GET /cuestionario
|
||||||
*/
|
*/
|
||||||
export interface GetCuestionario {
|
export interface GetCuestionario {
|
||||||
id_cuestionario: number;
|
id_cuestionario: number;
|
||||||
|
id_evento: number;
|
||||||
nombre_form: string;
|
nombre_form: string;
|
||||||
|
banner?: string;
|
||||||
descripcion: string;
|
descripcion: string;
|
||||||
contador_secciones: number;
|
contador_secciones: number;
|
||||||
|
cupo_maximo?: number | null; // Puede ser null si no hay límite
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
fecha_fin: string; // Formato ISO: 'YYYY-MM-DDTHH:mm:ss'
|
fecha_fin: string; // Formato ISO: 'YYYY-MM-DDTHH:mm:ss'
|
||||||
fecha_inicio: string; // Formato ISO: 'YYYY-MM-DDTHH:mm:ss'
|
fecha_inicio: string; // Formato ISO: 'YYYY-MM-DDTHH:mm:ss'
|
||||||
id_tipo_cuestionario: number;
|
id_tipo_cuestionario: number;
|
||||||
|
mensaje_correo?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetCuestionarioWithEvento extends GetCuestionario {
|
||||||
|
evento: GetEvento;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* */
|
/* */
|
||||||
|
|||||||