Frontend Vuejs 3

This commit is contained in:
Marcos Elias Rios Nuñez 2026-06-01 10:08:35 -03:00
parent a5bf51eb47
commit d539eff25e
16 changed files with 1777 additions and 0 deletions

12
frontend/Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM node:18-alpine
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
EXPOSE 8080
CMD ["npm", "run", "dev", "--", "--host"]

14
frontend/index.html Normal file
View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sistema de Investigación de Huellas</title>
</head>
<body>
<!-- Aquí es donde Vue monta toda la interfaz del sistema -->
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

902
frontend/package-lock.json generated Normal file
View File

@ -0,0 +1,902 @@
{
"name": "sip-frontend",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sip-frontend",
"version": "1.0.0",
"dependencies": {
"axios": "^1.6.8",
"bootstrap": "^5.3.3",
"bootstrap-icons-vue": "^1.11.3",
"bootstrap-vue-next": "^0.16.6",
"vue": "^3.4.21",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"vite": "^5.1.6"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.3",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.0"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.0",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.21.5",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.6",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.11",
"license": "MIT"
},
"node_modules/@floating-ui/vue": {
"version": "1.1.11",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.7.6",
"@floating-ui/utils": "^0.2.11",
"vue-demi": ">=0.13.0"
}
},
"node_modules/@floating-ui/vue/node_modules/vue-demi": {
"version": "0.14.10",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"license": "MIT"
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.60.4",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@types/estree": {
"version": "1.0.8",
"dev": true,
"license": "MIT"
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.20",
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": {
"version": "5.2.4",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"peerDependencies": {
"vite": "^5.0.0 || ^6.0.0",
"vue": "^3.2.25"
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.34",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.3",
"@vue/shared": "3.5.34",
"entities": "^7.0.1",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.34",
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.34",
"@vue/shared": "3.5.34"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.34",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.3",
"@vue/compiler-core": "3.5.34",
"@vue/compiler-dom": "3.5.34",
"@vue/compiler-ssr": "3.5.34",
"@vue/shared": "3.5.34",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.14",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.34",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.34",
"@vue/shared": "3.5.34"
}
},
"node_modules/@vue/devtools-api": {
"version": "6.6.4",
"license": "MIT"
},
"node_modules/@vue/reactivity": {
"version": "3.5.34",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.34"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.34",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.34",
"@vue/shared": "3.5.34"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.34",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.34",
"@vue/runtime-core": "3.5.34",
"@vue/shared": "3.5.34",
"csstype": "^3.2.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.34",
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.34",
"@vue/shared": "3.5.34"
},
"peerDependencies": {
"vue": "3.5.34"
}
},
"node_modules/@vue/shared": {
"version": "3.5.34",
"license": "MIT"
},
"node_modules/@vueuse/core": {
"version": "10.11.1",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.20",
"@vueuse/metadata": "10.11.1",
"@vueuse/shared": "10.11.1",
"vue-demi": ">=0.14.8"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/core/node_modules/vue-demi": {
"version": "0.14.10",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@vueuse/metadata": {
"version": "10.11.1",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "10.11.1",
"license": "MIT",
"dependencies": {
"vue-demi": ">=0.14.8"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared/node_modules/vue-demi": {
"version": "0.14.10",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.16.1",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/bootstrap": {
"version": "5.3.8",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/twbs"
},
{
"type": "opencollective",
"url": "https://opencollective.com/bootstrap"
}
],
"license": "MIT",
"peerDependencies": {
"@popperjs/core": "^2.11.8"
}
},
"node_modules/bootstrap-icons-vue": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/bootstrap-icons-vue/-/bootstrap-icons-vue-1.11.3.tgz",
"integrity": "sha512-Xba1GTDYon8KYSDTKiiAtiyfk4clhdKQYvCQPMkE58+F5loVwEmh0Wi+ECCfowNc9SGwpoSLpSkvg7rhgZBttw==",
"license": "MIT"
},
"node_modules/bootstrap-vue-next": {
"version": "0.16.6",
"license": "MIT",
"dependencies": {
"@floating-ui/vue": "^1.0.6",
"@vueuse/core": "^10.7.2"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/bootstrap-vue-next"
},
"peerDependencies": {
"vue": "^3.4.18"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/entities": {
"version": "7.0.1",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.21.5",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.21.5",
"@esbuild/android-arm": "0.21.5",
"@esbuild/android-arm64": "0.21.5",
"@esbuild/android-x64": "0.21.5",
"@esbuild/darwin-arm64": "0.21.5",
"@esbuild/darwin-x64": "0.21.5",
"@esbuild/freebsd-arm64": "0.21.5",
"@esbuild/freebsd-x64": "0.21.5",
"@esbuild/linux-arm": "0.21.5",
"@esbuild/linux-arm64": "0.21.5",
"@esbuild/linux-ia32": "0.21.5",
"@esbuild/linux-loong64": "0.21.5",
"@esbuild/linux-mips64el": "0.21.5",
"@esbuild/linux-ppc64": "0.21.5",
"@esbuild/linux-riscv64": "0.21.5",
"@esbuild/linux-s390x": "0.21.5",
"@esbuild/linux-x64": "0.21.5",
"@esbuild/netbsd-x64": "0.21.5",
"@esbuild/openbsd-x64": "0.21.5",
"@esbuild/sunos-x64": "0.21.5",
"@esbuild/win32-arm64": "0.21.5",
"@esbuild/win32-ia32": "0.21.5",
"@esbuild/win32-x64": "0.21.5"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"license": "MIT"
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.3",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.1.3",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.12",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"license": "ISC"
},
"node_modules/postcss": {
"version": "8.5.15",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/rollup": {
"version": "4.60.4",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
},
"bin": {
"rollup": "dist/bin/rollup"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.60.4",
"@rollup/rollup-android-arm64": "4.60.4",
"@rollup/rollup-darwin-arm64": "4.60.4",
"@rollup/rollup-darwin-x64": "4.60.4",
"@rollup/rollup-freebsd-arm64": "4.60.4",
"@rollup/rollup-freebsd-x64": "4.60.4",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.4",
"@rollup/rollup-linux-arm-musleabihf": "4.60.4",
"@rollup/rollup-linux-arm64-gnu": "4.60.4",
"@rollup/rollup-linux-arm64-musl": "4.60.4",
"@rollup/rollup-linux-loong64-gnu": "4.60.4",
"@rollup/rollup-linux-loong64-musl": "4.60.4",
"@rollup/rollup-linux-ppc64-gnu": "4.60.4",
"@rollup/rollup-linux-ppc64-musl": "4.60.4",
"@rollup/rollup-linux-riscv64-gnu": "4.60.4",
"@rollup/rollup-linux-riscv64-musl": "4.60.4",
"@rollup/rollup-linux-s390x-gnu": "4.60.4",
"@rollup/rollup-linux-x64-gnu": "4.60.4",
"@rollup/rollup-linux-x64-musl": "4.60.4",
"@rollup/rollup-openbsd-x64": "4.60.4",
"@rollup/rollup-openharmony-arm64": "4.60.4",
"@rollup/rollup-win32-arm64-msvc": "4.60.4",
"@rollup/rollup-win32-ia32-msvc": "4.60.4",
"@rollup/rollup-win32-x64-gnu": "4.60.4",
"@rollup/rollup-win32-x64-msvc": "4.60.4",
"fsevents": "~2.3.2"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/vite": {
"version": "5.4.21",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
"rollup": "^4.20.0"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^18.0.0 || >=20.0.0",
"less": "*",
"lightningcss": "^1.21.0",
"sass": "*",
"sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.4.0"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"less": {
"optional": true
},
"lightningcss": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
}
}
},
"node_modules/vue": {
"version": "3.5.34",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.34",
"@vue/compiler-sfc": "3.5.34",
"@vue/runtime-dom": "3.5.34",
"@vue/server-renderer": "3.5.34",
"@vue/shared": "3.5.34"
},
"peerDependencies": {
"typescript": "*"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/vue-router": {
"version": "4.6.4",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.5.0"
}
}
}
}

