Merge pull request 'create database and auth for staging and dev' (#99) from database into main
Reviewed-on: #99
This commit is contained in:
commit
bb68926c56
35
.env
35
.env
|
|
@ -1,35 +0,0 @@
|
||||||
# Archivo de variables de entorno para la configuración de Google Sheets
|
|
||||||
GOOGLE_SERVICE_ACCOUNT_EMAIL=dbsheets@dbsheets-487314.iam.gserviceaccount.com
|
|
||||||
GOOGLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCi2DVTbgH5WzE4\nrsT4BLcjplwszwkiZmhUxcVma/xI/6G+LQpzWTmmETmPWGFBpPukcoEDJDIugSFF\nuiKM+cSJIJJTYYWb5sBGXC6bi36AiA3W/zuRYGoFdNP+34iqepWpXpjKY8T0bbJJ\njhIgjW/SN2fYZeVBZqLATEzRqCcPYXI5gs9fyb2bzTEjiB3kCklVCudl/JDoxAWm\nJ9vO/OOXEgt/t97v9cMoo9OZ4Y3txqiQB41UdS97btDvpezc59u+8K9s5FT+yljC\nMyfRiRM+4MpycCoJobiT8NnCaNblMTd8mkfuujrFvnT/k9O794gBOJ2sgF7AYej4\nzm9tXpknAgMBAAECggEAM50bqcngTkydGT4rqAC2m1ILPRiR6JlU0CmvG3t2hxyf\nA0v7V/wbzYsAF3MHwGquZ7zzLy+1yA+doCAS+2Pe6yeruUnbs0I8BxEIpLxd6Bc3\na8GR+833TKtu1gW7p20bKoeVHfDpaB+stquVC0RJAPQWsfv54fTJ+PE2F+2YE8Np\njhWvM4OkUNZVBPZxG+RF0gzylIx2tw3QpLN3DMSr9Z2wGfohp7yryP96qqC1RFGQ\nCsaHsPRfzv+UNinGmA0Yzv79Stuqgsg+J3V/01Pm4ANutHix/kP0YAYCyg29UtVZ\nIJZtPOAfHWICYw+hQMpcfSLWzbkUQpwTTG0Bk6xmwQKBgQDUNf226UzdAm/V2Pag\nc1YF4P6Bru1A5jiFTpyrAYPSN+Wk84Gkl8XtuDDb7zXL+MaVb8aIVXTKhM2b0RDw\nHtDayoCCXci8VfP4sDRzjEGB1O1OPOl/wPD6L94CmNB0QeepNs4Q5xD0HEsoNN6s\nZGafIkIbVVUBCkuZwNxoLVXSKQKBgQDEcnKkgE/ror9vXSj0XY9Rn0wU2W6DreUt\nMizWyH9R0wBbaSJQ/4Wd3F6co2H2DSuZgXxLJvsPlqffUivs/oZnlx8HhWi6vjLL\nvSiqNC1D+Bh5TT7PEonENU99lruKtZfKcMpRtVF6WycW5ZlYok4lRCEBBvbjtGgr\nIHnqc8WazwKBgD0oHilDjQI4fJBtiY3uD0F8ePxYtEk6z9kjMaVat2my8DycbKVJ\nlCIRLguEIcXZuSlB4D5UGmdu5G5eTvpUdy5Go8huTLZyMvBdn5AZQJuxCH6+sTA/\nI3OGrf2jmpeWkrPCdpqKbOlc6g/5RJ78BFMiJVkJO4kkNWtnGF3xisUZAoGAJ8VT\n2GHxzC7CuvUNWYunfErXyFDMwvPttdy2nzUwMYyaQedi/yr8Dh2TOfsJ1hqMfSNt\n2nkl0t4ZVmj4Y56T71z09zzXGxduiTjehrbRgzUzHzu4P4vtQD/au/5MMaTZ+i4j\nh69Bs5fIriYFiaAyWfEVDXQvf6IChNiqVgDiPJkCgYEAooIiW4HHVUNGMnIeB7UI\nBXv5gSs2heHYSmEK+Er7In5cfJQzkcJvwiOMfOPCNuwVPn+ow5IN8le1acDon5I2\n0OsXhWmgyg9zmgguj273mH3h6+o6XTg5GrEYgPVpU3m2dJkd05oWhQvzy7/OxHs8\nE4ZMz97uqmU2r8XJCo//tv8=\n-----END PRIVATE KEY-----\n"
|
|
||||||
GOOGLE_SHEET_ID=12X6qeU0W4ZDw00vS4OreyC4nFjKBeBYo7rq3wyRsrNQ
|
|
||||||
|
|
||||||
# Archivo de variables de entorno para la configuración de envío de emails
|
|
||||||
EMAIL_API_KEY=9UShpS8oh5Iun92TJSfevElI3Lp99TCv
|
|
||||||
|
|
||||||
# This was inserted by prisma init:
|
|
||||||
# Environment variables declared in this file are NOT automatically loaded by Prisma.
|
|
||||||
# Please add import "dotenv/config"; to your prisma.config.ts file, or use the Prisma CLI with Bun
|
|
||||||
# to load environment variables from .env files: https://pris.ly/prisma-config-env-vars.
|
|
||||||
|
|
||||||
# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
|
|
||||||
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
|
|
||||||
|
|
||||||
# The following prisma+postgres URL is similar to the URL produced by running a local Prisma Postgres
|
|
||||||
# server with the prisma dev CLI command, when not choosing any non-default ports or settings. The API key, unlike the
|
|
||||||
# one found in a remote Prisma Postgres URL, does not contain any sensitive information.
|
|
||||||
|
|
||||||
DATABASE_URL="file:./dev.db"
|
|
||||||
|
|
||||||
N8N_WEBHOOK_URL="https://flows2.carpa.com/webhook/news-summary"
|
|
||||||
N8N_API_KEY="sk_live_JwjpTdZrLVvijCKuWylWZbUuhBm6zPDH"
|
|
||||||
|
|
||||||
# Cloudflare Email Sending
|
|
||||||
CLOUDFLARE_API_TOKEN=cfut_8Y0Tcwiv3v0K1LDK9QSvc3BjlU1lZFs1zikACgTw33977b37
|
|
||||||
CLOUDFLARE_ACCOUNT_ID=3e689750123db91e81d3542d2930a907
|
|
||||||
|
|
||||||
# Cloudflare Turnstile — public key (visible in frontend)
|
|
||||||
TURNSTILE_SITE_KEY=0x4AAAAAAD9IYWC6HPflpneO
|
|
||||||
|
|
||||||
# Cloudflare Turnstile — secret key (server-side, keep safe!)
|
|
||||||
# In production, set this as a Gitea Actions secret named TURNSTILE_SECRET_KEY
|
|
||||||
TURNSTILE_SECRET_KEY=0x4AAAAAAD9IYXASjQSpmo2iS902SDoXnT4
|
|
||||||
|
|
@ -12,3 +12,12 @@ SUPABASE_URL="http://localhost:8000"
|
||||||
SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"
|
SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"
|
||||||
SUPABASE_SERVICE_ROLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
|
SUPABASE_SERVICE_ROLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
|
||||||
POSTGRES_PASSWORD=postgres
|
POSTGRES_PASSWORD=postgres
|
||||||
|
|
||||||
|
# Admin auth — API key (para consumo externo de /api/admin/*)
|
||||||
|
ADMIN_API_KEY="CHANGE_ME_admin_api_key"
|
||||||
|
|
||||||
|
# Admin auth — credenciales del seed (solo se usan en scripts/seed-admin.ts)
|
||||||
|
ADMIN_EMAIL="admin@example.com"
|
||||||
|
ADMIN_USERNAME="admin"
|
||||||
|
ADMIN_PASSWORD="CHANGE_ME_password"
|
||||||
|
ADMIN_NOMBRE="Admin"
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ jobs:
|
||||||
host: ${{ secrets.VPS_HOST }}
|
host: ${{ secrets.VPS_HOST }}
|
||||||
username: ${{ secrets.VPS_USER }}
|
username: ${{ secrets.VPS_USER }}
|
||||||
key: ${{ secrets.VPS_SSH_KEY }}
|
key: ${{ secrets.VPS_SSH_KEY }}
|
||||||
source: "dist/*,package.json,pnpm-lock.yaml,pnpm-workspace.yaml,ecosystem.config.cjs"
|
source: "dist/*,package.json,pnpm-lock.yaml,pnpm-workspace.yaml,ecosystem.config.cjs,prisma/schema.prisma,prisma/migrations,prisma.config.ts"
|
||||||
target: "${{ env.TARGET_DIR }}"
|
target: "${{ env.TARGET_DIR }}"
|
||||||
|
|
||||||
- name: Restart PM2
|
- name: Restart PM2
|
||||||
|
|
@ -52,10 +52,21 @@ jobs:
|
||||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||||
npm install -g pnpm@11 2>&1
|
npm install -g pnpm@11 2>&1
|
||||||
cd ${{ env.TARGET_DIR }}
|
cd ${{ env.TARGET_DIR }}
|
||||||
|
cat > .env <<'ENVEOF'
|
||||||
|
DATABASE_URL="${{ secrets.DATABASE_URL }}"
|
||||||
|
DIRECT_URL="${{ secrets.DIRECT_URL }}"
|
||||||
|
GOOGLE_SERVICE_ACCOUNT_EMAIL="${{ secrets.GOOGLE_SERVICE_ACCOUNT_EMAIL }}"
|
||||||
|
GOOGLE_PRIVATE_KEY="${{ secrets.GOOGLE_PRIVATE_KEY }}"
|
||||||
|
GOOGLE_SHEET_ID="${{ secrets.GOOGLE_SHEET_ID }}"
|
||||||
|
EMAIL_API_KEY="${{ secrets.EMAIL_API_KEY }}"
|
||||||
|
TURNSTILE_SITE_KEY="${{ secrets.TURNSTILE_SITE_KEY }}"
|
||||||
|
TURNSTILE_SECRET_KEY="${{ secrets.TURNSTILE_SECRET_KEY }}"
|
||||||
|
CLOUDFLARE_API_TOKEN="${{ secrets.CLOUDFLARE_API_TOKEN }}"
|
||||||
|
CLOUDFLARE_ACCOUNT_ID="${{ secrets.CLOUDFLARE_ACCOUNT_ID }}"
|
||||||
|
ADMIN_API_KEY="${{ secrets.ADMIN_API_KEY }}"
|
||||||
|
ENVEOF
|
||||||
pnpm install --prod 2>&1
|
pnpm install --prod 2>&1
|
||||||
sed -i "s|TURNSTILE_SITE_KEY: \"\"|TURNSTILE_SITE_KEY: \"${{ secrets.TURNSTILE_SITE_KEY }}\"|g" ecosystem.config.cjs
|
pnpm exec prisma generate 2>&1
|
||||||
sed -i "s|TURNSTILE_SECRET_KEY: \"\"|TURNSTILE_SECRET_KEY: \"${{ secrets.TURNSTILE_SECRET_KEY }}\"|g" ecosystem.config.cjs
|
pnpm exec prisma migrate deploy 2>&1
|
||||||
sed -i "s|CLOUDFLARE_API_TOKEN: \"\"|CLOUDFLARE_API_TOKEN: \"${{ secrets.CLOUDFLARE_API_TOKEN }}\"|g" ecosystem.config.cjs
|
|
||||||
sed -i "s|CLOUDFLARE_ACCOUNT_ID: \"\"|CLOUDFLARE_ACCOUNT_ID: \"${{ secrets.CLOUDFLARE_ACCOUNT_ID }}\"|g" ecosystem.config.cjs
|
|
||||||
pm2 reload ecosystem.config.cjs --only ${{ env.APP_NAME }} --update-env || \
|
pm2 reload ecosystem.config.cjs --only ${{ env.APP_NAME }} --update-env || \
|
||||||
pm2 start ecosystem.config.cjs --only ${{ env.APP_NAME }} --update-env
|
pm2 start ecosystem.config.cjs --only ${{ env.APP_NAME }} --update-env
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ pnpm-debug.log*
|
||||||
|
|
||||||
|
|
||||||
# environment variables
|
# environment variables
|
||||||
|
.env
|
||||||
.env.keys
|
.env.keys
|
||||||
|
|
||||||
# macOS-specific files
|
# macOS-specific files
|
||||||
|
|
@ -31,5 +32,12 @@ data/
|
||||||
opencode/
|
opencode/
|
||||||
.opencode/
|
.opencode/
|
||||||
docs/
|
docs/
|
||||||
|
|
||||||
|
# guía técnica local
|
||||||
|
docs/GUIA-TECNICA.md
|
||||||
|
|
||||||
|
# scripts de migración (locales, no en producción)
|
||||||
|
scripts/migrate-sheets-to-db.ts
|
||||||
|
scripts/seed-form-config.ts
|
||||||
scripts/
|
scripts/
|
||||||
.env.example
|
.env.example
|
||||||
|
|
|
||||||
|
|
@ -2,26 +2,22 @@ module.exports = {
|
||||||
apps: [
|
apps: [
|
||||||
{
|
{
|
||||||
name: "cdrdpyj",
|
name: "cdrdpyj",
|
||||||
|
cwd: __dirname,
|
||||||
script: "dist/server/entry.mjs",
|
script: "dist/server/entry.mjs",
|
||||||
|
interpreter_args: "--env-file=.env",
|
||||||
env: {
|
env: {
|
||||||
NODE_ENV: "staging",
|
NODE_ENV: "staging",
|
||||||
PORT: 3310,
|
PORT: 3310,
|
||||||
TURNSTILE_SITE_KEY: "",
|
|
||||||
TURNSTILE_SECRET_KEY: "",
|
|
||||||
CLOUDFLARE_API_TOKEN: "",
|
|
||||||
CLOUDFLARE_ACCOUNT_ID: "",
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "cdrdpyj-live",
|
name: "cdrdpyj-live",
|
||||||
|
cwd: __dirname,
|
||||||
script: "dist/server/entry.mjs",
|
script: "dist/server/entry.mjs",
|
||||||
|
interpreter_args: "--env-file=.env",
|
||||||
env: {
|
env: {
|
||||||
NODE_ENV: "production",
|
NODE_ENV: "production",
|
||||||
PORT: 4321,
|
PORT: 4321,
|
||||||
TURNSTILE_SITE_KEY: "",
|
|
||||||
TURNSTILE_SECRET_KEY: "",
|
|
||||||
CLOUDFLARE_API_TOKEN: "",
|
|
||||||
CLOUDFLARE_ACCOUNT_ID: "",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,10 @@
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"postbuild": "node scripts/send-to-n8n.js",
|
"postbuild": "node scripts/send-to-n8n.js",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"astro": "astro"
|
"astro": "astro",
|
||||||
|
"db:migrate-sheets": "npx tsx scripts/migrate-sheets-to-db.ts",
|
||||||
|
"db:seed-form-config": "npx tsx scripts/seed-form-config.ts",
|
||||||
|
"db:setup": "npx tsx scripts/seed-form-config.ts && npx tsx scripts/migrate-sheets-to-db.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/markdoc": "^2.0.3",
|
"@astrojs/markdoc": "^2.0.3",
|
||||||
|
|
@ -42,6 +45,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"daisyui": "^5.5.18"
|
"daisyui": "^5.5.18",
|
||||||
|
"dotenv": "^17.4.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,9 @@ importers:
|
||||||
daisyui:
|
daisyui:
|
||||||
specifier: ^5.5.18
|
specifier: ^5.5.18
|
||||||
version: 5.5.20
|
version: 5.5.20
|
||||||
|
dotenv:
|
||||||
|
specifier: ^17.4.2
|
||||||
|
version: 17.4.2
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,6 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
engine: "classic",
|
engine: "classic",
|
||||||
datasource: {
|
datasource: {
|
||||||
url: env("DATABASE_URL"),
|
url: env("DIRECT_URL") || env("DATABASE_URL"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "sqlite"
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Contact" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"nombre" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"mensaje" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Contact_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Contact_email_key" ON "Contact"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Contact_email_createdAt_idx" ON "Contact"("email", "createdAt");
|
||||||
|
|
@ -0,0 +1,174 @@
|
||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the `Contact` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "Contact";
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "contact" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"nombre" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"mensaje" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "contact_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "formularios" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"numero_voluntario" SERIAL NOT NULL,
|
||||||
|
"nombre" TEXT NOT NULL,
|
||||||
|
"segundo_nombre" TEXT,
|
||||||
|
"apellido" TEXT NOT NULL,
|
||||||
|
"documento_nro" TEXT,
|
||||||
|
"documento_tipo" TEXT,
|
||||||
|
"correo" TEXT NOT NULL,
|
||||||
|
"fecha_nacimiento" DATE,
|
||||||
|
"nacionalidad" TEXT,
|
||||||
|
"sexo" TEXT,
|
||||||
|
"direccion" TEXT,
|
||||||
|
"ciudad" TEXT,
|
||||||
|
"estado" TEXT,
|
||||||
|
"pais" TEXT,
|
||||||
|
"codigo_postal" TEXT,
|
||||||
|
"telefono" TEXT,
|
||||||
|
"whatsapp" TEXT,
|
||||||
|
"profesion" TEXT,
|
||||||
|
"lugar_trabajo" TEXT,
|
||||||
|
"nivel_academico" TEXT,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'pendiente',
|
||||||
|
"form_version" TEXT,
|
||||||
|
"email_sent_at" TIMESTAMPTZ,
|
||||||
|
"submitted_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "formularios_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "respuestas" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"formulario_id" UUID NOT NULL,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
"seccion" TEXT NOT NULL DEFAULT '',
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "respuestas_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "secciones" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"title_key" TEXT NOT NULL,
|
||||||
|
"title" TEXT,
|
||||||
|
"orden" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"columns" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "secciones_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "campos" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"label_key" TEXT NOT NULL,
|
||||||
|
"tipo" TEXT NOT NULL,
|
||||||
|
"seccion_id" UUID,
|
||||||
|
"required" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"orden" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"options" JSONB,
|
||||||
|
"source" TEXT,
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "campos_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "form_versions" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"version" TEXT NOT NULL,
|
||||||
|
"config" JSONB NOT NULL,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "form_versions_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "admins" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"auth_user_id" UUID NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"nombre" TEXT NOT NULL,
|
||||||
|
"rol" TEXT NOT NULL DEFAULT 'coordinador',
|
||||||
|
"activo" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"ultimo_acceso" TIMESTAMPTZ,
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "admins_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "contact_email_key" ON "contact"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "contact_email_createdAt_idx" ON "contact"("email", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "formularios_numero_voluntario_key" ON "formularios"("numero_voluntario");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "formularios_correo_idx" ON "formularios"("correo");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "formularios_status_idx" ON "formularios"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "formularios_pais_idx" ON "formularios"("pais");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "formularios_submitted_at_idx" ON "formularios"("submitted_at");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "respuestas_formulario_id_idx" ON "respuestas"("formulario_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "respuestas_key_idx" ON "respuestas"("key");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "respuestas_seccion_idx" ON "respuestas"("seccion");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "secciones_orden_idx" ON "secciones"("orden");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "campos_key_key" ON "campos"("key");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "campos_seccion_id_idx" ON "campos"("seccion_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "campos_tipo_idx" ON "campos"("tipo");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "admins_auth_user_id_key" ON "admins"("auth_user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "admins_email_key" ON "admins"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "admins_email_idx" ON "admins"("email");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "respuestas" ADD CONSTRAINT "respuestas_formulario_id_fkey" FOREIGN KEY ("formulario_id") REFERENCES "formularios"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "campos" ADD CONSTRAINT "campos_seccion_id_fkey" FOREIGN KEY ("seccion_id") REFERENCES "secciones"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
-- Enable pg_trgm for trigram-based GIN indexes
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "formularios_nombre_trgm_idx" ON "formularios" USING GIN ("nombre" gin_trgm_ops);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "formularios_apellido_trgm_idx" ON "formularios" USING GIN ("apellido" gin_trgm_ops);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "formularios_correo_trgm_idx" ON "formularios" USING GIN ("correo" gin_trgm_ops);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "formularios_telefono_trgm_idx" ON "formularios" USING GIN ("telefono" gin_trgm_ops);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "formularios_whatsapp_trgm_idx" ON "formularios" USING GIN ("whatsapp" gin_trgm_ops);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "formularios_documento_nro_trgm_idx" ON "formularios" USING GIN ("documento_nro" gin_trgm_ops);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "respuestas_value_trgm_idx" ON "respuestas" USING GIN ("value" gin_trgm_ops);
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "formularios_ciudad_trgm_idx" ON "formularios" USING GIN ("ciudad" gin_trgm_ops);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "formularios_estado_trgm_idx" ON "formularios" USING GIN ("estado" gin_trgm_ops);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "formularios_profesion_trgm_idx" ON "formularios" USING GIN ("profesion" gin_trgm_ops);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "formularios_nivel_academico_trgm_idx" ON "formularios" USING GIN ("nivel_academico" gin_trgm_ops);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "formularios_nacionalidad_trgm_idx" ON "formularios" USING GIN ("nacionalidad" gin_trgm_ops);
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "admins" ALTER COLUMN "auth_user_id" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "admins" ADD COLUMN "password_hash" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "sessions" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"token_hash" TEXT NOT NULL,
|
||||||
|
"admin_id" UUID NOT NULL,
|
||||||
|
"expires_at" TIMESTAMPTZ NOT NULL,
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "sessions_token_hash_key" ON "sessions"("token_hash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "sessions_admin_id_idx" ON "sessions"("admin_id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_admin_id_fkey" FOREIGN KEY ("admin_id") REFERENCES "admins"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "admins" ADD COLUMN "username" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "admins_username_key" ON "admins"("username");
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# Please do not edit this file manually
|
# Please do not edit this file manually
|
||||||
# It should be added in your version-control system (e.g., Git)
|
# It should be added in your version-control system (e.g., Git)
|
||||||
provider = "sqlite"
|
provider = "postgresql"
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ generator client {
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "sqlite"
|
provider = "postgresql"
|
||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
|
directUrl = env("DIRECT_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Contact {
|
model Contact {
|
||||||
|
|
@ -16,4 +17,144 @@ model Contact {
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([email, createdAt])
|
@@index([email, createdAt])
|
||||||
}
|
@@map("contact")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Formulario {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
numero_voluntario Int @unique @default(autoincrement())
|
||||||
|
nombre String
|
||||||
|
segundo_nombre String?
|
||||||
|
apellido String
|
||||||
|
documento_nro String?
|
||||||
|
documento_tipo String?
|
||||||
|
correo String
|
||||||
|
fecha_nacimiento DateTime? @db.Date
|
||||||
|
nacionalidad String?
|
||||||
|
sexo String?
|
||||||
|
direccion String?
|
||||||
|
ciudad String?
|
||||||
|
estado String?
|
||||||
|
pais String?
|
||||||
|
codigo_postal String?
|
||||||
|
telefono String?
|
||||||
|
whatsapp String?
|
||||||
|
profesion String?
|
||||||
|
lugar_trabajo String?
|
||||||
|
nivel_academico String?
|
||||||
|
status String @default("pendiente")
|
||||||
|
form_version String?
|
||||||
|
email_sent_at DateTime? @db.Timestamptz
|
||||||
|
submitted_at DateTime @default(now()) @db.Timestamptz
|
||||||
|
updated_at DateTime @updatedAt @db.Timestamptz
|
||||||
|
|
||||||
|
respuestas Respuesta[]
|
||||||
|
|
||||||
|
@@index([correo])
|
||||||
|
@@index([status])
|
||||||
|
@@index([pais])
|
||||||
|
@@index([submitted_at])
|
||||||
|
@@index([nombre(ops: raw("gin_trgm_ops"))], type: Gin, map: "formularios_nombre_trgm_idx")
|
||||||
|
@@index([apellido(ops: raw("gin_trgm_ops"))], type: Gin, map: "formularios_apellido_trgm_idx")
|
||||||
|
@@index([correo(ops: raw("gin_trgm_ops"))], type: Gin, map: "formularios_correo_trgm_idx")
|
||||||
|
@@index([telefono(ops: raw("gin_trgm_ops"))], type: Gin, map: "formularios_telefono_trgm_idx")
|
||||||
|
@@index([whatsapp(ops: raw("gin_trgm_ops"))], type: Gin, map: "formularios_whatsapp_trgm_idx")
|
||||||
|
@@index([documento_nro(ops: raw("gin_trgm_ops"))], type: Gin, map: "formularios_documento_nro_trgm_idx")
|
||||||
|
@@index([ciudad(ops: raw("gin_trgm_ops"))], type: Gin, map: "formularios_ciudad_trgm_idx")
|
||||||
|
@@index([estado(ops: raw("gin_trgm_ops"))], type: Gin, map: "formularios_estado_trgm_idx")
|
||||||
|
@@index([profesion(ops: raw("gin_trgm_ops"))], type: Gin, map: "formularios_profesion_trgm_idx")
|
||||||
|
@@index([nivel_academico(ops: raw("gin_trgm_ops"))], type: Gin, map: "formularios_nivel_academico_trgm_idx")
|
||||||
|
@@index([nacionalidad(ops: raw("gin_trgm_ops"))], type: Gin, map: "formularios_nacionalidad_trgm_idx")
|
||||||
|
@@map("formularios")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Respuesta {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
formulario_id String @db.Uuid
|
||||||
|
key String
|
||||||
|
value String
|
||||||
|
seccion String @default("")
|
||||||
|
created_at DateTime @default(now()) @db.Timestamptz
|
||||||
|
|
||||||
|
formulario Formulario @relation(fields: [formulario_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([formulario_id])
|
||||||
|
@@index([key])
|
||||||
|
@@index([seccion])
|
||||||
|
@@index([value(ops: raw("gin_trgm_ops"))], type: Gin, map: "respuestas_value_trgm_idx")
|
||||||
|
@@map("respuestas")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Seccion {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
title_key String
|
||||||
|
title String?
|
||||||
|
orden Int @default(0)
|
||||||
|
columns Int @default(1)
|
||||||
|
created_at DateTime @default(now()) @db.Timestamptz
|
||||||
|
|
||||||
|
campos Campo[]
|
||||||
|
|
||||||
|
@@index([orden])
|
||||||
|
@@map("secciones")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Campo {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
key String @unique
|
||||||
|
label_key String
|
||||||
|
tipo String
|
||||||
|
seccion_id String? @db.Uuid
|
||||||
|
required Boolean @default(false)
|
||||||
|
orden Int @default(0)
|
||||||
|
options Json? @db.JsonB
|
||||||
|
source String?
|
||||||
|
created_at DateTime @default(now()) @db.Timestamptz
|
||||||
|
|
||||||
|
seccion Seccion? @relation(fields: [seccion_id], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([seccion_id])
|
||||||
|
@@index([tipo])
|
||||||
|
@@map("campos")
|
||||||
|
}
|
||||||
|
|
||||||
|
model FormVersion {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
version String
|
||||||
|
config Json @db.JsonB
|
||||||
|
active Boolean @default(false)
|
||||||
|
created_at DateTime @default(now()) @db.Timestamptz
|
||||||
|
|
||||||
|
@@map("form_versions")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Admin {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
auth_user_id String? @unique @db.Uuid
|
||||||
|
email String @unique
|
||||||
|
username String? @unique
|
||||||
|
nombre String
|
||||||
|
password_hash String?
|
||||||
|
rol String @default("coordinador")
|
||||||
|
activo Boolean @default(true)
|
||||||
|
ultimo_acceso DateTime? @db.Timestamptz
|
||||||
|
created_at DateTime @default(now()) @db.Timestamptz
|
||||||
|
|
||||||
|
sessions Session[]
|
||||||
|
|
||||||
|
@@index([email])
|
||||||
|
@@map("admins")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Session {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
token_hash String @unique
|
||||||
|
admin_id String @db.Uuid
|
||||||
|
expires_at DateTime @db.Timestamptz
|
||||||
|
created_at DateTime @default(now()) @db.Timestamptz
|
||||||
|
|
||||||
|
admin Admin @relation(fields: [admin_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([admin_id])
|
||||||
|
@@map("sessions")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>TinaCMS</title>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<!-- if development -->
|
|
||||||
<script type="module">
|
|
||||||
import RefreshRuntime from 'http://localhost:4001/@react-refresh'
|
|
||||||
RefreshRuntime.injectIntoGlobalHook(window)
|
|
||||||
window.$RefreshReg$ = () => {}
|
|
||||||
window.$RefreshSig$ = () => (type) => type
|
|
||||||
window.__vite_plugin_react_preamble_installed__ = true
|
|
||||||
</script>
|
|
||||||
<script type="module" src="http://localhost:4001/@vite/client"></script>
|
|
||||||
<script>
|
|
||||||
function handleLoadError() {
|
|
||||||
// Assets have failed to load
|
|
||||||
document.getElementById('root').innerHTML = '<style type="text/css"> #no-assets-placeholder body { font-family: sans-serif; font-size: 16px; line-height: 1.4; color: #333; background-color: #f5f5f5; } #no-assets-placeholder { max-width: 600px; margin: 0 auto; padding: 40px; text-align: center; background-color: #fff; box-shadow: 0px 0px 20px rgba(0, 0, 0, 0.1); } #no-assets-placeholder h1 { font-size: 24px; margin-bottom: 20px; } #no-assets-placeholder p { margin-bottom: 10px; } #no-assets-placeholder a { color: #0077cc; text-decoration: none; } #no-assets-placeholder a:hover { text-decoration: underline; } </style> <div id="no-assets-placeholder"> <h1>Failed loading TinaCMS assets</h1> <p> Your TinaCMS configuration may be misconfigured, and we could not load the assets for this page. </p> <p> Please visit <a href="https://tina.io/docs/r/FAQ/#13-how-do-i-resolve-failed-loading-tinacms-assets-error">this doc</a> for help. </p> </div> </div>';
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<script
|
|
||||||
type="module"
|
|
||||||
src="http://localhost:4001/src/main.tsx"
|
|
||||||
onerror="handleLoadError()"
|
|
||||||
></script>
|
|
||||||
<body class="tina-tailwind">
|
|
||||||
<div id="root"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -12,6 +12,7 @@ const tl = createTranslator(Astro.currentLocale);
|
||||||
const currentLocale = Astro.currentLocale;
|
const currentLocale = Astro.currentLocale;
|
||||||
const currentPath = Astro.url.pathname;
|
const currentPath = Astro.url.pathname;
|
||||||
const { locale } = Astro.params;
|
const { locale } = Astro.params;
|
||||||
|
const isAdmin = currentPath.split("/").includes("admin");
|
||||||
|
|
||||||
const languages = [
|
const languages = [
|
||||||
{ code: "es", icon: "icon_flag_es", label: "Español" },
|
{ code: "es", icon: "icon_flag_es", label: "Español" },
|
||||||
|
|
@ -156,6 +157,18 @@ function translatePath(newLocale: string) {
|
||||||
</li>
|
</li>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
{isAdmin && (
|
||||||
|
<li>
|
||||||
|
<form method="POST" action="/api/admin/auth/logout">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full text-left text-white hover:text-colorPrimary transition cursor-pointer"
|
||||||
|
>
|
||||||
|
Salir
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
<div class="w-50">
|
<div class="w-50">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -202,6 +215,18 @@ function translatePath(newLocale: string) {
|
||||||
variant="primary"
|
variant="primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<div class="hidden md:block">
|
||||||
|
<form method="POST" action="/api/admin/auth/logout">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="bg-[#22523F] text-[#EBE6D2] border-0 hover:bg-[#EBE6D2]/90 hover:text-tertiary uppercase rounded-none font-bold transition block text-center px-4 py-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
Salir
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div class="dropdown dropdown-end md:block hidden">
|
<div class="dropdown dropdown-end md:block hidden">
|
||||||
<div
|
<div
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,210 @@
|
||||||
|
<template>
|
||||||
|
<div class="space-y-8">
|
||||||
|
<div v-if="loading" class="flex justify-center py-16">
|
||||||
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="error" class="text-center py-10 text-error">{{ error }}</div>
|
||||||
|
|
||||||
|
<template v-else-if="stats">
|
||||||
|
<!-- Resumen -->
|
||||||
|
<section>
|
||||||
|
<h3 class="text-xs font-bold text-tertiary uppercase tracking-wide flex items-center gap-2 mb-3">
|
||||||
|
<span class="inline-block w-1 h-3.5 bg-colorSecondary"></span>Resumen
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<div class="stat bg-colorPrimary ring-1 ring-colorSecondary/40 rounded-box p-4">
|
||||||
|
<div class="stat-title text-tertiary/70 text-xs font-medium">Total voluntarios</div>
|
||||||
|
<div class="stat-value text-tertiary font-primary">{{ fmt(stats.total) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat bg-colorPrimary ring-1 ring-colorSecondary/40 rounded-box p-4">
|
||||||
|
<div class="stat-title text-tertiary/70 text-xs font-medium">Edad promedio</div>
|
||||||
|
<div class="stat-value text-tertiary font-primary">{{ stats.edad.promedio ?? "—" }} años</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat bg-colorPrimary ring-1 ring-colorSecondary/40 rounded-box p-4">
|
||||||
|
<div class="stat-title text-tertiary/70 text-xs font-medium">Femenino</div>
|
||||||
|
<div class="stat-value text-tertiary font-primary">{{ fmt(sexo.f) }}</div>
|
||||||
|
<div class="stat-desc text-tertiary/60">{{ pct(sexo.f, sexo.total) }}% del total</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat bg-colorPrimary ring-1 ring-colorSecondary/40 rounded-box p-4">
|
||||||
|
<div class="stat-title text-tertiary/70 text-xs font-medium">Masculino</div>
|
||||||
|
<div class="stat-value text-tertiary font-primary">{{ fmt(sexo.m) }}</div>
|
||||||
|
<div class="stat-desc text-tertiary/60">{{ pct(sexo.m, sexo.total) }}% del total</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Idiomas -->
|
||||||
|
<section>
|
||||||
|
<h3 class="text-xs font-bold text-tertiary uppercase tracking-wide flex items-center gap-2 mb-3">
|
||||||
|
<span class="inline-block w-1 h-3.5 bg-colorSecondary"></span>Idiomas
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||||
|
<div
|
||||||
|
v-for="lang in stats.idiomas"
|
||||||
|
:key="lang.value"
|
||||||
|
class="stat bg-colorPrimary ring-1 ring-colorSecondary/40 rounded-box p-4"
|
||||||
|
>
|
||||||
|
<div class="stat-title text-tertiary/70 text-xs font-medium">{{ lang.label }}</div>
|
||||||
|
<div class="stat-value text-tertiary font-primary text-2xl">{{ fmt(lang.count) }}</div>
|
||||||
|
<div class="stat-desc text-tertiary/60">{{ pct(lang.count, stats.total) }}% del total</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Distribuciones -->
|
||||||
|
<section>
|
||||||
|
<h3 class="text-xs font-bold text-tertiary uppercase tracking-wide flex items-center gap-2 mb-3">
|
||||||
|
<span class="inline-block w-1 h-3.5 bg-colorSecondary"></span>Distribuciones
|
||||||
|
</h3>
|
||||||
|
<div class="grid md:grid-cols-2 gap-4">
|
||||||
|
<div class="card bg-colorPrimary ring-1 ring-colorSecondary/40 rounded-box">
|
||||||
|
<div class="card-body p-4 sm:p-5">
|
||||||
|
<h4 class="card-title text-tertiary text-sm font-bold">Áreas de colaboración</h4>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div v-for="item in topAreas" :key="item.value" class="flex items-center gap-3">
|
||||||
|
<span class="w-32 sm:w-44 shrink-0 text-sm text-tertiary truncate" :title="item.label">{{ item.label }}</span>
|
||||||
|
<progress class="progress progress-secondary flex-1" :value="item.count" :max="areasMax"></progress>
|
||||||
|
<span class="w-12 shrink-0 text-right text-sm font-semibold text-tertiary">{{ fmt(item.count) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-colorPrimary ring-1 ring-colorSecondary/40 rounded-box">
|
||||||
|
<div class="card-body p-4 sm:p-5">
|
||||||
|
<h4 class="card-title text-tertiary text-sm font-bold">Días disponibles</h4>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div v-for="item in stats.dias" :key="item.value" class="flex items-center gap-3">
|
||||||
|
<span class="w-32 sm:w-44 shrink-0 text-sm text-tertiary truncate" :title="item.label">{{ item.label }}</span>
|
||||||
|
<progress class="progress progress-secondary flex-1" :value="item.count" :max="diasMax"></progress>
|
||||||
|
<span class="w-12 shrink-0 text-right text-sm font-semibold text-tertiary">{{ fmt(item.count) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-colorPrimary ring-1 ring-colorSecondary/40 rounded-box">
|
||||||
|
<div class="card-body p-4 sm:p-5">
|
||||||
|
<h4 class="card-title text-tertiary text-sm font-bold">Horario preferido</h4>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div v-for="item in stats.horarios" :key="item.value" class="flex items-center gap-3">
|
||||||
|
<span class="w-32 sm:w-44 shrink-0 text-sm text-tertiary truncate" :title="item.label">{{ item.label }}</span>
|
||||||
|
<progress class="progress progress-secondary flex-1" :value="item.count" :max="horariosMax"></progress>
|
||||||
|
<span class="w-12 shrink-0 text-right text-sm font-semibold text-tertiary">{{ fmt(item.count) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-colorPrimary ring-1 ring-colorSecondary/40 rounded-box">
|
||||||
|
<div class="card-body p-4 sm:p-5">
|
||||||
|
<h4 class="card-title text-tertiary text-sm font-bold">Países</h4>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div v-for="item in stats.paises" :key="item.value" class="flex items-center gap-3">
|
||||||
|
<span class="w-32 sm:w-44 shrink-0 text-sm text-tertiary truncate" :title="item.value">{{ item.value }}</span>
|
||||||
|
<progress class="progress progress-secondary flex-1" :value="item.count" :max="paisesMax"></progress>
|
||||||
|
<span class="w-12 shrink-0 text-right text-sm font-semibold text-tertiary">{{ fmt(item.count) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Disponibilidad y salud -->
|
||||||
|
<section>
|
||||||
|
<h3 class="text-xs font-bold text-tertiary uppercase tracking-wide flex items-center gap-2 mb-3">
|
||||||
|
<span class="inline-block w-1 h-3.5 bg-colorSecondary"></span>Disponibilidad y salud
|
||||||
|
</h3>
|
||||||
|
<div class="card bg-colorPrimary ring-1 ring-colorSecondary/40 rounded-box">
|
||||||
|
<div class="card-body p-4 sm:p-6 grid md:grid-cols-2 gap-x-8 gap-y-5">
|
||||||
|
<div v-for="m in condMetrics" :key="m.title" class="space-y-1.5">
|
||||||
|
<div class="flex justify-between text-sm text-tertiary">
|
||||||
|
<span class="font-medium">{{ m.title }}</span>
|
||||||
|
<span>Sí {{ m.pct }}% · No {{ 100 - m.pct }}%</span>
|
||||||
|
</div>
|
||||||
|
<progress class="progress progress-secondary w-full" :value="m.si" :max="m.total"></progress>
|
||||||
|
<p class="text-xs text-tertiary/60">
|
||||||
|
Sí: {{ fmt(m.si) }} · No: {{ fmt(m.no) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<div class="flex justify-between text-sm text-tertiary">
|
||||||
|
<span class="font-medium">Sexo</span>
|
||||||
|
<span>F {{ pct(sexo.f, sexo.total) }}% · M {{ pct(sexo.m, sexo.total) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex w-full h-3 rounded-none overflow-hidden bg-tertiary/15">
|
||||||
|
<div class="bg-tertiary h-full" :style="{ width: pct(sexo.f, sexo.total) + '%' }"></div>
|
||||||
|
<div class="bg-colorSecondary h-full" :style="{ width: pct(sexo.m, sexo.total) + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-tertiary/60">
|
||||||
|
F: {{ fmt(sexo.f) }} · M: {{ fmt(sexo.m) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from "vue";
|
||||||
|
import { cachedFetch } from "../../lib/adminCache";
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref("");
|
||||||
|
const stats = ref(null);
|
||||||
|
|
||||||
|
const fmt = (n) => Number(n || 0).toLocaleString("es");
|
||||||
|
const pct = (n, total) => (total ? Math.round(((n || 0) / total) * 100) : 0);
|
||||||
|
|
||||||
|
const sexo = computed(() => {
|
||||||
|
const s = stats.value?.sexo || [];
|
||||||
|
const f = s.find((x) => x.value === "F")?.count || 0;
|
||||||
|
const m = s.find((x) => x.value === "M")?.count || 0;
|
||||||
|
return { f, m, total: f + m };
|
||||||
|
});
|
||||||
|
|
||||||
|
const topAreas = computed(() => (stats.value?.areas || []).slice(0, 8));
|
||||||
|
const areasMax = computed(() => topAreas.value[0]?.count || 1);
|
||||||
|
const diasMax = computed(() => stats.value?.dias[0]?.count || 1);
|
||||||
|
const horariosMax = computed(() => stats.value?.horarios[0]?.count || 1);
|
||||||
|
const paisesMax = computed(() => stats.value?.paises[0]?.count || 1);
|
||||||
|
|
||||||
|
const condMetrics = computed(() => {
|
||||||
|
const c = stats.value?.condiciones;
|
||||||
|
if (!c) return [];
|
||||||
|
const mk = (title, key) => {
|
||||||
|
const si = c[key].si || 0;
|
||||||
|
const no = c[key].no || 0;
|
||||||
|
const total = si + no;
|
||||||
|
return { title, si, no, total, pct: total ? Math.round((si / total) * 100) : 0 };
|
||||||
|
};
|
||||||
|
return [
|
||||||
|
mk("Disponible fuera de ciudad", "fuera_ciudad"),
|
||||||
|
mk("Misiones internacionales", "misiones_internacionales"),
|
||||||
|
mk("Condición médica", "condicion_medica"),
|
||||||
|
mk("Voluntariado anterior", "voluntariado_anterior"),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchStats() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
const res = await cachedFetch("/api/admin/voluntarios/stats", { ttl: 60 });
|
||||||
|
const json = await res.json();
|
||||||
|
if (!json.success) throw new Error(json.message || "Error al cargar estadísticas");
|
||||||
|
stats.value = json;
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchStats);
|
||||||
|
</script>
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
<template>
|
||||||
|
<div ref="rootEl" class="relative">
|
||||||
|
<div
|
||||||
|
class="combobox-input input input-sm flex flex-wrap items-center gap-1 w-full cursor-text"
|
||||||
|
@click="focusInput"
|
||||||
|
>
|
||||||
|
<template v-if="multiple">
|
||||||
|
<span
|
||||||
|
v-for="item in currentValue"
|
||||||
|
:key="item"
|
||||||
|
class="badge badge-sm badge-neutral gap-1"
|
||||||
|
>
|
||||||
|
{{ item }}
|
||||||
|
<button type="button" class="btn btn-ghost btn-xs btn-circle" @click.stop="toggle(item)">✕</button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<input
|
||||||
|
ref="inputEl"
|
||||||
|
v-model="query"
|
||||||
|
type="text"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
class="flex-1 min-w-[4rem]"
|
||||||
|
@input="onInput"
|
||||||
|
@focus="open = true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
v-if="open"
|
||||||
|
class="absolute z-30 mt-1 w-full max-h-64 overflow-y-auto bg-base-100 rounded-box shadow ring-1 ring-base-300"
|
||||||
|
@mousedown.prevent
|
||||||
|
>
|
||||||
|
<li v-if="loading" class="px-3 py-2 text-sm text-base-content/50 flex items-center gap-2">
|
||||||
|
<span class="loading loading-spinner loading-xs"></span> Cargando…
|
||||||
|
</li>
|
||||||
|
<li v-else-if="options.length === 0" class="px-3 py-2 text-sm text-base-content/50">
|
||||||
|
{{ multiple ? "Sin opciones" : "Sin sugerencias — sigue escribiendo" }}
|
||||||
|
</li>
|
||||||
|
<li v-for="opt in options" :key="opt.value">
|
||||||
|
<label
|
||||||
|
v-if="multiple"
|
||||||
|
class="flex items-center gap-2 px-3 py-1.5 hover:bg-base-200 cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox checkbox-xs"
|
||||||
|
:checked="isSelected(opt.value)"
|
||||||
|
@change="toggle(opt.value)"
|
||||||
|
/>
|
||||||
|
<span class="flex-1 text-sm">
|
||||||
|
{{ opt.value }}
|
||||||
|
<span class="text-xs text-base-content/50">({{ opt.count }})</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-sm hover:bg-base-200"
|
||||||
|
@click="pick(opt.value)"
|
||||||
|
>
|
||||||
|
<span class="truncate">{{ opt.value }}</span>
|
||||||
|
<span class="text-xs text-base-content/50 shrink-0">({{ opt.count }})</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
|
||||||
|
import { cachedFetch } from "../../lib/adminCache";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
field: { type: String, required: true },
|
||||||
|
placeholder: { type: String, default: "Escribir…" },
|
||||||
|
multiple: { type: Boolean, default: false },
|
||||||
|
limit: { type: Number, default: 25 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const modelValue = defineModel({ type: [String, Array], default: "" });
|
||||||
|
|
||||||
|
const rootEl = ref(null);
|
||||||
|
const inputEl = ref(null);
|
||||||
|
const query = ref("");
|
||||||
|
const open = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
const options = ref([]);
|
||||||
|
|
||||||
|
let fetchTimer = null;
|
||||||
|
let fetchVersion = 0;
|
||||||
|
|
||||||
|
const currentValue = () => {
|
||||||
|
const v = modelValue.value;
|
||||||
|
return Array.isArray(v) ? v : typeof v === "string" ? v : "";
|
||||||
|
};
|
||||||
|
|
||||||
|
function focusInput() {
|
||||||
|
inputEl.value?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSelected(v) {
|
||||||
|
return currentValue().includes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(v) {
|
||||||
|
const arr = [...currentValue()];
|
||||||
|
const i = arr.indexOf(v);
|
||||||
|
if (i >= 0) arr.splice(i, 1);
|
||||||
|
else arr.push(v);
|
||||||
|
modelValue.value = arr;
|
||||||
|
query.value = "";
|
||||||
|
scheduleFetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(v) {
|
||||||
|
modelValue.value = v;
|
||||||
|
query.value = v;
|
||||||
|
open.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInput() {
|
||||||
|
if (!props.multiple) modelValue.value = query.value;
|
||||||
|
scheduleFetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOptions() {
|
||||||
|
loading.value = true;
|
||||||
|
const version = ++fetchVersion;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("field", props.field);
|
||||||
|
params.set("limit", String(props.limit));
|
||||||
|
if (query.value.trim().length >= 2) params.set("q", query.value.trim());
|
||||||
|
try {
|
||||||
|
const res = await cachedFetch(`/api/admin/voluntarios/distinct?${params.toString()}`, { ttl: 300 });
|
||||||
|
const json = await res.json();
|
||||||
|
if (version !== fetchVersion) return;
|
||||||
|
options.value = json.success ? json.data : [];
|
||||||
|
} catch {
|
||||||
|
if (version === fetchVersion) options.value = [];
|
||||||
|
} finally {
|
||||||
|
if (version === fetchVersion) loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleFetch() {
|
||||||
|
clearTimeout(fetchTimer);
|
||||||
|
fetchTimer = setTimeout(fetchOptions, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => modelValue.value,
|
||||||
|
(val) => {
|
||||||
|
if (!props.multiple && query.value !== val) query.value = typeof val === "string" ? val : "";
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function onDocClick(e) {
|
||||||
|
if (rootEl.value && !rootEl.value.contains(e.target)) open.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener("mousedown", onDocClick);
|
||||||
|
scheduleFetch();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener("mousedown", onDocClick);
|
||||||
|
clearTimeout(fetchTimer);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.combobox-input {
|
||||||
|
height: auto;
|
||||||
|
min-height: 2rem;
|
||||||
|
padding-top: 0.125rem;
|
||||||
|
padding-bottom: 0.125rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,285 @@
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div v-if="loading" class="flex justify-center py-16">
|
||||||
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="error" class="text-center py-10 text-error">{{ error }}</div>
|
||||||
|
|
||||||
|
<div v-else-if="data" class="space-y-6">
|
||||||
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||||
|
<h3 class="text-lg sm:text-xl font-bold">
|
||||||
|
{{ data.nombre }} {{ data.segundo_nombre }} {{ data.apellido }}
|
||||||
|
</h3>
|
||||||
|
<span class="font-mono text-sm opacity-70">#{{ data.numero_voluntario }}</span>
|
||||||
|
<span class="badge badge-sm" :class="badgeClass(data.status)">{{ data.status }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-for="sec in sectionsWithData"
|
||||||
|
:key="sec.key"
|
||||||
|
class="rounded-box ring-1 ring-base-300 bg-base-100/60 overflow-hidden"
|
||||||
|
>
|
||||||
|
<h4 class="font-medium text-sm uppercase tracking-wide px-4 py-3 border-b border-base-200">
|
||||||
|
{{ sec.title }}
|
||||||
|
</h4>
|
||||||
|
<dl class="divide-y divide-base-200/70 text-sm">
|
||||||
|
<div
|
||||||
|
v-for="f in sec.fields"
|
||||||
|
:key="f.key"
|
||||||
|
class="px-4 py-2.5 grid sm:grid-cols-[220px_1fr] gap-x-4 gap-y-1"
|
||||||
|
>
|
||||||
|
<dt class="text-base-content/60">{{ f.label }}</dt>
|
||||||
|
<dd class="min-w-0 break-words">
|
||||||
|
<div v-if="f.isList" :class="f.isLongList ? 'space-y-1.5' : 'flex flex-wrap gap-1.5'">
|
||||||
|
<div
|
||||||
|
v-if="f.isLongList"
|
||||||
|
v-for="(item, i) in f.displayValues"
|
||||||
|
:key="'list-' + i"
|
||||||
|
class="text-sm border-l-2 border-base-300 pl-2"
|
||||||
|
>
|
||||||
|
{{ item }}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
v-for="(item, i) in f.displayValues"
|
||||||
|
:key="'chip-' + i"
|
||||||
|
class="badge badge-sm badge-ghost border border-base-300 whitespace-normal text-left"
|
||||||
|
>
|
||||||
|
{{ item }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span v-else>{{ f.displayText }}</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-if="extraRows.length"
|
||||||
|
class="rounded-box ring-1 ring-base-300 bg-base-100/60 overflow-hidden"
|
||||||
|
>
|
||||||
|
<h4 class="font-medium text-sm uppercase tracking-wide px-4 py-3 border-b border-base-200">
|
||||||
|
Otros datos
|
||||||
|
</h4>
|
||||||
|
<dl class="divide-y divide-base-200/70 text-sm">
|
||||||
|
<div
|
||||||
|
v-for="r in extraRows"
|
||||||
|
:key="r.rawKey"
|
||||||
|
class="px-4 py-2.5 grid sm:grid-cols-[220px_1fr] gap-x-4 gap-y-1"
|
||||||
|
>
|
||||||
|
<dt class="text-base-content/60">{{ r.key }}</dt>
|
||||||
|
<dd class="min-w-0 break-words">{{ r.value }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-box ring-1 ring-base-300 bg-base-100/60 overflow-hidden">
|
||||||
|
<h4 class="font-medium text-sm uppercase tracking-wide px-4 py-3 border-b border-base-200">
|
||||||
|
Sistema
|
||||||
|
</h4>
|
||||||
|
<dl class="divide-y divide-base-200/70 text-sm">
|
||||||
|
<div class="px-4 py-2.5 grid sm:grid-cols-[220px_1fr] gap-x-4 gap-y-1">
|
||||||
|
<dt class="text-base-content/60">ID</dt>
|
||||||
|
<dd class="font-mono break-all">{{ data.id }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 py-2.5 grid sm:grid-cols-[220px_1fr] gap-x-4 gap-y-1">
|
||||||
|
<dt class="text-base-content/60">Versión de formulario</dt>
|
||||||
|
<dd>{{ data.form_version || "—" }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 py-2.5 grid sm:grid-cols-[220px_1fr] gap-x-4 gap-y-1">
|
||||||
|
<dt class="text-base-content/60">Correo enviado</dt>
|
||||||
|
<dd>{{ formatDateTime(data.email_sent_at) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 py-2.5 grid sm:grid-cols-[220px_1fr] gap-x-4 gap-y-1">
|
||||||
|
<dt class="text-base-content/60">Registrado</dt>
|
||||||
|
<dd>{{ formatDateTime(data.submitted_at) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 py-2.5 grid sm:grid-cols-[220px_1fr] gap-x-4 gap-y-1">
|
||||||
|
<dt class="text-base-content/60">Actualizado</dt>
|
||||||
|
<dd>{{ formatDateTime(data.updated_at) }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, onMounted } from "vue";
|
||||||
|
import { cachedFetch } from "../../lib/adminCache";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
numero: { type: [Number, String], required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const CORE_COLUMN_MAP = {
|
||||||
|
nombre: "nombre",
|
||||||
|
segundo_nombre: "segundo_nombre",
|
||||||
|
apellido: "apellido",
|
||||||
|
correo: "correo",
|
||||||
|
fecha_nacimiento: "fecha_nacimiento",
|
||||||
|
nacionalidad: "nacionalidad",
|
||||||
|
sexo: "sexo",
|
||||||
|
direccion_completa: "direccion",
|
||||||
|
ciudad: "ciudad",
|
||||||
|
estado: "estado",
|
||||||
|
pais: "pais",
|
||||||
|
postal: "codigo_postal",
|
||||||
|
telefono: "telefono",
|
||||||
|
whatsapp: "whatsapp",
|
||||||
|
profesion: "profesion",
|
||||||
|
lugar_trabajo_actual: "lugar_trabajo",
|
||||||
|
nivel_academico: "nivel_academico",
|
||||||
|
};
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref("");
|
||||||
|
const data = ref(null);
|
||||||
|
const sections = ref([]);
|
||||||
|
|
||||||
|
function pretty(v) {
|
||||||
|
if (v === "si") return "Sí";
|
||||||
|
if (v === "no") return "No";
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackLabel(key) {
|
||||||
|
if (key.startsWith("idioma_nivel_")) return `Nivel de ${key.slice("idioma_nivel_".length)}`;
|
||||||
|
if (key === "locale") return "Idioma de la solicitud";
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
const respuestasByKey = computed(() => {
|
||||||
|
const map = {};
|
||||||
|
for (const r of data.value?.respuestas || []) {
|
||||||
|
(map[r.key] ||= []).push(r.value);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
function fieldTokens(field) {
|
||||||
|
let values;
|
||||||
|
if (field.key === "documentos") {
|
||||||
|
const s = [data.value.documento_tipo, data.value.documento_nro].filter(Boolean).join(" ");
|
||||||
|
values = s ? [s] : [];
|
||||||
|
} else if (CORE_COLUMN_MAP[field.key]) {
|
||||||
|
const v = data.value[CORE_COLUMN_MAP[field.key]];
|
||||||
|
values = v === null || v === undefined || v === "" ? [] : [String(v)];
|
||||||
|
} else {
|
||||||
|
values = respuestasByKey.value[field.key] || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = [];
|
||||||
|
for (const raw of values) {
|
||||||
|
for (const part of String(raw).split(",")) {
|
||||||
|
const tok = part.trim();
|
||||||
|
if (!tok) continue;
|
||||||
|
const opt = (field.options || []).find((o) => o.value === tok);
|
||||||
|
let display = (opt ? opt.label : pretty(tok)).replace(/<[^>]*>/g, "");
|
||||||
|
if (field.tipo === "date" || field.key === "fecha_nacimiento") {
|
||||||
|
const d = new Date(display);
|
||||||
|
if (!isNaN(d)) {
|
||||||
|
display = d.toLocaleDateString("es", { year: "numeric", month: "2-digit", day: "2-digit" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokens.push(display);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionsWithData = computed(() => {
|
||||||
|
if (!data.value) return [];
|
||||||
|
return sections.value
|
||||||
|
.map((sec) => ({
|
||||||
|
...sec,
|
||||||
|
fields: sec.fields
|
||||||
|
.map((f) => {
|
||||||
|
const tokens = fieldTokens(f);
|
||||||
|
if (!tokens.length) return null;
|
||||||
|
return {
|
||||||
|
...f,
|
||||||
|
isList: f.tipo === "checkbox" && tokens.length > 1,
|
||||||
|
isLongList: f.tipo === "checkbox" && tokens.length > 1 && tokens.some((t) => t.length > 40),
|
||||||
|
displayValues: tokens,
|
||||||
|
displayText: tokens.join(", "),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean),
|
||||||
|
}))
|
||||||
|
.filter((sec) => sec.fields.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
const extraRows = computed(() => {
|
||||||
|
if (!data.value) return [];
|
||||||
|
const known = new Set();
|
||||||
|
for (const sec of sections.value) for (const f of sec.fields) known.add(f.key);
|
||||||
|
const rows = [];
|
||||||
|
for (const [key, vals] of Object.entries(respuestasByKey.value)) {
|
||||||
|
if (known.has(key)) continue;
|
||||||
|
const tokens = [];
|
||||||
|
for (const raw of vals) {
|
||||||
|
for (const part of String(raw).split(",")) {
|
||||||
|
const t = part.trim();
|
||||||
|
if (t) tokens.push(pretty(t));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!tokens.length) continue;
|
||||||
|
rows.push({ key: fallbackLabel(key), rawKey: key, value: tokens.join(", ") });
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchData() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
const res = await cachedFetch(`/api/admin/voluntarios/${props.numero}`, { ttl: 60 });
|
||||||
|
const json = await res.json();
|
||||||
|
if (!json.success) throw new Error(json.message || "Voluntario no encontrado");
|
||||||
|
data.value = json.data;
|
||||||
|
sections.value = json.form.sections;
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.numero,
|
||||||
|
() => {
|
||||||
|
data.value = null;
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function formatDateTime(iso) {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d)) return "—";
|
||||||
|
return d.toLocaleString("es", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function badgeClass(s) {
|
||||||
|
switch (s) {
|
||||||
|
case "aprobado":
|
||||||
|
return "badge-success";
|
||||||
|
case "rechazado":
|
||||||
|
return "badge-error";
|
||||||
|
case "legacy":
|
||||||
|
return "badge-warning";
|
||||||
|
default:
|
||||||
|
return "badge-neutral";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchData);
|
||||||
|
</script>
|
||||||
|
|
@ -0,0 +1,605 @@
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Filtros -->
|
||||||
|
<div class="bg-colorPrimary border-l-4 border-colorSecondary p-4 rounded-box space-y-3 shadow-lg shadow-tertiary/20">
|
||||||
|
<div class="flex flex-col md:flex-row gap-3 items-end">
|
||||||
|
<div class="flex-1 w-full">
|
||||||
|
<label class="label py-0 text-xs font-medium">Buscar</label>
|
||||||
|
<input
|
||||||
|
v-model="search"
|
||||||
|
type="text"
|
||||||
|
placeholder="Nombre, correo, teléfono, documento…"
|
||||||
|
class="input input-bordered input-sm w-full"
|
||||||
|
@input="onSearch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="w-full md:w-52">
|
||||||
|
<label class="label py-0 text-xs font-medium">Status</label>
|
||||||
|
<details class="dropdown w-full">
|
||||||
|
<summary class="btn btn-sm btn-outline w-full justify-between">
|
||||||
|
<span class="truncate">{{ statusLabel }}</span>
|
||||||
|
<span class="opacity-60">▾</span>
|
||||||
|
</summary>
|
||||||
|
<ul class="menu dropdown-content bg-base-100 rounded-box shadow ring-1 ring-base-300 z-20 w-56 max-h-64 overflow-y-auto">
|
||||||
|
<li v-for="s in statuses" :key="s">
|
||||||
|
<label class="flex items-center gap-2 py-1.5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:value="s"
|
||||||
|
v-model="selectedStatuses"
|
||||||
|
class="checkbox checkbox-xs"
|
||||||
|
@change="applyFilters"
|
||||||
|
/>
|
||||||
|
<span>{{ s }}</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button class="btn btn-ghost btn-sm text-tertiary" @click="clearFilters">Limpiar</button>
|
||||||
|
<button class="btn btn-primary btn-sm" @click="showFilters = !showFilters">
|
||||||
|
{{ showFilters ? "Ocultar filtros" : "Filtros" }}
|
||||||
|
<span v-if="activeFilterCount" class="badge badge-sm bg-tertiary text-colorPrimary border-0">{{ activeFilterCount }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chips de filtros activos -->
|
||||||
|
<div v-if="activeChips.length" class="flex flex-wrap gap-1.5">
|
||||||
|
<span
|
||||||
|
v-for="chip in activeChips"
|
||||||
|
:key="chip.key"
|
||||||
|
class="badge badge-sm bg-white gap-1 border border-colorSecondary/60 text-tertiary"
|
||||||
|
>
|
||||||
|
{{ chip.label }}
|
||||||
|
<button type="button" class="opacity-60 hover:opacity-100" @click="chip.remove()">✕</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Panel de filtros -->
|
||||||
|
<div v-if="showFilters" class="border-t border-colorSecondary/40 pt-3 space-y-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-bold text-tertiary mb-2 uppercase tracking-wide flex items-center gap-2">
|
||||||
|
<span class="inline-block w-1 h-3.5 bg-colorSecondary"></span>Fecha y edad
|
||||||
|
</p>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="label py-0 text-xs font-medium">Desde</label>
|
||||||
|
<input v-model="desde" type="date" class="input input-bordered input-sm w-full" @change="applyFilters" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label py-0 text-xs font-medium">Hasta</label>
|
||||||
|
<input v-model="hasta" type="date" class="input input-bordered input-sm w-full" @change="applyFilters" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label py-0 text-xs font-medium">Edad mín</label>
|
||||||
|
<input v-model="edadMin" type="number" min="0" max="120" class="input input-bordered input-sm w-full" @change="applyFilters" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label py-0 text-xs font-medium">Edad máx</label>
|
||||||
|
<input v-model="edadMax" type="number" min="0" max="120" class="input input-bordered input-sm w-full" @change="applyFilters" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-bold text-tertiary mb-2 uppercase tracking-wide flex items-center gap-2">
|
||||||
|
<span class="inline-block w-1 h-3.5 bg-colorSecondary"></span>Ubicación
|
||||||
|
</p>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="label py-0 text-xs font-medium">País</label>
|
||||||
|
<SearchableCombobox
|
||||||
|
v-model="selectedPaises"
|
||||||
|
field="pais"
|
||||||
|
multiple
|
||||||
|
placeholder="Buscar país…"
|
||||||
|
@update:modelValue="applyFilters"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label py-0 text-xs font-medium">Nacionalidad</label>
|
||||||
|
<SearchableCombobox
|
||||||
|
v-model="nacionalidad"
|
||||||
|
field="nacionalidad"
|
||||||
|
placeholder="Buscar…"
|
||||||
|
@update:modelValue="onSearch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label py-0 text-xs font-medium">Sexo</label>
|
||||||
|
<select v-model="sexo" class="select select-bordered select-sm w-full" @change="applyFilters">
|
||||||
|
<option value="">Todos</option>
|
||||||
|
<option value="M">M</option>
|
||||||
|
<option value="F">F</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label py-0 text-xs font-medium">Ciudad</label>
|
||||||
|
<SearchableCombobox
|
||||||
|
v-model="coreText.ciudad"
|
||||||
|
field="ciudad"
|
||||||
|
placeholder="Buscar…"
|
||||||
|
@update:modelValue="onSearch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label py-0 text-xs font-medium">Estado</label>
|
||||||
|
<SearchableCombobox
|
||||||
|
v-model="coreText.estado"
|
||||||
|
field="estado"
|
||||||
|
placeholder="Buscar…"
|
||||||
|
@update:modelValue="onSearch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-bold text-tertiary mb-2 uppercase tracking-wide flex items-center gap-2">
|
||||||
|
<span class="inline-block w-1 h-3.5 bg-colorSecondary"></span>Perfil
|
||||||
|
</p>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="label py-0 text-xs font-medium">Profesión</label>
|
||||||
|
<SearchableCombobox
|
||||||
|
v-model="coreText.profesion"
|
||||||
|
field="profesion"
|
||||||
|
placeholder="Buscar…"
|
||||||
|
@update:modelValue="onSearch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label py-0 text-xs font-medium">Nivel académico</label>
|
||||||
|
<SearchableCombobox
|
||||||
|
v-model="coreText.nivel_academico"
|
||||||
|
field="nivel_academico"
|
||||||
|
placeholder="Buscar…"
|
||||||
|
@update:modelValue="onSearch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label py-0 text-xs font-medium">Documento</label>
|
||||||
|
<input v-model="coreText.documento_nro" type="text" class="input input-bordered input-sm w-full" @input="onSearch" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label py-0 text-xs font-medium">Versión de formulario</label>
|
||||||
|
<select v-model="formVersion" class="select select-bordered select-sm w-full" @change="applyFilters">
|
||||||
|
<option value="">Todas</option>
|
||||||
|
<option v-for="f in formVersions" :key="f" :value="f">{{ f }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-bold text-tertiary mb-2 uppercase tracking-wide flex items-center gap-2">
|
||||||
|
<span class="inline-block w-1 h-3.5 bg-colorSecondary"></span>Preferencias y condiciones
|
||||||
|
</p>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||||
|
<div v-for="f in responseFields" :key="f">
|
||||||
|
<label class="label py-0 text-xs font-medium">{{ respLabels[f] }}</label>
|
||||||
|
<select v-model="respFilters[f]" class="select select-bordered select-sm w-full" @change="applyFilters">
|
||||||
|
<option value="">Todos</option>
|
||||||
|
<option v-for="o in respFilterOptions[f]" :key="o" :value="o">{{ o }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabla -->
|
||||||
|
<div class="overflow-x-auto bg-base-100 rounded-box shadow ring-1 ring-base-300">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-xs uppercase">
|
||||||
|
<th class="cursor-pointer select-none" @click="toggleSort('numero_voluntario')">#</th>
|
||||||
|
<th class="cursor-pointer select-none" @click="toggleSort('nombre')">Nombre completo</th>
|
||||||
|
<th>Correo</th>
|
||||||
|
<th>Teléfono</th>
|
||||||
|
<th class="cursor-pointer select-none" @click="toggleSort('pais')">País</th>
|
||||||
|
<th>Ciudad</th>
|
||||||
|
<th>Idiomas</th>
|
||||||
|
<th>Áreas</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="cursor-pointer select-none" @click="toggleSort('submitted_at')">Fecha</th>
|
||||||
|
<th class="w-20 text-right">Acción</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-if="loading">
|
||||||
|
<td colspan="11" class="text-center py-10">
|
||||||
|
<span class="loading loading-spinner loading-md text-primary"></span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else-if="error">
|
||||||
|
<td colspan="11" class="text-center py-10 text-error">{{ error }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else-if="rows.length === 0">
|
||||||
|
<td colspan="11" class="text-center py-10 text-base-content/40">Sin resultados</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="row in rows" :key="row.id" class="hover:bg-base-200/50 cursor-pointer" @click="openDetail(row)">
|
||||||
|
<td class="font-mono">{{ row.numero_voluntario }}</td>
|
||||||
|
<td class="font-medium whitespace-nowrap">{{ row.nombre_completo }}</td>
|
||||||
|
<td class="max-w-[220px] truncate">{{ row.correo }}</td>
|
||||||
|
<td class="whitespace-nowrap">{{ row.telefono || "—" }}</td>
|
||||||
|
<td class="whitespace-nowrap">{{ row.pais || "—" }}</td>
|
||||||
|
<td class="whitespace-nowrap">{{ row.ciudad || "—" }}</td>
|
||||||
|
<td class="max-w-[200px] truncate" :title="row.idioma">{{ row.idioma || "—" }}</td>
|
||||||
|
<td class="max-w-[200px] truncate" :title="row.areas_colaborar">{{ row.areas_colaborar || "—" }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-sm" :class="badgeClass(row.status)">{{ row.status || "pendiente" }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap">{{ formatDate(row.submitted_at) }}</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<button class="btn btn-ghost btn-xs" title="Ver detalle" @click.stop="openDetail(row)">Ver</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Paginación -->
|
||||||
|
<div class="flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<p class="text-sm text-amber-200">
|
||||||
|
{{ total }} registros · página {{ page }} de {{ totalPages || 1 }}
|
||||||
|
</p>
|
||||||
|
<div class="join">
|
||||||
|
<button class="join-item btn btn-sm" :disabled="page <= 1" @click="goPage(page - 1)">«</button>
|
||||||
|
<button
|
||||||
|
v-for="p in pageNumbers"
|
||||||
|
:key="p"
|
||||||
|
class="join-item btn btn-sm"
|
||||||
|
:class="p === page ? 'btn-primary' : ''"
|
||||||
|
@click="goPage(p)"
|
||||||
|
>
|
||||||
|
{{ p }}
|
||||||
|
</button>
|
||||||
|
<button class="join-item btn btn-sm" :disabled="page >= totalPages" @click="goPage(page + 1)">»</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal de detalle -->
|
||||||
|
<Teleport v-if="detailNumero !== null" to="body">
|
||||||
|
<div class="fixed inset-0 z-[60] overflow-y-auto">
|
||||||
|
<div class="fixed inset-0 bg-black/60" @click="closeDetail"></div>
|
||||||
|
<div class="relative min-h-full flex items-center justify-center p-4 sm:p-6">
|
||||||
|
<div class="w-full max-w-3xl max-h-[85vh] overflow-y-auto rounded-box bg-base-100 shadow-2xl ring-1 ring-base-300">
|
||||||
|
<div class="sticky top-0 z-10 flex items-center justify-between px-4 py-3 bg-base-100 border-b border-base-200">
|
||||||
|
<span class="text-sm font-semibold">Detalle del voluntario</span>
|
||||||
|
<button class="btn btn-ghost btn-sm" @click="closeDetail">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<VoluntarioDetail :numero="detailNumero" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, watch, onMounted, onUnmounted } from "vue";
|
||||||
|
import SearchableCombobox from "./SearchableCombobox.vue";
|
||||||
|
import VoluntarioDetail from "./VoluntarioDetail.vue";
|
||||||
|
import { cachedFetch } from "../../lib/adminCache";
|
||||||
|
|
||||||
|
const rows = ref([]);
|
||||||
|
const total = ref(0);
|
||||||
|
const page = ref(1);
|
||||||
|
const totalPages = ref(1);
|
||||||
|
const limit = 25;
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref("");
|
||||||
|
|
||||||
|
const detailNumero = ref(null);
|
||||||
|
let returnPath = "";
|
||||||
|
|
||||||
|
function slugUrl(numero) {
|
||||||
|
const m = window.location.pathname.match(/^\/([a-z]{2})\/admin/);
|
||||||
|
const locale = m ? m[1] : "es";
|
||||||
|
return `/${locale}/admin/voluntario/${numero}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetail(row) {
|
||||||
|
returnPath = window.location.pathname;
|
||||||
|
detailNumero.value = row.numero_voluntario;
|
||||||
|
history.pushState({ voluntario: true }, "", slugUrl(row.numero_voluntario));
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDetail() {
|
||||||
|
detailNumero.value = null;
|
||||||
|
history.replaceState(null, "", returnPath || window.location.pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPopstate() {
|
||||||
|
if (detailNumero.value !== null) detailNumero.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e) {
|
||||||
|
if (e.key === "Escape" && detailNumero.value !== null) closeDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(detailNumero, (v) => {
|
||||||
|
document.body.style.overflow = v === null ? "" : "hidden";
|
||||||
|
});
|
||||||
|
|
||||||
|
const search = ref("");
|
||||||
|
const selectedStatuses = ref([]);
|
||||||
|
const selectedPaises = ref([]);
|
||||||
|
const sort = ref("submitted_at");
|
||||||
|
const order = ref("desc");
|
||||||
|
const showFilters = ref(false);
|
||||||
|
|
||||||
|
const desde = ref("");
|
||||||
|
const hasta = ref("");
|
||||||
|
const edadMin = ref("");
|
||||||
|
const edadMax = ref("");
|
||||||
|
|
||||||
|
const coreText = reactive({
|
||||||
|
ciudad: "",
|
||||||
|
estado: "",
|
||||||
|
profesion: "",
|
||||||
|
nivel_academico: "",
|
||||||
|
documento_nro: "",
|
||||||
|
});
|
||||||
|
const sexo = ref("");
|
||||||
|
const nacionalidad = ref("");
|
||||||
|
const formVersion = ref("");
|
||||||
|
|
||||||
|
const responseFields = [
|
||||||
|
"idioma",
|
||||||
|
"areas_colaborar",
|
||||||
|
"dias_disponibles",
|
||||||
|
"horario_preferido",
|
||||||
|
"fuera_ciudad",
|
||||||
|
"misiones_internacionales",
|
||||||
|
"condicion_medica",
|
||||||
|
"voluntariado_anterior",
|
||||||
|
];
|
||||||
|
const respFilters = reactive(Object.fromEntries(responseFields.map((f) => [f, ""])));
|
||||||
|
|
||||||
|
const respLabels = {
|
||||||
|
idioma: "Idioma",
|
||||||
|
areas_colaborar: "Áreas de colaboración",
|
||||||
|
dias_disponibles: "Días disponibles",
|
||||||
|
horario_preferido: "Horario preferido",
|
||||||
|
fuera_ciudad: "Disponible fuera de ciudad",
|
||||||
|
misiones_internacionales: "Misiones internacionales",
|
||||||
|
condicion_medica: "Condición médica",
|
||||||
|
voluntariado_anterior: "Voluntariado anterior",
|
||||||
|
};
|
||||||
|
|
||||||
|
const respFilterOptions = {
|
||||||
|
idioma: ["Espanol", "Ingles", "Hebreo", "Portugues", "Frances", "Otro"],
|
||||||
|
areas_colaborar: [
|
||||||
|
"Ayuda Humanitaria", "Educacion", "Desarrollo Comunitario", "Liderazgo",
|
||||||
|
"Logistica", "Organizacion de Eventos", "Comunicacion Institucional",
|
||||||
|
"Fotografia y Medios", "Recaudacion de Fondos", "Gestion de Proyectos",
|
||||||
|
"Traduccion e Interpretacion", "Asesoria Juridica", "Servicios Medicos",
|
||||||
|
"Diplomacia Publica", "Administracion", "Tecnologia e Innovacion", "Otra",
|
||||||
|
],
|
||||||
|
dias_disponibles: ["Lunes", "Martes", "Miercoles", "Jueves", "Viernes", "Sabado", "Domingo"],
|
||||||
|
horario_preferido: ["Manana", "Tarde", "Noche", "Segun necesidades"],
|
||||||
|
fuera_ciudad: ["si", "no"],
|
||||||
|
misiones_internacionales: ["si", "no"],
|
||||||
|
condicion_medica: ["si", "no"],
|
||||||
|
voluntariado_anterior: ["si", "no"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const statuses = ref([]);
|
||||||
|
const formVersions = ref([]);
|
||||||
|
|
||||||
|
let searchTimer = null;
|
||||||
|
|
||||||
|
const statusLabel = computed(() =>
|
||||||
|
selectedStatuses.value.length ? selectedStatuses.value.join(", ") : "Todos"
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeFilterCount = computed(() => activeChips.value.length);
|
||||||
|
|
||||||
|
const activeChips = computed(() => {
|
||||||
|
const chips = [];
|
||||||
|
const add = (key, label, remove) => chips.push({ key, label, remove });
|
||||||
|
|
||||||
|
if (search.value.trim()) add("q", `Búsqueda: ${search.value.trim()}`, () => {
|
||||||
|
search.value = "";
|
||||||
|
onSearch();
|
||||||
|
});
|
||||||
|
for (const s of selectedStatuses.value) add(`status-${s}`, `Status: ${s}`, () => {
|
||||||
|
selectedStatuses.value = selectedStatuses.value.filter((x) => x !== s);
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
for (const p of selectedPaises.value) add(`pais-${p}`, `País: ${p}`, () => {
|
||||||
|
selectedPaises.value = selectedPaises.value.filter((x) => x !== p);
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
if (desde.value) add("desde", `Desde: ${desde.value}`, () => {
|
||||||
|
desde.value = "";
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
if (hasta.value) add("hasta", `Hasta: ${hasta.value}`, () => {
|
||||||
|
hasta.value = "";
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
if (edadMin.value) add("edad_min", `Edad mín: ${edadMin.value}`, () => {
|
||||||
|
edadMin.value = "";
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
if (edadMax.value) add("edad_max", `Edad máx: ${edadMax.value}`, () => {
|
||||||
|
edadMax.value = "";
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
for (const [k, v] of Object.entries(coreText)) {
|
||||||
|
if (v) {
|
||||||
|
const label = { ciudad: "Ciudad", estado: "Estado", profesion: "Profesión", nivel_academico: "Nivel académico", documento_nro: "Documento" }[k];
|
||||||
|
add(`core-${k}`, `${label}: ${v}`, () => {
|
||||||
|
coreText[k] = "";
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sexo.value) add("sexo", `Sexo: ${sexo.value}`, () => {
|
||||||
|
sexo.value = "";
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
if (nacionalidad.value) add("nacionalidad", `Nacionalidad: ${nacionalidad.value}`, () => {
|
||||||
|
nacionalidad.value = "";
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
if (formVersion.value) add("form_version", `Versión: ${formVersion.value}`, () => {
|
||||||
|
formVersion.value = "";
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
for (const f of responseFields) {
|
||||||
|
if (respFilters[f]) add(`resp-${f}`, `${respLabels[f]}: ${respFilters[f]}`, () => {
|
||||||
|
respFilters[f] = "";
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return chips;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchData() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = "";
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("page", String(page.value));
|
||||||
|
params.set("limit", String(limit));
|
||||||
|
params.set("sort", sort.value);
|
||||||
|
params.set("order", order.value);
|
||||||
|
if (search.value.trim()) params.set("q", search.value.trim());
|
||||||
|
if (selectedStatuses.value.length) params.set("status", selectedStatuses.value.join(","));
|
||||||
|
if (selectedPaises.value.length) params.set("pais", selectedPaises.value.join(","));
|
||||||
|
if (desde.value) params.set("desde", desde.value);
|
||||||
|
if (hasta.value) params.set("hasta", hasta.value);
|
||||||
|
if (edadMin.value) params.set("edad_min", edadMin.value);
|
||||||
|
if (edadMax.value) params.set("edad_max", edadMax.value);
|
||||||
|
for (const [f, v] of Object.entries(coreText)) if (v) params.set(f, v);
|
||||||
|
if (sexo.value) params.set("sexo", sexo.value);
|
||||||
|
if (nacionalidad.value) params.set("nacionalidad", nacionalidad.value);
|
||||||
|
if (formVersion.value) params.set("form_version", formVersion.value);
|
||||||
|
for (const f of responseFields) if (respFilters[f]) params.set(f, respFilters[f]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await cachedFetch(`/api/admin/voluntarios?${params.toString()}`, { ttl: 30 });
|
||||||
|
const json = await res.json();
|
||||||
|
if (!json.success) throw new Error(json.message || "Error al cargar");
|
||||||
|
rows.value = json.data;
|
||||||
|
total.value = json.total;
|
||||||
|
totalPages.value = json.totalPages;
|
||||||
|
page.value = json.page;
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOptions() {
|
||||||
|
try {
|
||||||
|
const res = await cachedFetch("/api/admin/voluntarios/options", { ttl: 300 });
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.success) {
|
||||||
|
statuses.value = json.statuses;
|
||||||
|
formVersions.value = json.form_versions;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// opciones no críticas
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearch() {
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(() => {
|
||||||
|
page.value = 1;
|
||||||
|
fetchData();
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
page.value = 1;
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
search.value = "";
|
||||||
|
selectedStatuses.value = [];
|
||||||
|
selectedPaises.value = [];
|
||||||
|
desde.value = "";
|
||||||
|
hasta.value = "";
|
||||||
|
edadMin.value = "";
|
||||||
|
edadMax.value = "";
|
||||||
|
Object.keys(coreText).forEach((k) => (coreText[k] = ""));
|
||||||
|
sexo.value = "";
|
||||||
|
nacionalidad.value = "";
|
||||||
|
formVersion.value = "";
|
||||||
|
Object.keys(respFilters).forEach((k) => (respFilters[k] = ""));
|
||||||
|
page.value = 1;
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSort(field) {
|
||||||
|
if (sort.value === field) {
|
||||||
|
order.value = order.value === "asc" ? "desc" : "asc";
|
||||||
|
} else {
|
||||||
|
sort.value = field;
|
||||||
|
order.value = "asc";
|
||||||
|
}
|
||||||
|
page.value = 1;
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function goPage(p) {
|
||||||
|
if (p < 1 || p > totalPages.value || p === page.value) return;
|
||||||
|
page.value = p;
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageNumbers = computed(() => {
|
||||||
|
const total = totalPages.value;
|
||||||
|
const current = page.value;
|
||||||
|
const start = Math.max(1, current - 2);
|
||||||
|
const end = Math.min(total, current + 2);
|
||||||
|
const pages = [];
|
||||||
|
for (let i = start; i <= end; i++) pages.push(i);
|
||||||
|
return pages;
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatDate(iso) {
|
||||||
|
if (!iso) return "—";
|
||||||
|
return new Date(iso).toLocaleDateString("es", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function badgeClass(s) {
|
||||||
|
switch (s) {
|
||||||
|
case "aprobado":
|
||||||
|
return "badge-success";
|
||||||
|
case "rechazado":
|
||||||
|
return "badge-error";
|
||||||
|
case "legacy":
|
||||||
|
return "badge-warning";
|
||||||
|
default:
|
||||||
|
return "badge-neutral";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchOptions();
|
||||||
|
fetchData();
|
||||||
|
window.addEventListener("popstate", onPopstate);
|
||||||
|
window.addEventListener("keydown", onKeydown);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener("popstate", onPopstate);
|
||||||
|
window.removeEventListener("keydown", onKeydown);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
@ -8,7 +8,7 @@ import "@fontsource/poppins/700.css";
|
||||||
import "@fontsource-variable/kameron";
|
import "@fontsource-variable/kameron";
|
||||||
import ShareSticky from "../components/ShareSticky.vue";
|
import ShareSticky from "../components/ShareSticky.vue";
|
||||||
import { routeTranslations } from "../i18n";
|
import { routeTranslations } from "../i18n";
|
||||||
const { title, description, image, url, date, noindex } = Astro.props;
|
const { title, description, image, url, date, noindex, theme } = Astro.props;
|
||||||
|
|
||||||
const currentLocale = Astro.currentLocale ?? "es";
|
const currentLocale = Astro.currentLocale ?? "es";
|
||||||
const direction = currentLocale === "he" ? "rtl" : "ltr";
|
const direction = currentLocale === "he" ? "rtl" : "ltr";
|
||||||
|
|
@ -21,7 +21,7 @@ const isNewsPage = contentSegments.some((segment) =>
|
||||||
);
|
);
|
||||||
---
|
---
|
||||||
|
|
||||||
<html lang={currentLocale} dir={direction} class="scroll-smooth">
|
<html lang={currentLocale} dir={direction} data-theme={theme ?? undefined} class="scroll-smooth">
|
||||||
<BaseHead
|
<BaseHead
|
||||||
title={title}
|
title={title}
|
||||||
description={description}
|
description={description}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
type CacheEntry = {
|
||||||
|
body: string;
|
||||||
|
status: number;
|
||||||
|
expiresAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PREFIX = "adminCache:";
|
||||||
|
|
||||||
|
export interface CachedFetchOptions {
|
||||||
|
ttl?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cachedFetch(
|
||||||
|
url: string,
|
||||||
|
options: CachedFetchOptions = {}
|
||||||
|
): Promise<Response> {
|
||||||
|
const ttl = options.ttl ?? 60;
|
||||||
|
const key = PREFIX + url;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(key);
|
||||||
|
if (raw) {
|
||||||
|
const entry = JSON.parse(raw) as CacheEntry;
|
||||||
|
if (
|
||||||
|
entry &&
|
||||||
|
typeof entry.expiresAt === "number" &&
|
||||||
|
entry.expiresAt > now
|
||||||
|
) {
|
||||||
|
return new Response(entry.body, {
|
||||||
|
status: entry.status,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// sessionStorage no disponible o entrada inválida
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url);
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
try {
|
||||||
|
const body = await res.text();
|
||||||
|
sessionStorage.setItem(
|
||||||
|
key,
|
||||||
|
JSON.stringify({
|
||||||
|
body,
|
||||||
|
status: res.status,
|
||||||
|
expiresAt: now + ttl * 1000,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return new Response(body, {
|
||||||
|
status: res.status,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// no se pudo cachear, devolver la respuesta original
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { defineMiddleware } from "astro:middleware";
|
||||||
|
import { getSession, isApiAuthorized } from "./pages/api/lib/auth";
|
||||||
|
|
||||||
|
const LOCALES = new Set(["es", "en", "fr", "he", "uk", "pt", "ru", "rw", "kr"]);
|
||||||
|
|
||||||
|
export const onRequest = defineMiddleware(async (context, next) => {
|
||||||
|
const { url, request, redirect } = context;
|
||||||
|
const pathname = url.pathname;
|
||||||
|
|
||||||
|
if (pathname === "/admin" || pathname === "/admin/") {
|
||||||
|
return redirect("/es/admin", 302);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname.startsWith("/api/admin/")) {
|
||||||
|
const isAuthEndpoint =
|
||||||
|
pathname.endsWith("/auth/login") || pathname.endsWith("/auth/logout");
|
||||||
|
|
||||||
|
if (request.method !== "GET" && !isAuthEndpoint) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ success: false, message: "Method not allowed" }),
|
||||||
|
{ status: 405, headers: { "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthEndpoint && !(await isApiAuthorized(request))) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ success: false, message: "Unauthorized" }),
|
||||||
|
{ status: 401, headers: { "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = pathname.split("/").filter(Boolean);
|
||||||
|
if (segments.length >= 2 && segments[1] === "admin") {
|
||||||
|
const locale = LOCALES.has(segments[0]) ? segments[0] : "es";
|
||||||
|
const isLogin = segments[2] === "login";
|
||||||
|
const session = await getSession(request);
|
||||||
|
|
||||||
|
if (!isLogin && !session) {
|
||||||
|
return redirect(`/${locale}/admin/login`, 302);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLogin && session) {
|
||||||
|
return redirect(`/${locale}/admin`, 302);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
@ -11,6 +11,7 @@ const tl = createTranslator(Astro.currentLocale);
|
||||||
|
|
||||||
const { locale, section } = Astro.params;
|
const { locale, section } = Astro.params;
|
||||||
const routeKey = getRouteKeyFromSlug(section);
|
const routeKey = getRouteKeyFromSlug(section);
|
||||||
|
const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY;
|
||||||
|
|
||||||
if (routeKey === "formulario") {
|
if (routeKey === "formulario") {
|
||||||
if (!["es", "en", "pt"].includes(Astro.currentLocale)) {
|
if (!["es", "en", "pt"].includes(Astro.currentLocale)) {
|
||||||
|
|
@ -62,7 +63,7 @@ if (routeKey === "formulario") {
|
||||||
<h1 class="text-3xl lg:text-4xl font-bold font-secondary text-[#EBE6D2] text-center mb-4 md:mb-9">
|
<h1 class="text-3xl lg:text-4xl font-bold font-secondary text-[#EBE6D2] text-center mb-4 md:mb-9">
|
||||||
{tl("form.page_title")}
|
{tl("form.page_title")}
|
||||||
</h1>
|
</h1>
|
||||||
<DynamicForm client:load locale={Astro.currentLocale} turnstileSiteKey={import.meta.env.TURNSTILE_SITE_KEY} />
|
<DynamicForm client:load locale={Astro.currentLocale} turnstileSiteKey={turnstileSiteKey} />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
---
|
||||||
|
import MainLayout from "@/layouts/MainLayout.astro";
|
||||||
|
import Header from "@/components/Header.astro";
|
||||||
|
import AdminDashboard from "@/components/admin/AdminDashboard.vue";
|
||||||
|
|
||||||
|
const { locale } = Astro.params;
|
||||||
|
---
|
||||||
|
|
||||||
|
<MainLayout theme="cdrpj" title="Dashboard de Voluntarios" noindex>
|
||||||
|
<div class="top-16 relative mb container mx-auto">
|
||||||
|
<Header />
|
||||||
|
</div>
|
||||||
|
<main class="container mx-auto px-4 py-10 mt-8">
|
||||||
|
<div class="flex flex-col lg:w-1/2 items-center mx-auto mb-6">
|
||||||
|
<h1 class="text-white text-2xl uppercase font-bold text-center mb-3 font-primary">
|
||||||
|
Admin
|
||||||
|
</h1>
|
||||||
|
<h2 class="text-colorPrimary text-3xl lg:text-5xl font-bold text-center font-secondary">
|
||||||
|
Dashboard de Voluntarios
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<nav class="flex justify-center gap-2 mb-10">
|
||||||
|
<a href={`/${locale}/admin/dashboard`} class="btn btn-primary btn-sm">Dashboard</a>
|
||||||
|
<a href={`/${locale}/admin`} class="btn btn-ghost btn-sm text-colorPrimary">Voluntarios</a>
|
||||||
|
</nav>
|
||||||
|
<div class="max-w-5xl mx-auto">
|
||||||
|
<AdminDashboard client:load />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</MainLayout>
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
---
|
||||||
|
import MainLayout from "@/layouts/MainLayout.astro";
|
||||||
|
import Header from "@/components/Header.astro";
|
||||||
|
import VoluntariosTable from "../../../components/admin/VoluntariosTable.vue";
|
||||||
|
|
||||||
|
const { locale } = Astro.params;
|
||||||
|
---
|
||||||
|
|
||||||
|
<MainLayout theme="cdrpj" title="Dashboard de Voluntarios" noindex>
|
||||||
|
<div class="top-16 relative mb container mx-auto">
|
||||||
|
<Header />
|
||||||
|
</div>
|
||||||
|
<main class="container mx-auto px-4 py-10 mt-8">
|
||||||
|
<div class="flex flex-col lg:w-1/2 items-center mx-auto mb-6">
|
||||||
|
<h1 class="text-white text-2xl uppercase font-bold text-center mb-3 font-primary">
|
||||||
|
Admin
|
||||||
|
</h1>
|
||||||
|
<h2 class="text-colorPrimary text-3xl lg:text-5xl font-bold text-center font-secondary">
|
||||||
|
Dashboard de Voluntarios
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<nav class="flex justify-center gap-2 mb-10">
|
||||||
|
<a href={`/${locale}/admin/dashboard`} class="btn btn-ghost btn-sm text-colorPrimary">Dashboard</a>
|
||||||
|
<a href={`/${locale}/admin`} class="btn btn-primary btn-sm">Voluntarios</a>
|
||||||
|
</nav>
|
||||||
|
<VoluntariosTable client:load />
|
||||||
|
</main>
|
||||||
|
</MainLayout>
|
||||||
|
|
@ -0,0 +1,156 @@
|
||||||
|
---
|
||||||
|
import "../../../styles/global.css";
|
||||||
|
import "@fontsource/poppins/400.css";
|
||||||
|
import "@fontsource/poppins/500.css";
|
||||||
|
import "@fontsource/poppins/700.css";
|
||||||
|
import "@fontsource-variable/kameron";
|
||||||
|
import { Icon } from "astro-icon/components";
|
||||||
|
|
||||||
|
const { locale } = Astro.params;
|
||||||
|
const redirectTo = `/${locale}/admin`;
|
||||||
|
---
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html lang={locale} data-theme="cdrpj">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
<title>Acceso administrativo</title>
|
||||||
|
</head>
|
||||||
|
<body class="font-primary">
|
||||||
|
<div class="min-h-screen flex">
|
||||||
|
<div
|
||||||
|
class="hidden lg:flex lg:w-1/2 relative flex-col justify-center items-center p-12 bg-gradient-to-br from-[#003421] to-[#22523F] text-colorPrimary overflow-hidden"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/img/opacity-logo.png"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="absolute inset-0 h-full w-full object-cover opacity-15 mix-blend-luminosity pointer-events-none"
|
||||||
|
/>
|
||||||
|
<div class="relative z-10 flex flex-col items-center">
|
||||||
|
<div class="flex justify-center items-center gap-2 w-110 mb-10">
|
||||||
|
<img src=/img/logo-metalico.webp alt="Logo" class="w-32" />
|
||||||
|
<h1 class="font-secondary text-3xl xl:text-4xl font-bold text-colorSecondary mb-4 text-center">
|
||||||
|
Centro del Reino de Paz y Justicia
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p class="text-lg text-colorPrimary/80 max-w-md text-center">
|
||||||
|
Panel de administración — acceso restringido
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative overflow-hidden flex-1 flex flex-col justify-center items-center p-8 bg-colorPrimary bg-gradient-to-br from-[#003421] to-[#22523F] lg:bg-none">
|
||||||
|
<img
|
||||||
|
src="/img/opacity-logo.png"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="absolute inset-0 h-full w-full object-cover opacity-15 mix-blend-luminosity pointer-events-none lg:hidden"
|
||||||
|
/>
|
||||||
|
<div class="relative z-10 w-full max-w-sm">
|
||||||
|
<div class="lg:hidden flex flex-col items-center mb-8">
|
||||||
|
<img src=/img/logo-metalico.webp alt="Logo" class="w-40 mb-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="font-secondary text-3xl font-bold text-colorPrimary lg:text-tertiary mb-1">
|
||||||
|
Iniciar sesión
|
||||||
|
</h2>
|
||||||
|
<p class="text-colorPrimary/80 lg:text-tertiary/70 mb-8">
|
||||||
|
Ingresa tus credenciales de administrador
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form id="login-form" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="username" class="block text-sm font-medium text-colorPrimary lg:text-tertiary mb-1">
|
||||||
|
Usuario o correo
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
autocomplete="username"
|
||||||
|
class="input w-full bg-white border border-tertiary/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium text-colorPrimary lg:text-tertiary mb-1">
|
||||||
|
Contraseña
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autocomplete="current-password"
|
||||||
|
class="input w-full bg-white border border-tertiary/20 pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="toggle-password"
|
||||||
|
aria-label="Mostrar u ocultar contraseña"
|
||||||
|
class="absolute right-2 top-1/2 -translate-y-1/2 text-tertiary hover:text-colorSecondary"
|
||||||
|
>
|
||||||
|
<Icon id="eye-open" name="ph:eye" class="w-5 h-5" />
|
||||||
|
<Icon id="eye-closed" name="ph:eye-slash" class="w-5 h-5 hidden" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="error" class="text-red-300 lg:text-error text-sm hidden"></p>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary w-full">Entrar</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script define:vars={{ redirectTo }}>
|
||||||
|
const form = document.getElementById("login-form");
|
||||||
|
const errorEl = document.getElementById("error");
|
||||||
|
|
||||||
|
const togglePassword = document.getElementById("toggle-password");
|
||||||
|
const passwordInput = document.getElementById("password");
|
||||||
|
const eyeOpen = document.getElementById("eye-open");
|
||||||
|
const eyeClosed = document.getElementById("eye-closed");
|
||||||
|
|
||||||
|
togglePassword.addEventListener("click", () => {
|
||||||
|
const show = passwordInput.type === "password";
|
||||||
|
passwordInput.type = show ? "text" : "password";
|
||||||
|
eyeOpen.classList.toggle("hidden", show);
|
||||||
|
eyeClosed.classList.toggle("hidden", !show);
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
errorEl.classList.add("hidden");
|
||||||
|
|
||||||
|
const username = document.getElementById("username").value;
|
||||||
|
const password = document.getElementById("password").value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
window.location.href = redirectTo;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
errorEl.textContent = data.message || "Credenciales inválidas";
|
||||||
|
errorEl.classList.remove("hidden");
|
||||||
|
} catch {
|
||||||
|
errorEl.textContent = "Error de conexión";
|
||||||
|
errorEl.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
import MainLayout from "@/layouts/MainLayout.astro";
|
||||||
|
import Header from "@/components/Header.astro";
|
||||||
|
import VoluntarioDetail from "@/components/admin/VoluntarioDetail.vue";
|
||||||
|
|
||||||
|
const { numero } = Astro.params;
|
||||||
|
---
|
||||||
|
|
||||||
|
<MainLayout theme="cdrpj" title="Detalle de Voluntario" noindex>
|
||||||
|
<div class="top-16 relative mb container mx-auto">
|
||||||
|
<Header />
|
||||||
|
</div>
|
||||||
|
<main class="container mx-auto px-4 py-10 mt-8">
|
||||||
|
<div class="flex flex-col lg:w-1/2 items-center mx-auto mb-10">
|
||||||
|
<h1 class="text-white text-2xl uppercase font-bold text-center mb-3 font-primary">
|
||||||
|
Admin
|
||||||
|
</h1>
|
||||||
|
<h2 class="text-colorPrimary text-3xl lg:text-5xl font-bold text-center font-secondary">
|
||||||
|
Detalle de Voluntario
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
<VoluntarioDetail numero={numero} client:load />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</MainLayout>
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { prisma } from "../../lib/prisma";
|
||||||
|
import {
|
||||||
|
createSession,
|
||||||
|
isSameOrigin,
|
||||||
|
sessionCookieFor,
|
||||||
|
verifyPassword,
|
||||||
|
} from "../../lib/auth";
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
|
try {
|
||||||
|
if (!isSameOrigin(request)) {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, message: "Origen no válido" },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const identifier = String(body?.username || body?.email || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const password = String(body?.password || "");
|
||||||
|
|
||||||
|
if (!identifier || !password) {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, message: "Credenciales requeridas" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const admin = await prisma.admin.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ username: { equals: identifier, mode: "insensitive" } },
|
||||||
|
{ email: { equals: identifier, mode: "insensitive" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
!admin ||
|
||||||
|
!admin.activo ||
|
||||||
|
!admin.password_hash ||
|
||||||
|
!verifyPassword(password, admin.password_hash)
|
||||||
|
) {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, message: "Credenciales inválidas" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.admin.update({
|
||||||
|
where: { id: admin.id },
|
||||||
|
data: { ultimo_acceso: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = await createSession(admin.id);
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ success: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Set-Cookie": sessionCookieFor(token),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error en /api/admin/auth/login:", error);
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, message: "Error en el login" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { clearSessionCookie, isSameOrigin, revokeSession } from "../../lib/auth";
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
|
if (!isSameOrigin(request)) {
|
||||||
|
return new Response(null, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await revokeSession(request);
|
||||||
|
|
||||||
|
return new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: {
|
||||||
|
Location: "/",
|
||||||
|
"Set-Cookie": clearSessionCookie(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,250 @@
|
||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { prisma } from "../lib/prisma";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
const SORT_FIELDS = new Set([
|
||||||
|
"numero_voluntario",
|
||||||
|
"submitted_at",
|
||||||
|
"nombre",
|
||||||
|
"apellido",
|
||||||
|
"correo",
|
||||||
|
"status",
|
||||||
|
"pais",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const SEARCHABLE_FIELDS = [
|
||||||
|
"nombre",
|
||||||
|
"segundo_nombre",
|
||||||
|
"apellido",
|
||||||
|
"correo",
|
||||||
|
"telefono",
|
||||||
|
"whatsapp",
|
||||||
|
"documento_nro",
|
||||||
|
"ciudad",
|
||||||
|
"profesion",
|
||||||
|
"nivel_academico",
|
||||||
|
"nacionalidad",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const CORE_TEXT_FILTERS = ["documento_nro"] as const;
|
||||||
|
|
||||||
|
const FUZZY_TEXT_FILTERS = [
|
||||||
|
"ciudad",
|
||||||
|
"estado",
|
||||||
|
"profesion",
|
||||||
|
"nivel_academico",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const FUZZY_THRESHOLD = 0.35;
|
||||||
|
|
||||||
|
const CORE_EXACT_FILTERS = [
|
||||||
|
"sexo",
|
||||||
|
"nacionalidad",
|
||||||
|
"form_version",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const RESPONSE_FILTERS = [
|
||||||
|
"idioma",
|
||||||
|
"areas_colaborar",
|
||||||
|
"dias_disponibles",
|
||||||
|
"horario_preferido",
|
||||||
|
"fuera_ciudad",
|
||||||
|
"misiones_internacionales",
|
||||||
|
"condicion_medica",
|
||||||
|
"voluntariado_anterior",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function splitList(v: string): string[] {
|
||||||
|
return v
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenToOr(token: string): Prisma.FormularioWhereInput {
|
||||||
|
const or: Prisma.FormularioWhereInput[] = SEARCHABLE_FIELDS.map((field) => ({
|
||||||
|
[field]: { contains: token, mode: "insensitive" },
|
||||||
|
}));
|
||||||
|
or.push({
|
||||||
|
respuestas: {
|
||||||
|
some: { value: { contains: token, mode: "insensitive" } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (/^\d+$/.test(token)) {
|
||||||
|
or.push({ numero_voluntario: { equals: Number(token) } });
|
||||||
|
}
|
||||||
|
return { OR: or };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSearchWhere(q: string): Prisma.FormularioWhereInput {
|
||||||
|
const tokens = q.split(/\s+/).filter(Boolean);
|
||||||
|
if (tokens.length === 1) return tokenToOr(tokens[0]);
|
||||||
|
return { AND: tokens.map(tokenToOr) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDateRange(desde: string, hasta: string): Prisma.FormularioWhereInput | null {
|
||||||
|
const filter: Prisma.DateTimeFilter = {};
|
||||||
|
if (desde) filter.gte = new Date(desde);
|
||||||
|
if (hasta) {
|
||||||
|
const h = new Date(hasta);
|
||||||
|
h.setHours(23, 59, 59, 999);
|
||||||
|
filter.lte = h;
|
||||||
|
}
|
||||||
|
if (Object.keys(filter).length === 0) return null;
|
||||||
|
return { submitted_at: filter };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAgeRange(min: string, max: string): Prisma.FormularioWhereInput | null {
|
||||||
|
const filter: Prisma.DateTimeFilter = {};
|
||||||
|
const now = new Date();
|
||||||
|
if (min) {
|
||||||
|
const maxBirth = new Date(now);
|
||||||
|
maxBirth.setFullYear(now.getFullYear() - Number(min));
|
||||||
|
filter.lte = maxBirth;
|
||||||
|
}
|
||||||
|
if (max) {
|
||||||
|
const minBirth = new Date(now);
|
||||||
|
minBirth.setFullYear(now.getFullYear() - Number(max) - 1);
|
||||||
|
minBirth.setDate(minBirth.getDate() + 1);
|
||||||
|
filter.gte = minBirth;
|
||||||
|
}
|
||||||
|
if (Object.keys(filter).length === 0) return null;
|
||||||
|
return { fecha_nacimiento: filter };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResponseFilter(key: string, values: string[]): Prisma.FormularioWhereInput {
|
||||||
|
const or: Prisma.FormularioWhereInput[] = values.map((v) => ({
|
||||||
|
respuestas: {
|
||||||
|
some: { key, value: { contains: v, mode: "insensitive" } },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return or.length === 1 ? or[0] : { OR: or };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fuzzyIds(field: string, value: string): Promise<string[]> {
|
||||||
|
const col = Prisma.raw(field);
|
||||||
|
const rows = await prisma.$queryRaw<{ id: string }[]>`
|
||||||
|
SELECT id::text AS id
|
||||||
|
FROM formularios
|
||||||
|
WHERE ${col} IS NOT NULL
|
||||||
|
AND ${col} <> ''
|
||||||
|
AND length(${col}) >= 2
|
||||||
|
AND (
|
||||||
|
similarity(lower(${col}), lower(${value})) > ${FUZZY_THRESHOLD}
|
||||||
|
OR ${col} ILIKE ${`%${value}%`}
|
||||||
|
)
|
||||||
|
LIMIT 5000
|
||||||
|
`;
|
||||||
|
return rows.map((r) => r.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ url }) => {
|
||||||
|
try {
|
||||||
|
const sp = url.searchParams;
|
||||||
|
const page = Math.max(1, parseInt(sp.get("page") || "1", 10));
|
||||||
|
const limit = Math.min(100, Math.max(1, parseInt(sp.get("limit") || "25", 10)));
|
||||||
|
const q = (sp.get("q") || "").trim();
|
||||||
|
const status = (sp.get("status") || "").trim();
|
||||||
|
const pais = (sp.get("pais") || "").trim();
|
||||||
|
const sort = SORT_FIELDS.has(sp.get("sort") || "") ? sp.get("sort")! : "submitted_at";
|
||||||
|
const order = sp.get("order") === "asc" ? "asc" : "desc";
|
||||||
|
|
||||||
|
const conditions: Prisma.FormularioWhereInput[] = [];
|
||||||
|
|
||||||
|
if (q) conditions.push(buildSearchWhere(q));
|
||||||
|
|
||||||
|
const dateRange = buildDateRange((sp.get("desde") || "").trim(), (sp.get("hasta") || "").trim());
|
||||||
|
if (dateRange) conditions.push(dateRange);
|
||||||
|
|
||||||
|
const ageRange = buildAgeRange((sp.get("edad_min") || "").trim(), (sp.get("edad_max") || "").trim());
|
||||||
|
if (ageRange) conditions.push(ageRange);
|
||||||
|
|
||||||
|
if (status) conditions.push({ status: { in: splitList(status) } });
|
||||||
|
if (pais) conditions.push({ pais: { in: splitList(pais) } });
|
||||||
|
|
||||||
|
for (const field of CORE_TEXT_FILTERS) {
|
||||||
|
const v = (sp.get(field) || "").trim();
|
||||||
|
if (v) conditions.push({ [field]: { contains: v, mode: "insensitive" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of FUZZY_TEXT_FILTERS) {
|
||||||
|
const v = (sp.get(field) || "").trim();
|
||||||
|
if (v) {
|
||||||
|
const ids = await fuzzyIds(field, v);
|
||||||
|
conditions.push(ids.length ? { id: { in: ids } } : { id: { in: [] } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of CORE_EXACT_FILTERS) {
|
||||||
|
const v = (sp.get(field) || "").trim();
|
||||||
|
if (v) conditions.push({ [field]: { in: splitList(v) } });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of RESPONSE_FILTERS) {
|
||||||
|
const v = (sp.get(key) || "").trim();
|
||||||
|
if (v) conditions.push(buildResponseFilter(key, splitList(v)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const where: Prisma.FormularioWhereInput = conditions.length ? { AND: conditions } : {};
|
||||||
|
|
||||||
|
const [total, rows] = await Promise.all([
|
||||||
|
prisma.formulario.count({ where }),
|
||||||
|
prisma.formulario.findMany({
|
||||||
|
where,
|
||||||
|
include: { respuestas: { select: { key: true, value: true } } },
|
||||||
|
orderBy: { [sort]: order },
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const data = rows.map((f) => {
|
||||||
|
const respuestas: Record<string, string> = {};
|
||||||
|
for (const r of f.respuestas) {
|
||||||
|
respuestas[r.key] = respuestas[r.key]
|
||||||
|
? `${respuestas[r.key]}, ${r.value}`
|
||||||
|
: r.value;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: f.id,
|
||||||
|
numero_voluntario: f.numero_voluntario,
|
||||||
|
nombre_completo: [f.nombre, f.segundo_nombre, f.apellido]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" "),
|
||||||
|
nombre: f.nombre,
|
||||||
|
segundo_nombre: f.segundo_nombre,
|
||||||
|
apellido: f.apellido,
|
||||||
|
correo: f.correo,
|
||||||
|
telefono: f.telefono,
|
||||||
|
whatsapp: f.whatsapp,
|
||||||
|
pais: f.pais,
|
||||||
|
ciudad: f.ciudad,
|
||||||
|
estado: f.estado,
|
||||||
|
profesion: f.profesion,
|
||||||
|
nivel_academico: f.nivel_academico,
|
||||||
|
status: f.status,
|
||||||
|
submitted_at: f.submitted_at,
|
||||||
|
...respuestas,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
}, {
|
||||||
|
headers: { "Cache-Control": "private, max-age=30, stale-while-revalidate=120" },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error en /api/admin/voluntarios:", error);
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, message: "Error al obtener voluntarios" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { prisma } from "../../lib/prisma";
|
||||||
|
import { t, type I18nKey } from "../../../../i18n";
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ params }) => {
|
||||||
|
try {
|
||||||
|
const numero = Number(params.numero);
|
||||||
|
if (!Number.isInteger(numero) || numero <= 0) {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, message: "Número de voluntario inválido" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [voluntario, secciones] = await Promise.all([
|
||||||
|
prisma.formulario.findUnique({
|
||||||
|
where: { numero_voluntario: numero },
|
||||||
|
include: { respuestas: { orderBy: { created_at: "asc" } } },
|
||||||
|
}),
|
||||||
|
prisma.seccion.findMany({
|
||||||
|
orderBy: { orden: "asc" },
|
||||||
|
include: { campos: { orderBy: { orden: "asc" } } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!voluntario) {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, message: "Voluntario no encontrado" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections = secciones.map((s) => ({
|
||||||
|
key: s.title_key.replace(/^form\./, ""),
|
||||||
|
title: t("es", s.title_key as I18nKey),
|
||||||
|
fields: s.campos.map((c) => ({
|
||||||
|
key: c.key,
|
||||||
|
label: t("es", c.label_key as I18nKey),
|
||||||
|
tipo: c.tipo,
|
||||||
|
options: Array.isArray(c.options)
|
||||||
|
? (c.options as { value?: string; label?: string }[])
|
||||||
|
.filter((o) => o && typeof o === "object")
|
||||||
|
.map((o) => ({
|
||||||
|
value: o.value ?? "",
|
||||||
|
label: o.label ? t("es", o.label as I18nKey) : o.value ?? "",
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id: voluntario.id,
|
||||||
|
numero_voluntario: voluntario.numero_voluntario,
|
||||||
|
nombre: voluntario.nombre,
|
||||||
|
segundo_nombre: voluntario.segundo_nombre,
|
||||||
|
apellido: voluntario.apellido,
|
||||||
|
documento_nro: voluntario.documento_nro,
|
||||||
|
documento_tipo: voluntario.documento_tipo,
|
||||||
|
correo: voluntario.correo,
|
||||||
|
fecha_nacimiento: voluntario.fecha_nacimiento,
|
||||||
|
nacionalidad: voluntario.nacionalidad,
|
||||||
|
sexo: voluntario.sexo,
|
||||||
|
direccion: voluntario.direccion,
|
||||||
|
ciudad: voluntario.ciudad,
|
||||||
|
estado: voluntario.estado,
|
||||||
|
pais: voluntario.pais,
|
||||||
|
codigo_postal: voluntario.codigo_postal,
|
||||||
|
telefono: voluntario.telefono,
|
||||||
|
whatsapp: voluntario.whatsapp,
|
||||||
|
profesion: voluntario.profesion,
|
||||||
|
lugar_trabajo: voluntario.lugar_trabajo,
|
||||||
|
nivel_academico: voluntario.nivel_academico,
|
||||||
|
status: voluntario.status,
|
||||||
|
form_version: voluntario.form_version,
|
||||||
|
email_sent_at: voluntario.email_sent_at,
|
||||||
|
submitted_at: voluntario.submitted_at,
|
||||||
|
updated_at: voluntario.updated_at,
|
||||||
|
respuestas: voluntario.respuestas.map((r) => ({
|
||||||
|
seccion: r.seccion,
|
||||||
|
key: r.key,
|
||||||
|
value: r.value,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
form: { sections },
|
||||||
|
}, {
|
||||||
|
headers: { "Cache-Control": "private, max-age=60, stale-while-revalidate=120" },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error en /api/admin/voluntarios/[numero]:", error);
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, message: "Error al obtener el voluntario" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { prisma } from "../../lib/prisma";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
const DISTINCT_FIELDS = new Set([
|
||||||
|
"ciudad",
|
||||||
|
"estado",
|
||||||
|
"profesion",
|
||||||
|
"nivel_academico",
|
||||||
|
"nacionalidad",
|
||||||
|
"pais",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const SIMILARITY_THRESHOLD = 0.35;
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ url }) => {
|
||||||
|
try {
|
||||||
|
const sp = url.searchParams;
|
||||||
|
const field = (sp.get("field") || "").trim();
|
||||||
|
const q = (sp.get("q") || "").trim();
|
||||||
|
const limit = Math.min(100, Math.max(1, parseInt(sp.get("limit") || "25", 10)));
|
||||||
|
|
||||||
|
if (!DISTINCT_FIELDS.has(field)) {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, message: "Campo no permitido" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const col = Prisma.raw(field);
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRaw<{ value: string; count: number }[]>`
|
||||||
|
SELECT ${col} AS value, COUNT(*)::int AS count
|
||||||
|
FROM formularios
|
||||||
|
WHERE ${col} IS NOT NULL AND ${col} <> ''
|
||||||
|
${q.length >= 2 ? Prisma.sql`AND (similarity(lower(${col}), lower(${q})) > ${SIMILARITY_THRESHOLD} OR ${col} ILIKE ${`%${q}%`})` : Prisma.empty}
|
||||||
|
GROUP BY ${col}
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
field,
|
||||||
|
data: rows.map((r) => ({ value: r.value, count: r.count })),
|
||||||
|
}, {
|
||||||
|
headers: { "Cache-Control": "private, max-age=300, stale-while-revalidate=600" },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error en /api/admin/voluntarios/distinct:", error);
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, message: "Error al obtener valores" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { prisma } from "../../lib/prisma";
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
export const GET: APIRoute = async () => {
|
||||||
|
try {
|
||||||
|
const [paises, statuses, nacionalidades, form_versions] = await Promise.all([
|
||||||
|
prisma.formulario.findMany({
|
||||||
|
distinct: ["pais"],
|
||||||
|
select: { pais: true },
|
||||||
|
where: { pais: { not: null } },
|
||||||
|
}),
|
||||||
|
prisma.formulario.findMany({
|
||||||
|
distinct: ["status"],
|
||||||
|
select: { status: true },
|
||||||
|
}),
|
||||||
|
prisma.formulario.findMany({
|
||||||
|
distinct: ["nacionalidad"],
|
||||||
|
select: { nacionalidad: true },
|
||||||
|
where: { nacionalidad: { not: null } },
|
||||||
|
}),
|
||||||
|
prisma.formulario.findMany({
|
||||||
|
distinct: ["form_version"],
|
||||||
|
select: { form_version: true },
|
||||||
|
where: { form_version: { not: null } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
paises: paises
|
||||||
|
.map((p) => p.pais)
|
||||||
|
.filter((p): p is string => Boolean(p))
|
||||||
|
.sort((a, b) => a.localeCompare(b, "es")),
|
||||||
|
statuses: statuses
|
||||||
|
.map((s) => s.status)
|
||||||
|
.filter((s): s is string => Boolean(s))
|
||||||
|
.sort(),
|
||||||
|
nacionalidades: nacionalidades
|
||||||
|
.map((n) => n.nacionalidad)
|
||||||
|
.filter((n): n is string => Boolean(n))
|
||||||
|
.sort((a, b) => a.localeCompare(b, "es")),
|
||||||
|
form_versions: form_versions
|
||||||
|
.map((f) => f.form_version)
|
||||||
|
.filter((f): f is string => Boolean(f))
|
||||||
|
.sort(),
|
||||||
|
}, {
|
||||||
|
headers: { "Cache-Control": "private, max-age=300, stale-while-revalidate=600" },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error en /api/admin/voluntarios/options:", error);
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, message: "Error al obtener opciones" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { prisma } from "../../lib/prisma";
|
||||||
|
import { t, type I18nKey } from "../../../../i18n";
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
const OPTION_FIELDS = [
|
||||||
|
"idioma",
|
||||||
|
"areas_colaborar",
|
||||||
|
"dias_disponibles",
|
||||||
|
"horario_preferido",
|
||||||
|
"sexo",
|
||||||
|
"fuera_ciudad",
|
||||||
|
"misiones_internacionales",
|
||||||
|
"condicion_medica",
|
||||||
|
"voluntariado_anterior",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
async function multiValueDist(key: string): Promise<{ value: string; count: number }[]> {
|
||||||
|
const rows = await prisma.$queryRaw<{ v: string; cnt: number }[]>`
|
||||||
|
SELECT trim(unnest(string_to_array(value, ','))) AS v, count(DISTINCT formulario_id)::int AS cnt
|
||||||
|
FROM respuestas
|
||||||
|
WHERE key = ${key} AND btrim(value) <> ''
|
||||||
|
GROUP BY v
|
||||||
|
ORDER BY cnt DESC
|
||||||
|
`;
|
||||||
|
return rows.map((r) => ({ value: r.v, count: r.cnt }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function valueDist(key: string): Promise<{ value: string; count: number }[]> {
|
||||||
|
const rows = await prisma.respuesta.groupBy({
|
||||||
|
by: ["value"],
|
||||||
|
where: { key, value: { not: "" } },
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
return rows
|
||||||
|
.filter((r) => r.value !== null)
|
||||||
|
.map((r) => ({ value: r.value!, count: r._count._all }))
|
||||||
|
.sort((a, b) => b.count - a.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET: APIRoute = async () => {
|
||||||
|
try {
|
||||||
|
const campos = await prisma.campo.findMany({
|
||||||
|
where: { key: { in: OPTION_FIELDS as unknown as string[] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const optionMaps: Record<string, Record<string, string>> = {};
|
||||||
|
for (const c of campos) {
|
||||||
|
optionMaps[c.key] = {};
|
||||||
|
if (Array.isArray(c.options)) {
|
||||||
|
for (const o of c.options as { value?: string; label?: string }[]) {
|
||||||
|
if (o && o.value) {
|
||||||
|
optionMaps[c.key][o.value] = o.label ? t("es", o.label as I18nKey) : o.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const withLabels = (
|
||||||
|
dist: { value: string; count: number }[],
|
||||||
|
key: string
|
||||||
|
): { value: string; label: string; count: number }[] =>
|
||||||
|
dist.map((d) => ({
|
||||||
|
value: d.value,
|
||||||
|
label: optionMaps[key]?.[d.value] || d.value,
|
||||||
|
count: d.count,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const [total, statuses, sexo, paises, [idiomas, areas, dias, horarios], condiciones, edad] =
|
||||||
|
await Promise.all([
|
||||||
|
prisma.formulario.count(),
|
||||||
|
prisma.formulario.groupBy({ by: ["status"], _count: { _all: true } }),
|
||||||
|
prisma.formulario.groupBy({ by: ["sexo"], _count: { _all: true } }),
|
||||||
|
prisma.formulario.groupBy({
|
||||||
|
by: ["pais"],
|
||||||
|
_count: { _all: true },
|
||||||
|
where: { pais: { not: null } },
|
||||||
|
orderBy: { _count: { pais: "desc" } },
|
||||||
|
take: 12,
|
||||||
|
}),
|
||||||
|
Promise.all([
|
||||||
|
multiValueDist("idioma"),
|
||||||
|
multiValueDist("areas_colaborar"),
|
||||||
|
multiValueDist("dias_disponibles"),
|
||||||
|
multiValueDist("horario_preferido"),
|
||||||
|
]),
|
||||||
|
Promise.all(
|
||||||
|
["fuera_ciudad", "misiones_internacionales", "condicion_medica", "voluntariado_anterior"].map(
|
||||||
|
(k) => valueDist(k)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
prisma.$queryRaw<{ promedio: number | null }[]>`
|
||||||
|
SELECT round(avg(extract(epoch from (now() - fecha_nacimiento)) / 31557600), 1) AS promedio
|
||||||
|
FROM formularios
|
||||||
|
WHERE fecha_nacimiento IS NOT NULL
|
||||||
|
`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const condKeys = [
|
||||||
|
"fuera_ciudad",
|
||||||
|
"misiones_internacionales",
|
||||||
|
"condicion_medica",
|
||||||
|
"voluntariado_anterior",
|
||||||
|
] as const;
|
||||||
|
const condicionesObj: Record<string, Record<string, number>> = {};
|
||||||
|
condKeys.forEach((k, i) => {
|
||||||
|
condicionesObj[k] = Object.fromEntries(condiciones[i].map((c) => [c.value, c.count]));
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatPais = paises
|
||||||
|
.filter((p) => p.pais !== null)
|
||||||
|
.map((p) => ({ value: p.pais!, count: p._count._all }));
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
total,
|
||||||
|
edad: { promedio: edad[0]?.promedio ?? null },
|
||||||
|
idiomas: withLabels(idiomas, "idioma"),
|
||||||
|
areas: withLabels(areas, "areas_colaborar"),
|
||||||
|
dias: withLabels(dias, "dias_disponibles"),
|
||||||
|
horarios: withLabels(horarios, "horario_preferido"),
|
||||||
|
sexo: withLabels(
|
||||||
|
sexo.filter((s) => s.sexo !== null).map((s) => ({ value: s.sexo!, count: s._count._all })),
|
||||||
|
"sexo"
|
||||||
|
),
|
||||||
|
paises: formatPais,
|
||||||
|
statuses: statuses.map((s) => ({ value: s.status, count: s._count._all })),
|
||||||
|
condiciones: condicionesObj,
|
||||||
|
}, {
|
||||||
|
headers: { "Cache-Control": "private, max-age=60, stale-while-revalidate=300" },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error en /api/admin/voluntarios/stats:", error);
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, message: "Error al obtener estadísticas" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
import { appendToSheet, emailExists } from "../lib/googleSheets";
|
import { appendToSheet } from "../lib/googleSheets";
|
||||||
import { sendEmailCf } from "../lib/email";
|
import { sendEmailCf } from "../lib/email";
|
||||||
|
import { prisma } from "../lib/prisma";
|
||||||
|
import { emailExistsInDb } from "../lib/formularioDb";
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
|
|
@ -22,7 +24,7 @@ export const POST: APIRoute = async ({ request }) => {
|
||||||
return Response.redirect("/?error=datos", 303);
|
return Response.redirect("/?error=datos", 303);
|
||||||
}
|
}
|
||||||
|
|
||||||
const exists = await emailExists(email);
|
const exists = await emailExistsInDb(email);
|
||||||
if (exists) {
|
if (exists) {
|
||||||
return Response.json({
|
return Response.json({
|
||||||
success: false,
|
success: false,
|
||||||
|
|
@ -41,10 +43,22 @@ export const POST: APIRoute = async ({ request }) => {
|
||||||
new Date().toLocaleString()
|
new Date().toLocaleString()
|
||||||
];
|
];
|
||||||
|
|
||||||
// 🔹 Enviar correo (Cloudflare) y guardar en Google Sheets
|
// 🔹 Enviar correo (Cloudflare), guardar en Google Sheets y en PostgreSQL
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
sendEmailCf({ nombres, apellidos }, email, "CENTRO_DEL_REINO_PAZ_Y_JUSTICIA_POSTULACION"),
|
sendEmailCf({ nombres, apellidos }, email, "CENTRO_DEL_REINO_PAZ_Y_JUSTICIA_POSTULACION"),
|
||||||
appendToSheet(valuesForSheets)
|
appendToSheet(valuesForSheets),
|
||||||
|
prisma.formulario.create({
|
||||||
|
data: {
|
||||||
|
nombre: nombres!,
|
||||||
|
apellido: apellidos!,
|
||||||
|
pais: pais || null,
|
||||||
|
ciudad: lugar || null,
|
||||||
|
direccion: direccion || null,
|
||||||
|
telefono: telefono || null,
|
||||||
|
correo: email!,
|
||||||
|
status: "legacy",
|
||||||
|
},
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 🔹 Redirección elegante después de enviar
|
// 🔹 Redirección elegante después de enviar
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
import { appendRegistrationToSheet } from "../lib/googleSheets";
|
import { appendRegistrationToSheet } from "../lib/googleSheets";
|
||||||
import { sendEmailCf } from "../lib/email";
|
import { sendEmailCf } from "../lib/email";
|
||||||
import { emailExistsInRegistry, addEmailToRegistry } from "../lib/emailsRegistry";
|
import { saveFormularioToSupabase, emailExistsInDb } from "../lib/formularioDb";
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
const COLUMNS = [
|
const COLUMNS = [
|
||||||
|
|
@ -79,7 +79,7 @@ export const POST: APIRoute = async ({ request }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const turnstileToken = body.turnstileToken || "";
|
const turnstileToken = body.turnstileToken || "";
|
||||||
const turnstileSecret = import.meta.env.TURNSTILE_SECRET_KEY;
|
const turnstileSecret = process.env.TURNSTILE_SECRET_KEY;
|
||||||
if (turnstileSecret && turnstileToken) {
|
if (turnstileSecret && turnstileToken) {
|
||||||
const verify = await fetch(
|
const verify = await fetch(
|
||||||
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
||||||
|
|
@ -102,13 +102,19 @@ export const POST: APIRoute = async ({ request }) => {
|
||||||
timeZone: "America/Puerto_Rico",
|
timeZone: "America/Puerto_Rico",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (emailExistsInRegistry(formData.correo)) {
|
const exists = await emailExistsInDb(formData.correo);
|
||||||
|
if (exists) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ success: false, message: "Este correo ya está registrado. No se permiten 2 registros." },
|
{ success: false, message: "Este correo ya está registrado. No se permiten 2 registros." },
|
||||||
{ status: 409 }
|
{ status: 409 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const form = await saveFormularioToSupabase(
|
||||||
|
formData,
|
||||||
|
body.responses || []
|
||||||
|
);
|
||||||
|
|
||||||
const row = COLUMNS.map((key) => flattenValue(formData[key]));
|
const row = COLUMNS.map((key) => flattenValue(formData[key]));
|
||||||
row.push(timestamp);
|
row.push(timestamp);
|
||||||
|
|
||||||
|
|
@ -119,9 +125,10 @@ export const POST: APIRoute = async ({ request }) => {
|
||||||
apellidos: formData.apellido,
|
apellidos: formData.apellido,
|
||||||
}, formData.correo, "CENTRO_DEL_REINO_PAZ_Y_JUSTICIA_POSTULACION");
|
}, formData.correo, "CENTRO_DEL_REINO_PAZ_Y_JUSTICIA_POSTULACION");
|
||||||
|
|
||||||
addEmailToRegistry(formData.correo);
|
return Response.json({
|
||||||
|
...result,
|
||||||
return Response.json(result);
|
numero_voluntario: form.numero_voluntario,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error en /api/formulario/send:", error);
|
console.error("Error en /api/formulario/send:", error);
|
||||||
return Response.json(
|
return Response.json(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
import {
|
||||||
|
createHash,
|
||||||
|
randomBytes,
|
||||||
|
scryptSync,
|
||||||
|
timingSafeEqual,
|
||||||
|
} from "node:crypto";
|
||||||
|
import { prisma } from "./prisma";
|
||||||
|
|
||||||
|
const SESSION_COOKIE = "cdrdpyj_admin";
|
||||||
|
const SESSION_TTL_MS = 8 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export interface AuthSession {
|
||||||
|
adminId: string;
|
||||||
|
email: string;
|
||||||
|
nombre: string;
|
||||||
|
rol: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionCookieName(): string {
|
||||||
|
return SESSION_COOKIE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashPassword(password: string): string {
|
||||||
|
const salt = randomBytes(16).toString("hex");
|
||||||
|
const hash = scryptSync(password, salt, 64).toString("hex");
|
||||||
|
return `${salt}:${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyPassword(password: string, stored: string): boolean {
|
||||||
|
const [salt, hash] = stored.split(":");
|
||||||
|
if (!salt || !hash) return false;
|
||||||
|
const test = scryptSync(password, salt, 64);
|
||||||
|
const ref = Buffer.from(hash, "hex");
|
||||||
|
return test.length === ref.length && timingSafeEqual(test, ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(value: string): string {
|
||||||
|
return createHash("sha256").update(value).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function timingSafeEqualStr(a: string, b: string): boolean {
|
||||||
|
const ab = Buffer.from(a);
|
||||||
|
const bb = Buffer.from(b);
|
||||||
|
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionCookieFor(token: string): string {
|
||||||
|
const secure = process.env.NODE_ENV === "production" ? "; Secure" : "";
|
||||||
|
return `${SESSION_COOKIE}=${token}; HttpOnly; Path=/; SameSite=Lax; Max-Age=${Math.floor(
|
||||||
|
SESSION_TTL_MS / 1000
|
||||||
|
)}${secure}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSessionCookie(): string {
|
||||||
|
const secure = process.env.NODE_ENV === "production" ? "; Secure" : "";
|
||||||
|
return `${SESSION_COOKIE}=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0${secure}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSessionToken(request: Request): string | null {
|
||||||
|
const cookie = request.headers.get("cookie");
|
||||||
|
if (!cookie) return null;
|
||||||
|
const entry = cookie
|
||||||
|
.split(";")
|
||||||
|
.map((c) => c.trim())
|
||||||
|
.find((c) => c.startsWith(`${SESSION_COOKIE}=`));
|
||||||
|
if (!entry) return null;
|
||||||
|
return entry.slice(SESSION_COOKIE.length + 1) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSession(adminId: string): Promise<string> {
|
||||||
|
const token = randomBytes(32).toString("base64url");
|
||||||
|
await prisma.session.deleteMany({
|
||||||
|
where: { expires_at: { lte: new Date() } },
|
||||||
|
});
|
||||||
|
await prisma.session.create({
|
||||||
|
data: {
|
||||||
|
token_hash: sha256(token),
|
||||||
|
admin_id: adminId,
|
||||||
|
expires_at: new Date(Date.now() + SESSION_TTL_MS),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSession(request: Request): Promise<AuthSession | null> {
|
||||||
|
const token = readSessionToken(request);
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await prisma.session.findUnique({
|
||||||
|
where: { token_hash: sha256(token) },
|
||||||
|
include: { admin: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
!session ||
|
||||||
|
session.expires_at.getTime() <= Date.now() ||
|
||||||
|
!session.admin.activo
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
adminId: session.admin.id,
|
||||||
|
email: session.admin.email,
|
||||||
|
nombre: session.admin.nombre,
|
||||||
|
rol: session.admin.rol,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error al leer la sesión:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeSession(request: Request): Promise<void> {
|
||||||
|
const token = readSessionToken(request);
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
await prisma.session.delete({ where: { token_hash: sha256(token) } });
|
||||||
|
} catch {
|
||||||
|
// sesión inexistente, no hacer nada
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isApiAuthorized(request: Request): Promise<boolean> {
|
||||||
|
if (await getSession(request)) return true;
|
||||||
|
|
||||||
|
const apiKey = process.env.ADMIN_API_KEY;
|
||||||
|
if (!apiKey) return false;
|
||||||
|
|
||||||
|
const key =
|
||||||
|
request.headers.get("x-api-key") ??
|
||||||
|
request.headers.get("authorization")?.replace(/^Bearer\s+/i, "");
|
||||||
|
if (!key) return false;
|
||||||
|
|
||||||
|
return timingSafeEqualStr(key, apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSameOrigin(request: Request): boolean {
|
||||||
|
const origin = request.headers.get("origin");
|
||||||
|
const referer = request.headers.get("referer");
|
||||||
|
const host = request.headers.get("host");
|
||||||
|
if (!host) return true;
|
||||||
|
try {
|
||||||
|
if (origin) return new URL(origin).host === host;
|
||||||
|
if (referer) return new URL(referer).host === host;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@ import Cloudflare from "cloudflare";
|
||||||
type TypeEmailData = "CENTRO_DEL_REINO_PAZ_Y_JUSTICIA_INFO" | "CENTRO_DEL_REINO_PAZ_Y_JUSTICIA_POSTULACION";
|
type TypeEmailData = "CENTRO_DEL_REINO_PAZ_Y_JUSTICIA_INFO" | "CENTRO_DEL_REINO_PAZ_Y_JUSTICIA_POSTULACION";
|
||||||
|
|
||||||
export async function sendEmail(data: object, to: string, type: TypeEmailData) {
|
export async function sendEmail(data: object, to: string, type: TypeEmailData) {
|
||||||
const emailApiKey = import.meta.env.EMAIL_API_KEY;
|
const emailApiKey = process.env.EMAIL_API_KEY;
|
||||||
|
|
||||||
const dataFinal = {
|
const dataFinal = {
|
||||||
...data,
|
...data,
|
||||||
|
|
@ -77,8 +77,13 @@ function stripHtml(html: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendEmailCf(data: Record<string, string>, to: string, type: TypeEmailData) {
|
export async function sendEmailCf(data: Record<string, string>, to: string, type: TypeEmailData) {
|
||||||
const apiToken = import.meta.env.CLOUDFLARE_API_TOKEN;
|
const apiToken = process.env.CLOUDFLARE_API_TOKEN;
|
||||||
const accountId = import.meta.env.CLOUDFLARE_ACCOUNT_ID;
|
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
|
||||||
|
|
||||||
|
if (!apiToken || !accountId) {
|
||||||
|
await sendEmail(data, to, type);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const client = new Cloudflare({ apiToken });
|
const client = new Cloudflare({ apiToken });
|
||||||
|
|
@ -122,7 +127,7 @@ export async function sendEmailCf(data: Record<string, string>, to: string, type
|
||||||
// to: string,
|
// to: string,
|
||||||
// type: TypeEmailData
|
// type: TypeEmailData
|
||||||
// ) {
|
// ) {
|
||||||
// const apiKey = import.meta.env.BREVO_API_KEY;
|
// const apiKey = process.env.BREVO_API_KEY;
|
||||||
// const { subject } = TEMPLATE_URLS[type];
|
// const { subject } = TEMPLATE_URLS[type];
|
||||||
// const rawHtml = await fetchTemplate(type);
|
// const rawHtml = await fetchTemplate(type);
|
||||||
// const html = renderTemplate(rawHtml, data);
|
// const html = renderTemplate(rawHtml, data);
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
const REGISTRY_PATH = path.resolve(process.cwd(), "data", "emails.txt");
|
|
||||||
|
|
||||||
export function emailExistsInRegistry(email: string): boolean {
|
|
||||||
try {
|
|
||||||
if (!fs.existsSync(REGISTRY_PATH)) return false;
|
|
||||||
const content = fs.readFileSync(REGISTRY_PATH, "utf-8");
|
|
||||||
return content.split("\n").some((line) => line.trim().toLowerCase() === email.trim().toLowerCase());
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addEmailToRegistry(email: string): void {
|
|
||||||
const dir = path.dirname(REGISTRY_PATH);
|
|
||||||
if (!fs.existsSync(dir)) {
|
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
|
||||||
}
|
|
||||||
fs.appendFileSync(REGISTRY_PATH, email.trim().toLowerCase() + "\n");
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { prisma } from "./prisma";
|
||||||
|
|
||||||
|
type FormData = Record<string, unknown>;
|
||||||
|
type RespuestaPayload = {
|
||||||
|
key: string;
|
||||||
|
value: unknown;
|
||||||
|
section_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CORE_KEYS = new Set([
|
||||||
|
"nombre",
|
||||||
|
"segundo_nombre",
|
||||||
|
"apellido",
|
||||||
|
"documentos",
|
||||||
|
"correo",
|
||||||
|
"fecha_nacimiento",
|
||||||
|
"nacionalidad",
|
||||||
|
"sexo",
|
||||||
|
"direccion_completa",
|
||||||
|
"ciudad",
|
||||||
|
"estado",
|
||||||
|
"pais",
|
||||||
|
"postal",
|
||||||
|
"telefono",
|
||||||
|
"whatsapp",
|
||||||
|
"profesion",
|
||||||
|
"lugar_trabajo_actual",
|
||||||
|
"nivel_academico",
|
||||||
|
"nombre_completo",
|
||||||
|
"confirmar_nombre",
|
||||||
|
"confirmar_firma",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isBlank(v: unknown): boolean {
|
||||||
|
return v === undefined || v === null || v === "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function emailExistsInDb(email: string): Promise<boolean> {
|
||||||
|
const normalized = (email || "").trim().toLowerCase();
|
||||||
|
if (!normalized) return false;
|
||||||
|
const found = await prisma.formulario.findFirst({
|
||||||
|
where: { correo: { equals: normalized, mode: "insensitive" } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return Boolean(found);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveFormularioToSupabase(
|
||||||
|
formData: FormData,
|
||||||
|
responses: RespuestaPayload[]
|
||||||
|
) {
|
||||||
|
const core = {
|
||||||
|
nombre: (formData.nombre as string) ?? "",
|
||||||
|
segundo_nombre: (formData.segundo_nombre as string) || null,
|
||||||
|
apellido: (formData.apellido as string) ?? "",
|
||||||
|
documento_nro: (formData.documentos as string) || null,
|
||||||
|
correo: (formData.correo as string) ?? "",
|
||||||
|
fecha_nacimiento: formData.fecha_nacimiento
|
||||||
|
? new Date(formData.fecha_nacimiento as string)
|
||||||
|
: null,
|
||||||
|
nacionalidad: (formData.nacionalidad as string) || null,
|
||||||
|
sexo: (formData.sexo as string) || null,
|
||||||
|
direccion: (formData.direccion_completa as string) || null,
|
||||||
|
ciudad: (formData.ciudad as string) || null,
|
||||||
|
estado: (formData.estado as string) || null,
|
||||||
|
pais: (formData.pais as string) || null,
|
||||||
|
codigo_postal: (formData.postal as string) || null,
|
||||||
|
telefono: (formData.telefono as string) || null,
|
||||||
|
whatsapp: (formData.whatsapp as string) || null,
|
||||||
|
profesion: (formData.profesion as string) || null,
|
||||||
|
lugar_trabajo: (formData.lugar_trabajo_actual as string) || null,
|
||||||
|
nivel_academico: (formData.nivel_academico as string) || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const respRows: {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
seccion: string;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
for (const r of responses || []) {
|
||||||
|
if (CORE_KEYS.has(r.key)) continue;
|
||||||
|
if (isBlank(r.value)) continue;
|
||||||
|
|
||||||
|
if (Array.isArray(r.value)) {
|
||||||
|
for (const v of r.value) {
|
||||||
|
if (!isBlank(v)) {
|
||||||
|
respRows.push({
|
||||||
|
key: r.key,
|
||||||
|
value: String(v),
|
||||||
|
seccion: r.section_id ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
respRows.push({
|
||||||
|
key: r.key,
|
||||||
|
value: String(r.value),
|
||||||
|
seccion: r.section_id ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const form = await tx.formulario.create({ data: core });
|
||||||
|
if (respRows.length) {
|
||||||
|
await tx.respuesta.createMany({
|
||||||
|
data: respRows.map((r) => ({ ...r, formulario_id: form.id })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return form;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -2,14 +2,14 @@ import { google } from 'googleapis';
|
||||||
|
|
||||||
const auth = new google.auth.GoogleAuth({
|
const auth = new google.auth.GoogleAuth({
|
||||||
credentials: {
|
credentials: {
|
||||||
client_email: import.meta.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
|
client_email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
|
||||||
private_key: import.meta.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
|
private_key: process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
|
||||||
},
|
},
|
||||||
scopes: ['https://www.googleapis.com/auth/spreadsheets'],
|
scopes: ['https://www.googleapis.com/auth/spreadsheets'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const sheets = google.sheets({ version: 'v4', auth });
|
const sheets = google.sheets({ version: 'v4', auth });
|
||||||
const SPREADSHEET_ID = import.meta.env.GOOGLE_SHEET_ID;
|
const SPREADSHEET_ID = process.env.GOOGLE_SHEET_ID;
|
||||||
|
|
||||||
export async function emailExists(email: string): Promise<boolean> {
|
export async function emailExists(email: string): Promise<boolean> {
|
||||||
const formula = `=COUNTIF(G:G,"${email}")`;
|
const formula = `=COUNTIF(G:G,"${email}")`;
|
||||||
|
|
|
||||||
|
|
@ -7,4 +7,4 @@ const globalForPrisma = globalThis as unknown as {
|
||||||
export const prisma =
|
export const prisma =
|
||||||
globalForPrisma.prisma ?? new PrismaClient();
|
globalForPrisma.prisma ?? new PrismaClient();
|
||||||
|
|
||||||
if (import.meta.env.DEV) globalForPrisma.prisma = prisma;
|
if (import.meta.env?.DEV) globalForPrisma.prisma = prisma;
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
import { getVolunteerCount } from "../lib/googleSheets";
|
import { prisma } from "../lib/prisma";
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
export const GET: APIRoute = async () => {
|
export const GET: APIRoute = async () => {
|
||||||
try {
|
try {
|
||||||
const count = await getVolunteerCount();
|
const count = await prisma.formulario.count();
|
||||||
|
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ success: true, count },
|
{ success: true, count },
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,40 @@
|
||||||
@plugin '@tailwindcss/typography';
|
@plugin '@tailwindcss/typography';
|
||||||
@plugin "daisyui";
|
@plugin "daisyui";
|
||||||
|
|
||||||
|
@plugin "daisyui/theme" {
|
||||||
|
name: "cdrpj";
|
||||||
|
default: false;
|
||||||
|
color-scheme: light;
|
||||||
|
--color-base-100: #ffffff;
|
||||||
|
--color-base-200: #ebe6d2;
|
||||||
|
--color-base-300: #d8d1b9;
|
||||||
|
--color-base-content: #003421;
|
||||||
|
--color-primary: #cba16a;
|
||||||
|
--color-primary-content: #003421;
|
||||||
|
--color-secondary: #003421;
|
||||||
|
--color-secondary-content: #ebe6d2;
|
||||||
|
--color-accent: #22523f;
|
||||||
|
--color-accent-content: #ebe6d2;
|
||||||
|
--color-neutral: #22523f;
|
||||||
|
--color-neutral-content: #ebe6d2;
|
||||||
|
--color-info: #22523f;
|
||||||
|
--color-info-content: #ebe6d2;
|
||||||
|
--color-success: #4a8c6f;
|
||||||
|
--color-success-content: #ffffff;
|
||||||
|
--color-warning: #cba16a;
|
||||||
|
--color-warning-content: #003421;
|
||||||
|
--color-error: #b03a2e;
|
||||||
|
--color-error-content: #ffffff;
|
||||||
|
--radius-selector: 0rem;
|
||||||
|
--radius-field: 0rem;
|
||||||
|
--radius-box: 0rem;
|
||||||
|
--size-selector: .25rem;
|
||||||
|
--size-field: .25rem;
|
||||||
|
--border: 1px;
|
||||||
|
--depth: 1;
|
||||||
|
--noise: 0;
|
||||||
|
}
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-primary: "Poppins", sans-serif;
|
--font-primary: "Poppins", sans-serif;
|
||||||
--font-secondary: "Kameron Variable", sans-serif;
|
--font-secondary: "Kameron Variable", sans-serif;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue