Compare commits
1 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
fb98f53795 |
10
.env
10
.env
|
|
@ -18,6 +18,8 @@ EMAIL_API_KEY=9UShpS8oh5Iun92TJSfevElI3Lp99TCv
|
||||||
# server with the prisma dev CLI command, when not choosing any non-default ports or settings. The API key, unlike the
|
# 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.
|
# 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_WEBHOOK_URL="https://flows2.carpa.com/webhook/news-summary"
|
||||||
N8N_API_KEY="sk_live_JwjpTdZrLVvijCKuWylWZbUuhBm6zPDH"
|
N8N_API_KEY="sk_live_JwjpTdZrLVvijCKuWylWZbUuhBm6zPDH"
|
||||||
|
|
||||||
|
|
@ -31,11 +33,3 @@ TURNSTILE_SITE_KEY=0x4AAAAAAD9IYWC6HPflpneO
|
||||||
# Cloudflare Turnstile — secret key (server-side, keep safe!)
|
# Cloudflare Turnstile — secret key (server-side, keep safe!)
|
||||||
# In production, set this as a Gitea Actions secret named TURNSTILE_SECRET_KEY
|
# In production, set this as a Gitea Actions secret named TURNSTILE_SECRET_KEY
|
||||||
TURNSTILE_SECRET_KEY=0x4AAAAAAD9IYXASjQSpmo2iS902SDoXnT4
|
TURNSTILE_SECRET_KEY=0x4AAAAAAD9IYXASjQSpmo2iS902SDoXnT4
|
||||||
|
|
||||||
# Supabase PostgreSQL
|
|
||||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres?schema=public"
|
|
||||||
DIRECT_URL="postgresql://postgres:postgres@localhost:5432/postgres?schema=public"
|
|
||||||
SUPABASE_URL="http://localhost:8000"
|
|
||||||
SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"
|
|
||||||
SUPABASE_SERVICE_ROLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
|
|
||||||
POSTGRES_PASSWORD=postgres
|
|
||||||
|
|
|
||||||
|
|
@ -31,9 +31,5 @@ data/
|
||||||
opencode/
|
opencode/
|
||||||
.opencode/
|
.opencode/
|
||||||
docs/
|
docs/
|
||||||
|
|
||||||
# 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
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,7 @@
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,6 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
engine: "classic",
|
engine: "classic",
|
||||||
datasource: {
|
datasource: {
|
||||||
url: env("DIRECT_URL") || env("DATABASE_URL"),
|
url: env("DATABASE_URL"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
# Please do not edit this file manually
|
|
||||||
# It should be added in your version-control system (e.g., Git)
|
|
||||||
provider = "sqlite"
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
-- 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");
|
|
||||||
|
|
@ -1,174 +0,0 @@
|
||||||
/*
|
|
||||||
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;
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
-- 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);
|
|
||||||
|
|
@ -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 = "postgresql"
|
provider = "sqlite"
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,8 @@ generator client {
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "sqlite"
|
||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
directUrl = env("DIRECT_URL")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model Contact {
|
model Contact {
|
||||||
|
|
@ -17,122 +16,4 @@ 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")
|
|
||||||
@@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
|
|
||||||
nombre String
|
|
||||||
rol String @default("coordinador")
|
|
||||||
activo Boolean @default(true)
|
|
||||||
ultimo_acceso DateTime? @db.Timestamptz
|
|
||||||
created_at DateTime @default(now()) @db.Timestamptz
|
|
||||||
|
|
||||||
@@index([email])
|
|
||||||
@@map("admins")
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
<!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>
|
||||||
|
|
@ -1,482 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<!-- Filtros -->
|
|
||||||
<div class="bg-base-200/60 p-4 rounded-box space-y-3">
|
|
||||||
<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="w-full md:w-52">
|
|
||||||
<label class="label py-0 text-xs font-medium">País</label>
|
|
||||||
<details class="dropdown w-full">
|
|
||||||
<summary class="btn btn-sm btn-outline w-full justify-between">
|
|
||||||
<span class="truncate">{{ paisLabel }}</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="p in paises" :key="p">
|
|
||||||
<label class="flex items-center gap-2 py-1.5">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:value="p"
|
|
||||||
v-model="selectedPaises"
|
|
||||||
class="checkbox checkbox-xs"
|
|
||||||
@change="applyFilters"
|
|
||||||
/>
|
|
||||||
<span>{{ p }}</span>
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button class="btn btn-ghost btn-sm" @click="clearFilters">Limpiar</button>
|
|
||||||
<button class="btn btn-outline btn-sm" @click="showFilters = !showFilters">
|
|
||||||
{{ showFilters ? "Ocultar filtros" : "Filtros" }}
|
|
||||||
<span v-if="activeFilterCount" class="badge badge-sm badge-primary">{{ activeFilterCount }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Panel avanzado -->
|
|
||||||
<div v-if="showFilters" class="border-t border-base-300 pt-3 space-y-4">
|
|
||||||
<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">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>
|
|
||||||
<label class="label py-0 text-xs font-medium">¿Tiene WhatsApp?</label>
|
|
||||||
<select v-model="hasWhatsapp" class="select select-bordered select-sm w-full" @change="applyFilters">
|
|
||||||
<option value="">Todos</option>
|
|
||||||
<option value="true">Sí</option>
|
|
||||||
<option value="false">No</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="label py-0 text-xs font-medium">¿Correo enviado?</label>
|
|
||||||
<select v-model="emailSent" class="select select-bordered select-sm w-full" @change="applyFilters">
|
|
||||||
<option value="">Todos</option>
|
|
||||||
<option value="true">Sí</option>
|
|
||||||
<option value="false">No</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p class="text-xs font-medium text-base-content/70 mb-2">Datos personales</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">Ciudad</label>
|
|
||||||
<input v-model="coreText.ciudad" type="text" class="input input-bordered input-sm w-full" @input="onSearch" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="label py-0 text-xs font-medium">Estado</label>
|
|
||||||
<input v-model="coreText.estado" type="text" class="input input-bordered input-sm w-full" @input="onSearch" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="label py-0 text-xs font-medium">Profesión</label>
|
|
||||||
<input v-model="coreText.profesion" type="text" class="input input-bordered input-sm w-full" @input="onSearch" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="label py-0 text-xs font-medium">Nivel académico</label>
|
|
||||||
<input v-model="coreText.nivel_academico" type="text" class="input input-bordered input-sm w-full" @input="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">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">Nacionalidad</label>
|
|
||||||
<select v-model="nacionalidad" class="select select-bordered select-sm w-full" @change="applyFilters">
|
|
||||||
<option value="">Todas</option>
|
|
||||||
<option v-for="n in nacionalidades" :key="n" :value="n">{{ n }}</option>
|
|
||||||
</select>
|
|
||||||
</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-medium text-base-content/70 mb-2">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>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-if="loading">
|
|
||||||
<td colspan="10" class="text-center py-10">
|
|
||||||
<span class="loading loading-spinner loading-md text-primary"></span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-else-if="error">
|
|
||||||
<td colspan="10" class="text-center py-10 text-error">{{ error }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-else-if="rows.length === 0">
|
|
||||||
<td colspan="10" 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">
|
|
||||||
<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>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Paginación -->
|
|
||||||
<div class="flex items-center justify-between flex-wrap gap-3">
|
|
||||||
<p class="text-sm text-base-content/60">
|
|
||||||
{{ 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>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, reactive, computed, onMounted } from "vue";
|
|
||||||
|
|
||||||
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 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 hasWhatsapp = ref("");
|
|
||||||
const emailSent = 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 paises = ref([]);
|
|
||||||
const statuses = ref([]);
|
|
||||||
const nacionalidades = ref([]);
|
|
||||||
const formVersions = ref([]);
|
|
||||||
|
|
||||||
let searchTimer = null;
|
|
||||||
|
|
||||||
const statusLabel = computed(() =>
|
|
||||||
selectedStatuses.value.length ? selectedStatuses.value.join(", ") : "Todos"
|
|
||||||
);
|
|
||||||
const paisLabel = computed(() =>
|
|
||||||
selectedPaises.value.length ? selectedPaises.value.join(", ") : "Todos"
|
|
||||||
);
|
|
||||||
|
|
||||||
const activeFilterCount = computed(() => {
|
|
||||||
let n = 0;
|
|
||||||
if (search.value.trim()) n++;
|
|
||||||
if (selectedStatuses.value.length) n++;
|
|
||||||
if (selectedPaises.value.length) n++;
|
|
||||||
if (desde.value || hasta.value) n++;
|
|
||||||
if (edadMin.value || edadMax.value) n++;
|
|
||||||
if (hasWhatsapp.value || emailSent.value) n++;
|
|
||||||
if (sexo.value || nacionalidad.value || formVersion.value) n++;
|
|
||||||
for (const f of responseFields) if (respFilters[f]) n++;
|
|
||||||
for (const f of Object.keys(coreText)) if (coreText[f]) n++;
|
|
||||||
return n;
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
if (hasWhatsapp.value) params.set("has_whatsapp", hasWhatsapp.value);
|
|
||||||
if (emailSent.value) params.set("email_sent", emailSent.value);
|
|
||||||
for (const f of Object.keys(coreText)) if (coreText[f]) params.set(f, coreText[f]);
|
|
||||||
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 fetch(`/api/admin/voluntarios?${params.toString()}`);
|
|
||||||
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 fetch("/api/admin/voluntarios/options");
|
|
||||||
const json = await res.json();
|
|
||||||
if (json.success) {
|
|
||||||
paises.value = json.paises;
|
|
||||||
statuses.value = json.statuses;
|
|
||||||
nacionalidades.value = json.nacionalidades;
|
|
||||||
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 = "";
|
|
||||||
hasWhatsapp.value = "";
|
|
||||||
emailSent.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();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
locale: es
|
locale: en
|
||||||
title: 'Señales de una transformación global: advertencias sobre Israel y el clima en el Medio Oriente'
|
title: 'Señales de una transformación global: advertencias sobre Israel y el clima en el Medio Oriente'
|
||||||
date: 2023-07-27
|
date: 2023-07-27
|
||||||
slug: 2023-07-27-senales-de-una-transformacion-global-advertencias-sobre-israel-y-el-clima-en-el-medio-oriente
|
slug: 2023-07-27-senales-de-una-transformacion-global-advertencias-sobre-israel-y-el-clima-en-el-medio-oriente
|
||||||
|
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
import { defineMiddleware } from "astro:middleware";
|
|
||||||
|
|
||||||
export const onRequest = defineMiddleware((context, next) => {
|
|
||||||
const { url, redirect } = context;
|
|
||||||
const { pathname } = url;
|
|
||||||
|
|
||||||
if (pathname === "/admin" || pathname === "/admin/") {
|
|
||||||
return redirect("/es/admin", 302);
|
|
||||||
}
|
|
||||||
|
|
||||||
return next();
|
|
||||||
});
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
---
|
|
||||||
import "../../../styles/global.css";
|
|
||||||
import "@fontsource/poppins/400.css";
|
|
||||||
import "@fontsource/poppins/500.css";
|
|
||||||
import "@fontsource/poppins/700.css";
|
|
||||||
import VoluntariosTable from "../../../components/admin/VoluntariosTable.vue";
|
|
||||||
---
|
|
||||||
|
|
||||||
<html lang="es">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>Dashboard de Voluntarios</title>
|
|
||||||
<meta name="robots" content="noindex" />
|
|
||||||
</head>
|
|
||||||
<body class="font-primary bg-base-200 min-h-screen">
|
|
||||||
<header class="bg-[#003421] text-[#EBE6D2]">
|
|
||||||
<div class="max-w-7xl mx-auto px-4 py-6 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl font-bold font-secondary">Dashboard de Voluntarios</h1>
|
|
||||||
<p class="text-sm text-white/70">Registros migrados desde Google Sheets</p>
|
|
||||||
</div>
|
|
||||||
<span class="badge badge-ghost badge-sm">sin auth</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main class="max-w-7xl mx-auto px-4 py-8">
|
|
||||||
<VoluntariosTable client:load />
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,230 +0,0 @@
|
||||||
import type { APIRoute } from "astro";
|
|
||||||
import { prisma } from "../lib/prisma";
|
|
||||||
import type { 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 = [
|
|
||||||
"ciudad",
|
|
||||||
"estado",
|
|
||||||
"profesion",
|
|
||||||
"nivel_academico",
|
|
||||||
"documento_nro",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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 hasWhatsapp = (sp.get("has_whatsapp") || "").trim();
|
|
||||||
if (hasWhatsapp) {
|
|
||||||
conditions.push(hasWhatsapp === "true" ? { whatsapp: { not: null } } : { whatsapp: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
const emailSent = (sp.get("email_sent") || "").trim();
|
|
||||||
if (emailSent) {
|
|
||||||
conditions.push(emailSent === "true" ? { email_sent_at: { not: null } } : { email_sent_at: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
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),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error en /api/admin/voluntarios:", error);
|
|
||||||
return Response.json(
|
|
||||||
{ success: false, message: "Error al obtener voluntarios" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
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(),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error en /api/admin/voluntarios/options:", error);
|
|
||||||
return Response.json(
|
|
||||||
{ success: false, message: "Error al obtener opciones" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
import { appendToSheet, emailExists } from "../lib/googleSheets";
|
import { appendToSheet, emailExists } from "../lib/googleSheets";
|
||||||
import { sendEmailCf } from "../lib/email";
|
import { sendEmailCf } from "../lib/email";
|
||||||
import { prisma } from "../lib/prisma";
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
|
|
@ -42,22 +41,10 @@ export const POST: APIRoute = async ({ request }) => {
|
||||||
new Date().toLocaleString()
|
new Date().toLocaleString()
|
||||||
];
|
];
|
||||||
|
|
||||||
// 🔹 Enviar correo (Cloudflare), guardar en Google Sheets y en PostgreSQL
|
// 🔹 Enviar correo (Cloudflare) y guardar en Google Sheets
|
||||||
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 { saveFormularioToSupabase } from "../lib/formularioDb";
|
import { emailExistsInRegistry, addEmailToRegistry } from "../lib/emailsRegistry";
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
const COLUMNS = [
|
const COLUMNS = [
|
||||||
|
|
@ -102,10 +102,6 @@ export const POST: APIRoute = async ({ request }) => {
|
||||||
timeZone: "America/Puerto_Rico",
|
timeZone: "America/Puerto_Rico",
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = await saveFormularioToSupabase(
|
|
||||||
formData,
|
|
||||||
body.responses || []
|
|
||||||
);
|
|
||||||
if (emailExistsInRegistry(formData.correo)) {
|
if (emailExistsInRegistry(formData.correo)) {
|
||||||
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." },
|
||||||
|
|
@ -123,10 +119,6 @@ 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");
|
||||||
|
|
||||||
return Response.json({
|
|
||||||
...result,
|
|
||||||
numero_voluntario: form.numero_voluntario,
|
|
||||||
});
|
|
||||||
addEmailToRegistry(formData.correo);
|
addEmailToRegistry(formData.correo);
|
||||||
|
|
||||||
return Response.json(result);
|
return Response.json(result);
|
||||||
|
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
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 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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
||||||
Loading…
Reference in New Issue