23
frontend/package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "sip-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.6.8",
"bootstrap": "^5.3.3",
"bootstrap-icons-vue": "^1.11.3",
"bootstrap-vue-next": "^0.16.6",
"vue": "^3.4.21",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"vite": "^5.1.6"
}
}

18
frontend/src/App.vue Normal file
View File

@ -0,0 +1,18 @@
<template>
<div id="app-container">
<!-- El router-view actúa como el layout dinámico, renderizando la vista activa -->
<router-view />
</div>
</template>
<script setup>
// No requiere lógica global por ahora
</script>
<style>
/* Estilos globales básicos opcionales */
body {
background-color: #f8f9fa;
min-height: 100vh;
}
</style>

View File

@ -0,0 +1,211 @@
<template>
<div class="dashboard-wrapper">
<aside class="sidebar">
<div class="sidebar-brand">
<div class="sidebar-brand d-flex align-items-center py-4 px-3" style="background-color: #111a22; border-bottom: 1px solid #243342;">
<div class="fingerprint-icon-container me-3 d-flex align-items-center justify-content-center">
<svg xmlns="http://www.w3.org/2000/svg" width="34" height="34" fill="url(#brand-fingerprint-gradient)" class="bi bi-fingerprint icon-glow" viewBox="0 0 16 16">
<defs>
<linearGradient id="brand-fingerprint-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#00f2fe" />
<stop offset="100%" stop-color="#4facfe" />
</linearGradient>
</defs>
<path d="M8.06 6.5a.5.5 0 0 1 .5.5v.75a1.5 1.5 0 0 0 1.5 1.5h.5a.5.5 0 0 1 0 1h-.5A2.5 2.5 0 0 1 8.56 7.75V7a.5.5 0 0 1 .5-.5z"/>
<path d="M13.06 8a5.03 5.03 0 0 1-4.7 5c-.32 0-.64-.03-.95-.1a.5.5 0 1 1 .22-.98c.23.05.48.08.73.08A4.03 4.03 0 0 0 12.06 8a.5.5 0 0 1 1 0z"/>
<path d="M11.06 8a3.02 3.02 0 0 1-2.7 3c-.56 0-1.1-.11-1.61-.33a.5.5 0 1 1 .39-.92c.38.16.79.25 1.22.25a2.02 2.02 0 0 0 1.7-2 .5.5 0 0 1 1 0z"/>
<path d="M9.06 6a1.01 1.01 0 0 1 1 1v.5a.5.5 0 0 1-1 0V7a.01.01 0 0 0 0-.01.5.5 0 0 1 .5-.5z"/>
<path d="M7.06 5.5A2.01 2.01 0 0 1 9.06 3.5c1.01 0 1.84.75 1.98 1.72a.5.5 0 1 1-.99.14A1.01 1.01 0 0 0 9.06 4.5a1.01 1.01 0 0 0-1 1V6a.5.5 0 0 1-1 0v-.5z"/>
<path d="M5.06 6.5A4.01 4.01 0 0 1 9.06 2.5c1.96 0 3.59 1.42 3.93 3.31a.5.5 0 1 1-.98.18A3.01 3.01 0 0 0 9.06 3.5a3.01 3.01 0 0 0-3 3v1a.5.5 0 0 1-1 0v-.52-.48z"/>
<path d="M3.06 7.5A6.01 6.01 0 0 1 9.06 1.5c2.89 0 5.33 2.05 5.88 4.83a.5.5 0 1 1-.98.2A5.01 5.01 0 0 0 9.06 2.5a5.01 5.01 0 0 0-5 5v2.5a.5.5 0 0 1-1 0v-.52-.48-1.5z"/>
<path d="M1.06 8.5A8.01 8.01 0 0 1 9.06.5c3.84 0 7.11 2.7 7.84 6.43a.5.5 0 0 1-.98.2A7.01 7.01 0 0 0 9.06 1.5a7.01 7.01 0 0 0-7 7v4.5a.5.5 0 0 1-1 0v-.52-.48-2.5z"/>
</svg>
</div>
<div class="brand-text-container">
<h6 class="mb-0 text-white font-weight-bold tracking-normal" style="font-size: 13.5px; line-height: 1.3;">
Sistema de Identificación Papiloscópica
</h6>
<span class="badge bg-dark-subtle text-info font-weight-bold mt-1" style="font-size: 10px; letter-spacing: 0.5px; padding: 3px 6px;">
S.I.P
</span>
</div>
</div>
</div>
<nav class="sidebar-menu">
<b-button to="/" variant="link" class="menu-item" active-class="active" exact>
Panel Principal
</b-button>
<b-button v-if="isAdmin" to="/admin/usuarios" variant="link" class="menu-item" active-class="active">
Investigadores
</b-button>
<b-button to="/huellas" variant="link" class="menu-item" active-class="active">
Capturas de Huellas
</b-button>
<b-button to="/informes" variant="link" class="menu-item" active-class="active">
Informes y Métricas
</b-button>
</nav>
<div class="sidebar-footer">
<div class="user-info mb-2">
<p class="mb-0 text-white text-truncate">{{ user.username }}</p>
<b-badge variant="light" class="text-uppercase" style="font-size: 10px;">{{ user.role }}</b-badge>
</div>
<b-button variant="outline-danger" size="sm" class="w-100" @click="logout">
Cerrar Sesión
</b-button>
</div>
</aside>
<div class="main-workspace">
<header class="topbar shadow-sm">
<div class="d-flex align-items-center">
<h5 class="mb-0 text-secondary text-uppercase" style="font-size: 14px; letter-spacing: 1px;">
Sistema de Identificación Papiloscópica
</h5>
</div>
<div class="topbar-actions">
<b-badge variant="success" class="p-2">Laboratorio Activo</b-badge>
</div>
</header>
<main class="content-body">
<slot></slot>
</main>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
const user = ref({ username: 'Investigador', role: 'inv' });
const isAdmin = ref(false);
onMounted(() => {
const userData = JSON.parse(localStorage.getItem('user') || '{}');
user.value = userData;
isAdmin.value = userData.role === 'admin';
});
const logout = () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
router.push({ name: 'Login' });
};
</script>
<style scoped>
.dashboard-wrapper {
display: flex;
min-height: 100vh;
background-color: #f4f6f9;
}
.sidebar {
width: 260px;
background-color: #1a252f;
display: flex;
flex-direction: column;
position: fixed;
top: 0;
bottom: 0;
left: 0;
z-index: 100;
}
.sidebar-brand {
padding: 24px;
display: flex;
align-items: center;
border-bottom: 1px solid #2c3e50;
background-color: #141c22;
}
.sidebar-menu {
padding: 15px 0;
flex-grow: 1;
}
.menu-item {
display: block;
width: 100%;
padding: 12px 25px;
color: #abc0d4;
text-align: left;
text-decoration: none;
border-radius: 0;
font-size: 15px;
transition: all 0.2s ease;
}
.menu-item:hover {
color: #fff;
background-color: #2c3e50;
}
.menu-item.active {
color: #fff;
background-color: #007bff;
font-weight: bold;
}
.sidebar-footer {
padding: 20px;
background-color: #141c22;
border-top: 1px solid #2c3e50;
}
.main-workspace {
margin-left: 260px;
/* Desplaza el contenido para que no lo tape la barra fija */
flex-grow: 1;
display: flex;
flex-direction: column;
}
.topbar {
height: 65px;
background-color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 30px;
}
.content-body {
padding: 30px;
overflow-y: auto;
}
/* Estilos específicos para destacar el isotipo biométrico */
.fingerprint-icon-container {
min-width: 48px;
height: 48px;
background-color: rgba(0, 242, 254, 0.06); /* Fondo sutil cian */
border: 1px solid rgba(0, 242, 254, 0.15);
border-radius: 10px;
flex-shrink: 0; /* Evita que el contenedor se deforme por textos largos */
transition: all 0.3s ease;
}
.fingerprint-icon-container:hover {
background-color: rgba(0, 242, 254, 0.1);
border-color: rgba(0, 242, 254, 0.3);
}
.icon-glow {
/* Filtro de sombra para simular una pantalla de hardware bio-médico activa */
filter: drop-shadow(0px 0px 6px rgba(0, 242, 254, 0.5));
}
.brand-text-container {
overflow: hidden;
}
</style>

