second commit for data base and prisma shema

This commit is contained in:
Esteban Paz 2026-08-14 21:17:53 -05:00
parent cd78defda1
commit a5d736a30d
8 changed files with 840 additions and 32 deletions

View File

@ -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);

View File

@ -54,6 +54,12 @@ model Formulario {
@@index([status]) @@index([status])
@@index([pais]) @@index([pais])
@@index([submitted_at]) @@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") @@map("formularios")
} }
@ -70,6 +76,7 @@ model Respuesta {
@@index([formulario_id]) @@index([formulario_id])
@@index([key]) @@index([key])
@@index([seccion]) @@index([seccion])
@@index([value(ops: raw("gin_trgm_ops"))], type: Gin, map: "respuestas_value_trgm_idx")
@@map("respuestas") @@map("respuestas")
} }

View File

@ -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>

View File

@ -0,0 +1,482 @@
<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"></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"></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>

12
src/middleware.ts Normal file
View File

@ -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();
});

View File

@ -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";
---
<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>

View File

@ -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<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 }
);
}
};

View File

@ -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 }
);
}
};