diff --git a/prisma/migrations/20260813010740_add_trigram_search_indexes/migration.sql b/prisma/migrations/20260813010740_add_trigram_search_indexes/migration.sql
new file mode 100644
index 0000000..7ca2f3a
--- /dev/null
+++ b/prisma/migrations/20260813010740_add_trigram_search_indexes/migration.sql
@@ -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);
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 2516d92..b028508 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -54,6 +54,12 @@ model Formulario {
@@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")
}
@@ -70,6 +76,7 @@ model Respuesta {
@@index([formulario_id])
@@index([key])
@@index([seccion])
+ @@index([value(ops: raw("gin_trgm_ops"))], type: Gin, map: "respuestas_value_trgm_idx")
@@map("respuestas")
}
diff --git a/public/admin/index.html b/public/admin/index.html
deleted file mode 100644
index 13d19a5..0000000
--- a/public/admin/index.html
+++ /dev/null
@@ -1,32 +0,0 @@
-
-
-
-
-
- TinaCMS
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/src/components/admin/VoluntariosTable.vue b/src/components/admin/VoluntariosTable.vue
new file mode 100644
index 0000000..a03e826
--- /dev/null
+++ b/src/components/admin/VoluntariosTable.vue
@@ -0,0 +1,482 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ statusLabel }}
+ ▾
+
+
+
+
+
+
+
+
+ {{ paisLabel }}
+ ▾
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Datos personales
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Preferencias y condiciones
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | # |
+ Nombre completo |
+ Correo |
+ Teléfono |
+ País |
+ Ciudad |
+ Idiomas |
+ Áreas |
+ Status |
+ Fecha |
+
+
+
+
+ |
+
+ |
+
+
+ | {{ error }} |
+
+
+ | Sin resultados |
+
+
+ | {{ row.numero_voluntario }} |
+ {{ row.nombre_completo }} |
+ {{ row.correo }} |
+ {{ row.telefono || "—" }} |
+ {{ row.pais || "—" }} |
+ {{ row.ciudad || "—" }} |
+ {{ row.idioma || "—" }} |
+ {{ row.areas_colaborar || "—" }} |
+
+ {{ row.status || "pendiente" }}
+ |
+ {{ formatDate(row.submitted_at) }} |
+
+
+
+
+
+
+
+
+ {{ total }} registros · página {{ page }} de {{ totalPages || 1 }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/middleware.ts b/src/middleware.ts
new file mode 100644
index 0000000..18c8aeb
--- /dev/null
+++ b/src/middleware.ts
@@ -0,0 +1,12 @@
+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();
+});
diff --git a/src/pages/[locale]/admin/index.astro b/src/pages/[locale]/admin/index.astro
new file mode 100644
index 0000000..6709d1b
--- /dev/null
+++ b/src/pages/[locale]/admin/index.astro
@@ -0,0 +1,30 @@
+---
+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";
+---
+
+
+
+
+
+ Dashboard de Voluntarios
+
+
+
+
+
+
+
Dashboard de Voluntarios
+
Registros migrados desde Google Sheets
+
+
sin auth
+
+
+
+
+
+
+
diff --git a/src/pages/api/admin/voluntarios.ts b/src/pages/api/admin/voluntarios.ts
new file mode 100644
index 0000000..849482e
--- /dev/null
+++ b/src/pages/api/admin/voluntarios.ts
@@ -0,0 +1,230 @@
+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 = {};
+ 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 }
+ );
+ }
+};
diff --git a/src/pages/api/admin/voluntarios/options.ts b/src/pages/api/admin/voluntarios/options.ts
new file mode 100644
index 0000000..bf8ce33
--- /dev/null
+++ b/src/pages/api/admin/voluntarios/options.ts
@@ -0,0 +1,56 @@
+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 }
+ );
+ }
+};