22
frontend/src/main.js Normal file
View File

@ -0,0 +1,22 @@
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
// 1. Importar los estilos obligatorios de Bootstrap 5 y la librería
import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap-vue-next/dist/bootstrap-vue-next.css';
// 2. Importar el plugin global de componentes unificados
import { createBootstrap } from 'bootstrap-vue-next';
const app = createApp(App);
app.use(router);
// 3. Registrar globalmente todos los componentes (b-form, b-button, b-alert, etc.)
app.use(createBootstrap({
components: true, // Esto habilita el registro automático de todos los b-*
directives: true // Habilita directivas como v-b-modal o v-b-tooltip
}));
app.mount('#app');

View File

@ -0,0 +1,78 @@
import { createRouter, createWebHistory } from 'vue-router';
import Dashboard from '../views/Dashboard.vue';
// Módulo AUTH: Login y Register
import Login from '../views/auth/Login.vue';
import Register from '../views/auth/Register.vue'; // Si decidís usar el registro visual
import Huellas from '../views/Huellas.vue';
import Informes from '../views/Informes.vue';
// Módulo USER: Gestión y CRUD
import UsersCrud from '../views/user/UsersCrud.vue';
const routes = [
{
path: '/login',
name: 'Login',
component: Login,
meta: { guest: true }
},
{
path: '/register',
name: 'Register',
component: Register,
meta: { guest: true } // Permite acceso solo a usuarios no autenticados
},
{
path: '/',
name: 'Dashboard',
component: Dashboard,
meta: { requiresAuth: true }
},
{
path: '/admin/usuarios',
name: 'UsersCrud',
component: UsersCrud,
meta: { requiresAuth: true, requiresAdmin: true }
},
// rutas registradas y protegidas por token
{
path: '/huellas',
name: 'Huellas',
component: Huellas,
meta: { requiresAuth: true }
},
{
path: '/informes',
name: 'Informes',
component: Informes,
meta: { requiresAuth: true }
}
];
const router = createRouter({
history: createWebHistory(),
routes,
});
// Guardia de Navegación Global (Middleware)
router.beforeEach((to, from, next) => {
const isAuthenticated = !!localStorage.getItem('token');
const user = JSON.parse(localStorage.getItem('user') || '{}');
const isAdmin = user.role === 'admin';
if (to.meta.requiresAuth && !isAuthenticated) {
return next({ name: 'Login' });
}
if (to.meta.guest && isAuthenticated) {
return next({ name: 'Dashboard' });
}
if (to.meta.requiresAdmin && !isAdmin) {
return next({ name: 'Dashboard' });
}
next();
});
export default router;

View File

@ -0,0 +1,37 @@
import axios from 'axios';
// Captura la variable inyectada por docker-compose mediante Vite
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api';
const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Interceptor: Inyecta el Token JWT "Bearer" automáticamente en cada petición (Estilo Laravel Sanctum)
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
}, (error) => {
return Promise.reject(error);
});
// Interceptor: Manejo global de respuestas (Redirección si el token expira - Error 401)
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response && error.response.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default apiClient;

View File

@ -0,0 +1,46 @@
<template>
<AdminLayout>
<b-row class="mb-4">
<b-col>
<h3 class="text-dark font-weight-bold">Panel de Control - Acceso de Investigador</h3>
<p class="text-muted">Métricas globales y estado del banco de muestras analizadas.</p>
</b-col>
</b-row>
<b-row class="mb-4">
<b-col md="4">
<b-card class="border-0 shadow-sm text-center py-3">
<h2 class="text-primary font-weight-bold mb-0">30</h2>
<span class="text-muted text-uppercase small font-weight-bold">Muestras Registradas</span>
</b-card>
</b-col>
<b-col md="4">
<b-card class="border-0 shadow-sm text-center py-3">
<h2 class="text-success font-weight-bold mb-0">12</h2>
<span class="text-muted text-uppercase small font-weight-bold">Informes Publicados</span>
</b-card>
</b-col>
<b-col md="4">
<b-card class="border-0 shadow-sm text-center py-3">
<h2 class="text-warning font-weight-bold mb-0">0</h2>
<span class="text-muted text-uppercase small font-weight-bold">Alertas del Sistema</span>
</b-card>
</b-col>
</b-row>
<b-row>
<b-col md="8">
<b-card title="Últimas Capturas en el Laboratorio" class="border-0 shadow-sm">
<p class="text-muted small">Historial reciente de procesamiento biométrico.</p>
<div class="p-5 text-center bg-light text-muted rounded border border-dashed">
Próximamente: Lista en tiempo real de huellas cargadas por los investigadores.
</div>
</b-card>
</b-col>
</b-row>
</AdminLayout>
</template>
<script setup>
import AdminLayout from '../components/AdminLayout.vue'; // Importamos el Layout estructurador
</script>

View File

@ -0,0 +1,9 @@
<template>
<AdminLayout>
<h3>Banco de Huellas Dactilares</h3>
<p class="text-muted">Módulo de captura y almacenamiento biométrico.</p>
</AdminLayout>
</template>
<script setup>
import AdminLayout from '../components/AdminLayout.vue';
</script>

View File

@ -0,0 +1,9 @@
<template>
<AdminLayout>
<h3>Informes de Investigación</h3>
<p class="text-muted">Exportación de datos y reportes de métricas estadísticas.</p>
</AdminLayout>
</template>
<script setup>
import AdminLayout from '../components/AdminLayout.vue';
</script>

View File

@ -0,0 +1,141 @@
<template>
<div class="login-bg d-flex justify-content-center align-items-center">
<div class="login-overlay-card shadow-lg">
<div class="card-header-accent text-center py-4">
<h4 class="mb-0 text-white font-weight-bold">Panel de Control Biométrico</h4>
<small class="text-light opacity-75">Autenticación de Investigadores</small>
</div>
<div class="p-4">
<b-alert v-model="showError" variant="danger" dismissible class="mb-3" style="font-size: 14px;">
{{ errorMessage }}
</b-alert>
<b-form @submit.prevent="handleLogin">
<b-form-group label="Identificador de Usuario" label-for="username" class="mb-3 font-weight-bold text-secondary">
<b-input-group>
<b-input-group-text class="input-group-icon-text">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-person-fill text-secondary" viewBox="0 0 16 16">
<path d="M3 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6"/>
</svg>
</b-input-group-text>
<b-form-input id="username" v-model="form.username" required placeholder="Ej: mrios_investigador" class="form-control-custom"></b-form-input>
</b-input-group>
</b-form-group>
<b-form-group label="Contraseña de Acceso" label-for="password" class="mb-4 font-weight-bold text-secondary">
<b-input-group>
<b-input-group-text class="input-group-icon-text">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-lock-fill text-secondary" viewBox="0 0 16 16">
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2m3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2"/>
</svg>
</b-input-group-text>
<b-form-input id="password" type="password" v-model="form.password" required placeholder="••••••••••••" class="form-control-custom"></b-form-input>
</b-input-group>
</b-form-group>
<b-button type="submit" variant="info" class="w-100 py-2 text-white font-weight-bold tracking-wide shadow-sm" :disabled="loading">
{{ loading ? 'Validando Credenciales...' : 'Iniciar Sesión' }}
</b-button>
</b-form>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import apiClient from '../../services/api';
const router = useRouter();
const form = ref({ username: '', password: '' });
const loading = ref(false);
const showError = ref(false);
const errorMessage = ref('');
const handleLogin = async () => {
loading.value = true;
showError.value = false;
try {
const response = await apiClient.post('/auth/login', form.value);
localStorage.setItem('token', response.data.token);
localStorage.setItem('user', JSON.stringify(response.data.user));
router.push({ name: 'Dashboard' });
} catch (error) {
showError.value = true;
errorMessage.value = error.response?.data?.message || 'Error de comunicación con el laboratorio.';
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.login-bg {
background: linear-gradient(135deg, #eef2f3 0%, #8e9eab 100%);
min-height: 100vh;
width: 100vw;
position: relative;
overflow: hidden;
}
/* MARCA DE AGUA OPTIMIZADA: Relleno gris + Contornos negros puros definidos */
.login-bg::before {
content: "";
position: absolute;
width: 650px;
height: 650px;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
/* Agregamos propiedades stroke (contorno negro) y stroke-width al SVG embebido */
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23b0b7bd' stroke='%23000000' stroke-width='0.35' stroke-linejoin='round'%3E%3Cpath d='M8.06 6.5a.5.5 0 0 1 .5.5v.75a1.5 1.5 0 0 0 1.5 1.5h.5a.5.5 0 0 1 0 1h-.5A2.5 2.5 0 0 1 8.56 7.75V7a.5.5 0 0 1 .5-.5z'/%3E%3Cpath d='M13.06 8a5.03 5.03 0 0 1-4.7 5c-.32 0-.64-.03-.95-.1a.5.5 0 1 1 .22-.98c.23.05.48.08.73.08A4.03 4.03 0 0 0 12.06 8a.5.5 0 0 1 1 0z'/%3E%3Cpath d='M11.06 8a3.02 3.02 0 0 1-2.7 3c-.56 0-1.1-.11-1.61-.33a.5.5 0 1 1 .39-.92c.38.16.79.25 1.22.25a2.02 2.02 0 0 0 1.7-2 .5.5 0 0 1 1 0z'/%3E%3Cpath d='M9.06 6a1.01 1.01 0 0 1 1 1v.5a.5.5 0 0 1-1 0V7a.01.01 0 0 0 0-.01.5.5 0 0 1 .5-.5z'/%3E%3Cpath d='M7.06 5.5A2.01 2.01 0 0 1 9.06 3.5c1.01 0 1.84.75 1.98 1.72a.5.5 0 1 1-.99.14A1.01 1.01 0 0 0 9.06 4.5a1.01 1.01 0 0 0-1 1V6a.5.5 0 0 1-1 0v-.5z'/%3E%3Cpath d='M5.06 6.5A4.01 4.01 0 0 1 9.06 2.5c1.96 0 3.59 1.42 3.93 3.31a.5.5 0 1 1-.98.18A3.01 3.01 0 0 0 9.06 3.5a3.01 3.01 0 0 0-3 3v1a.5.5 0 0 1-1 0v-.52-.48z'/%3E%3Cpath d='M3.06 7.5A6.01 6.01 0 0 1 9.06 1.5c2.89 0 5.33 2.05 5.88 4.83a.5.5 0 1 1-.98.2A5.01 5.01 0 0 0 9.06 2.5a5.01 5.01 0 0 0-5 5v2.5a.5.5 0 0 1-1 0v-.52-.48-1.5z'/%3E%3Cpath d='M1.06 8.5A8.01 8.01 0 0 1 9.06.5c3.84 0 7.11 2.7 7.84 6.43a.5.5 0 0 1-.98.2A7.01 7.01 0 0 0 9.06 1.5a7.01 7.01 0 0 0-7 7v4.5a.5.5 0 0 1-1 0v-.52-.48-2.5z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: center;
background-size: contain;
opacity: 0.12; /* Subimos levemente la opacidad para que resalte la combinación */
pointer-events: none;
}
.login-overlay-card {
background: rgba(255, 255, 255, 0.95);
border-radius: 12px;
width: 100%;
max-width: 440px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.8);
z-index: 10;
}
.card-header-accent {
background: linear-gradient(135deg, #1f4068 0%, #162447 100%);
}
.input-group-icon-text {
background-color: #f8f9fa;
border-right: none;
border-top-left-radius: 6px;
border-bottom-left-radius: 6px;
padding-left: 14px;
padding-right: 10px;
}
.form-control-custom {
padding: 10px 14px;
border-top-right-radius: 6px;
border-bottom-right-radius: 6px;
border-left: none;
font-size: 15px;
}
.form-control-custom:focus {
border-color: #ced4da;
box-shadow: none;
}
.input-group:focus-within .input-group-icon-text,
.input-group:focus-within .form-control-custom {
border-color: #1f4068;
}
</style>

View File

@ -0,0 +1,48 @@
<template>
<b-container class="vh-100 d-flex justify-content-center align-items-center">
<b-card title="Registro de Investigador" style="max-width: 400px; width: 100%;" class="shadow">
<b-alert v-model="alert.show" :variant="alert.variant" dismissible>
{{ alert.message }}
</b-alert>
<b-form @submit.prevent="handleRegister">
<b-form-group label="Usuario:" class="mb-3">
<b-form-input v-model="form.username" required placeholder="Ej: mrios"></b-form-input>
</b-form-group>
<b-form-group label="Correo Institucional:" class="mb-3">
<b-form-input type="email" v-model="form.email" required placeholder="ejemplo@uader.edu.ar"></b-form-input>
</b-form-group>
<b-form-group label="Contraseña:" class="mb-4">
<b-form-input type="password" v-model="form.password" required></b-form-input>
</b-form-group>
<b-button type="submit" variant="success" class="w-100 mb-2">Registrarse</b-button>
<b-button to="/login" variant="link" class="w-100 size-sm">¿Ya tenés cuenta? Iniciá sesión</b-button>
</b-form>
</b-card>
</b-container>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import apiClient from '../../services/api';
const router = useRouter();
const form = ref({ username: '', email: '', password: '' });
const alert = ref({ show: false, variant: 'danger', message: '' });
const handleRegister = async () => {
try {
await apiClient.post('/auth/register', form.value);
alert.value = { show: true, variant: 'success', message: 'Registro exitoso. Redirigiendo...' };
setTimeout(() => {
router.push({ name: 'Login' });
}, 2000);
} catch (error) {
alert.value = { show: true, variant: 'danger', message: error.response?.data?.message || 'Error en el registro.' };
}
};
</script>

View File

@ -0,0 +1,193 @@
<template>
<AdminLayout>
<b-container class="mt-4">
<b-row class="mb-4 align-items-center">
<b-col>
<h2>Administración de Investigadores</h2>
<p class="text-muted">Gestión de usuarios, roles y permisos del proyecto.</p>
</b-col>
<b-col class="text-end">
<b-button variant="primary" @click="openCreateModal">
+ Registrar Investigador
</b-button>
</b-col>
</b-row>
<!-- Alertas de estado -->
<b-alert v-model="alert.show" :variant="alert.variant" dismissible class="mb-3">
{{ alert.message }}
</b-alert>
<!-- Tabla de Usuarios (Estilo b-table reactiva) -->
<b-card no-body class="shadow-sm">
<b-table :items="users" :fields="fields" striped hover responsive class="mb-0">
<!-- Celda personalizada para el Rol -->
<template #cell(role)="data">
<div class="d-flex align-items-center gap-2">
<b-badge :variant="data.item.role === 'admin' ? 'danger' : 'info'">
{{ data.item.role || 'Sin Rol' }}
</b-badge>
<b-badge v-if="data.item.role === 'admin'" variant="warning" pill>
No borrable
</b-badge>
</div>
</template>
<!-- Celda de Acciones -->
<template #cell(actions)="data">
<b-button size="sm" variant="warning" class="me-2" @click="openEditModal(data.item)">
Editar
</b-button>
<b-button
size="sm"
:variant="data.item.role === 'admin' ? 'secondary' : 'danger'"
@click="deleteUser(data.item)">
Dar de Baja
</b-button>
<b-badge v-if="data.item.role === 'admin'" variant="warning" class="ms-2">
Admin no eliminable
</b-badge>
</template>
</b-table>
</b-card>
<!-- Modal informativo si no es posible borrar -->
<b-modal
id="cannot-delete-modal"
v-model="cannotDeleteModal.show"
title="Acción no permitida"
ok-only
ok-title="Cerrar">
<p class="mb-0">{{ cannotDeleteModal.message }}</p>
</b-modal>
<!-- Modal para Crear/Editar Usuario -->
<b-modal id="user-modal" v-model="modal.show" :title="modal.isEdit ? 'Editar Usuario' : 'Nuevo Investigador'" @ok="handleModalOk">
<b-form @submit.prevent>
<b-form-group label="Nombre de Usuario:" class="mb-3">
<b-form-input v-model="userForm.username" required placeholder="Ej: mrios"></b-form-input>
</b-form-group>
<b-form-group label="Correo Electrónico:" class="mb-3">
<b-form-input type="email" v-model="userForm.email" required placeholder="investigador@uader.edu.ar"></b-form-input>
</b-form-group>
<b-form-group :label="modal.isEdit ? 'Contraseña (Dejar en blanco para no modificar):' : 'Contraseña:'" class="mb-3">
<b-form-input type="password" v-model="userForm.password"></b-form-input>
</b-form-group>
<b-form-group label="Asignar Rol de Investigación:" class="mb-3">
<b-form-select v-model="userForm.role_id" :options="roleOptions" required></b-form-select>
</b-form-group>
</b-form>
</b-modal>
</b-container>
</AdminLayout>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import apiClient from '../../services/api';
import AdminLayout from '../../components/AdminLayout.vue'; // Importamos el Layout estructurador
// Configuración de columnas para la tabla de BootstrapVue
const fields = [
{ key: 'id', label: 'ID', sortable: true },
{ key: 'username', label: 'Usuario', sortable: true },
{ key: 'email', label: 'Email' },
{ key: 'role', label: 'Rol / Nivel' },
{ key: 'actions', label: 'Acciones' }
];
const users = ref([]);
const roles = ref([]);
const roleOptions = ref([]);
const alert = ref({ show: false, variant: 'success', message: '' });
const modal = ref({ show: false, isEdit: false, currentId: null });
const userForm = ref({ username: '', email: '', password: '', role_id: null });
const cannotDeleteModal = ref({ show: false, message: '' });
// Cargar datos iniciales de la API al montar el componente
const fetchUsersAndRoles = async () => {
try {
const [usersRes, rolesRes] = await Promise.all([
apiClient.get('/users'),
apiClient.get('/roles')
]);
users.value = usersRes.data;
roles.value = rolesRes.data;
// Formatear opciones para el componente b-form-select
roleOptions.value = rolesRes.data.map(r => ({ value: r.id, text: r.name.toUpperCase() }));
} catch (error) {
showAlert('danger', 'Error al sincronizar datos con el servidor.');
}
};
const showAlert = (variant, message) => {
alert.value = { show: true, variant, message };
};
const openCreateModal = () => {
modal.value = { show: true, isEdit: false, currentId: null };
userForm.value = { username: '', email: '', password: '', role_id: roleOptions.value[0]?.value || null };
};
const openEditModal = (user) => {
modal.value = { show: true, isEdit: true, currentId: user.id };
// Buscar ID del rol correspondiente basándose en el nombre
const matchingRole = roles.value.find(r => r.name === user.role);
userForm.value = {
username: user.username,
email: user.email,
password: '',
role_id: matchingRole ? matchingRole.id : null
};
};
const handleModalOk = async (bvModalEvent) => {
bvModalEvent.preventDefault(); // Previene que el modal se cierre antes de validar la petición
try {
if (modal.value.isEdit) {
await apiClient.put(`/users/${modal.value.currentId}`, userForm.value);
showAlert('success', 'Investigador actualizado correctamente.');
} else {
await apiClient.post('/users', userForm.value);
showAlert('success', 'Nuevo investigador registrado en el proyecto.');
}
modal.value.show = false;
fetchUsersAndRoles();
} catch (error) {
showAlert('danger', error.response?.data?.message || 'Error al procesar la solicitud.');
}
};
const deleteUser = async (user) => {
if (user.role === 'admin') {
cannotDeleteModal.value = {
show: true,
message: 'No se puede eliminar a un usuario con rol administrador. El rol admin tiene permisos especiales y su cuenta debe mantenerse activa.'
};
return;
}
if (confirm('¿Está seguro de que desea revocar el acceso a este usuario dentro de la investigación?')) {
try {
await apiClient.delete(`/users/${user.id}`);
showAlert('warning', 'Acceso revocado y usuario eliminado.');
fetchUsersAndRoles();
} catch (error) {
showAlert('danger', error.response?.data?.message || 'No se pudo completar la eliminación.');
}
}
};
onMounted(() => {
fetchUsersAndRoles();
});
</script>

14
frontend/vite.config.js Normal file
View File

@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// Debes asegurarte de incluir el 'export default'
export default defineConfig({
plugins: [vue()],
server: {
host: '0.0.0.0', // Forzamos a Vite a escuchar en la red interna de Docker
port: 8080, // El puerto mapeado en tu docker-compose
watch: {
usePolling: true // Crucial en Docker para que detecte cuando editás archivos desde Windows/Linux host
}
}
})