diff --git a/.gitignore b/.gitignore
index 3148277..291f341 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,6 +32,9 @@ opencode/
.opencode/
docs/
+# guía técnica local
+docs/GUIA-TECNICA.md
+
# scripts de migración (locales, no en producción)
scripts/migrate-sheets-to-db.ts
scripts/seed-form-config.ts
diff --git a/prisma/migrations/20260815180714_add_trigram_fuzzy_indexes/migration.sql b/prisma/migrations/20260815180714_add_trigram_fuzzy_indexes/migration.sql
new file mode 100644
index 0000000..9295739
--- /dev/null
+++ b/prisma/migrations/20260815180714_add_trigram_fuzzy_indexes/migration.sql
@@ -0,0 +1,14 @@
+-- CreateIndex
+CREATE INDEX "formularios_ciudad_trgm_idx" ON "formularios" USING GIN ("ciudad" gin_trgm_ops);
+
+-- CreateIndex
+CREATE INDEX "formularios_estado_trgm_idx" ON "formularios" USING GIN ("estado" gin_trgm_ops);
+
+-- CreateIndex
+CREATE INDEX "formularios_profesion_trgm_idx" ON "formularios" USING GIN ("profesion" gin_trgm_ops);
+
+-- CreateIndex
+CREATE INDEX "formularios_nivel_academico_trgm_idx" ON "formularios" USING GIN ("nivel_academico" gin_trgm_ops);
+
+-- CreateIndex
+CREATE INDEX "formularios_nacionalidad_trgm_idx" ON "formularios" USING GIN ("nacionalidad" gin_trgm_ops);
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index b028508..6358ffc 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -60,6 +60,11 @@ model Formulario {
@@index([telefono(ops: raw("gin_trgm_ops"))], type: Gin, map: "formularios_telefono_trgm_idx")
@@index([whatsapp(ops: raw("gin_trgm_ops"))], type: Gin, map: "formularios_whatsapp_trgm_idx")
@@index([documento_nro(ops: raw("gin_trgm_ops"))], type: Gin, map: "formularios_documento_nro_trgm_idx")
+ @@index([ciudad(ops: raw("gin_trgm_ops"))], type: Gin, map: "formularios_ciudad_trgm_idx")
+ @@index([estado(ops: raw("gin_trgm_ops"))], type: Gin, map: "formularios_estado_trgm_idx")
+ @@index([profesion(ops: raw("gin_trgm_ops"))], type: Gin, map: "formularios_profesion_trgm_idx")
+ @@index([nivel_academico(ops: raw("gin_trgm_ops"))], type: Gin, map: "formularios_nivel_academico_trgm_idx")
+ @@index([nacionalidad(ops: raw("gin_trgm_ops"))], type: Gin, map: "formularios_nacionalidad_trgm_idx")
@@map("formularios")
}
diff --git a/src/components/Header.astro b/src/components/Header.astro
index f210430..679de8a 100644
--- a/src/components/Header.astro
+++ b/src/components/Header.astro
@@ -12,6 +12,7 @@ const tl = createTranslator(Astro.currentLocale);
const currentLocale = Astro.currentLocale;
const currentPath = Astro.url.pathname;
const { locale } = Astro.params;
+const isAdmin = currentPath.split("/").includes("admin");
const languages = [
{ code: "es", icon: "icon_flag_es", label: "Español" },
@@ -156,6 +157,16 @@ function translatePath(newLocale: string) {
))
}
+ {isAdmin && (
+
+
+ Acceder
+
+
+ )}
+ {isAdmin && (
+
+
+
+ )}
+
+
+
+
+
+
{{ error }}
+
+
+
+
+
+ Resumen
+
+
+
+
Total voluntarios
+
{{ fmt(stats.total) }}
+
+
+
Edad promedio
+
{{ stats.edad.promedio ?? "—" }} años
+
+
+
Femenino
+
{{ fmt(sexo.f) }}
+
{{ pct(sexo.f, sexo.total) }}% del total
+
+
+
Masculino
+
{{ fmt(sexo.m) }}
+
{{ pct(sexo.m, sexo.total) }}% del total
+
+
+
+
+
+
+
+ Idiomas
+
+
+
+
{{ lang.label }}
+
{{ fmt(lang.count) }}
+
{{ pct(lang.count, stats.total) }}% del total
+
+
+
+
+
+
+
+ Distribuciones
+
+
+
+
+
Áreas de colaboración
+
+
+
{{ item.label }}
+
+
{{ fmt(item.count) }}
+
+
+
+
+
+
+
+
Días disponibles
+
+
+
{{ item.label }}
+
+
{{ fmt(item.count) }}
+
+
+
+
+
+
+
+
Horario preferido
+
+
+
{{ item.label }}
+
+
{{ fmt(item.count) }}
+
+
+
+
+
+
+
+
Países
+
+
+
{{ item.value }}
+
+
{{ fmt(item.count) }}
+
+
+
+
+
+
+
+
+
+
+ Disponibilidad y salud
+
+
+
+
+
+ {{ m.title }}
+ Sí {{ m.pct }}% · No {{ 100 - m.pct }}%
+
+
+
+ Sí: {{ fmt(m.si) }} · No: {{ fmt(m.no) }}
+
+
+
+
+
+ Sexo
+ F {{ pct(sexo.f, sexo.total) }}% · M {{ pct(sexo.m, sexo.total) }}%
+
+
+
+ F: {{ fmt(sexo.f) }} · M: {{ fmt(sexo.m) }}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/admin/SearchableCombobox.vue b/src/components/admin/SearchableCombobox.vue
new file mode 100644
index 0000000..73a086a
--- /dev/null
+++ b/src/components/admin/SearchableCombobox.vue
@@ -0,0 +1,178 @@
+
+
+
+
+
+ {{ item }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/admin/VoluntarioDetail.vue b/src/components/admin/VoluntarioDetail.vue
new file mode 100644
index 0000000..d78acd0
--- /dev/null
+++ b/src/components/admin/VoluntarioDetail.vue
@@ -0,0 +1,284 @@
+
+
+
+
+
+
+
{{ error }}
+
+
+
+
+ {{ data.nombre }} {{ data.segundo_nombre }} {{ data.apellido }}
+
+ #{{ data.numero_voluntario }}
+ {{ data.status }}
+
+
+
+
+ {{ sec.title }}
+
+
+
+
- {{ f.label }}
+
-
+
+
+ {{ item }}
+
+
+ {{ item }}
+
+
+ {{ f.displayText }}
+
+
+
+
+
+
+
+ Otros datos
+
+
+
+
- {{ r.key }}
+ - {{ r.value }}
+
+
+
+
+
+
+ Sistema
+
+
+
+
- ID
+ - {{ data.id }}
+
+
+
- Versión de formulario
+ - {{ data.form_version || "—" }}
+
+
+
- Correo enviado
+ - {{ formatDateTime(data.email_sent_at) }}
+
+
+
- Registrado
+ - {{ formatDateTime(data.submitted_at) }}
+
+
+
- Actualizado
+ - {{ formatDateTime(data.updated_at) }}
+
+
+
+
+
+
+
+
diff --git a/src/components/admin/VoluntariosTable.vue b/src/components/admin/VoluntariosTable.vue
index a03e826..ef083b7 100644
--- a/src/components/admin/VoluntariosTable.vue
+++ b/src/components/admin/VoluntariosTable.vue
@@ -1,7 +1,7 @@
-
+
@@ -36,97 +36,76 @@
-
-
-
-
- {{ paisLabel }}
- ▾
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+ {{ chip.label }}
+ ✕
+
+
+
+
+
+
-
Datos personales
+
+ Ubicación
+
+
+
+
+
+ Perfil
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -154,7 +174,9 @@
-
Preferencias y condiciones
+
+ Preferencias y condiciones
+
@@ -183,21 +205,22 @@
Áreas |
Status |
Fecha |
+
Acción |
- |
+ |
|
- | {{ error }} |
+ {{ error }} |
- | Sin resultados |
+ Sin resultados |
-
+
| {{ row.numero_voluntario }} |
{{ row.nombre_completo }} |
{{ row.correo }} |
@@ -210,6 +233,9 @@
{{ row.status || "pendiente" }}
{{ formatDate(row.submitted_at) }} |
+
+ Ver
+ |
@@ -217,7 +243,7 @@
-
+
{{ total }} registros · página {{ page }} de {{ totalPages || 1 }}
@@ -234,11 +260,31 @@
»
+
+
+
+
+
+
+
+
+ Detalle del voluntario
+ ✕
+
+
+
+
+
+
+
+
diff --git a/src/layouts/MainLayout.astro b/src/layouts/MainLayout.astro
index 432452b..dbb9bce 100644
--- a/src/layouts/MainLayout.astro
+++ b/src/layouts/MainLayout.astro
@@ -8,7 +8,7 @@ import "@fontsource/poppins/700.css";
import "@fontsource-variable/kameron";
import ShareSticky from "../components/ShareSticky.vue";
import { routeTranslations } from "../i18n";
-const { title, description, image, url, date, noindex } = Astro.props;
+const { title, description, image, url, date, noindex, theme } = Astro.props;
const currentLocale = Astro.currentLocale ?? "es";
const direction = currentLocale === "he" ? "rtl" : "ltr";
@@ -21,7 +21,7 @@ const isNewsPage = contentSegments.some((segment) =>
);
---
-
+
+
+
+
+
+
+
+ Admin
+
+
+ Dashboard de Voluntarios
+
+
+
+
+
+
diff --git a/src/pages/[locale]/admin/index.astro b/src/pages/[locale]/admin/index.astro
index 6709d1b..2f17098 100644
--- a/src/pages/[locale]/admin/index.astro
+++ b/src/pages/[locale]/admin/index.astro
@@ -1,30 +1,28 @@
---
-import "../../../styles/global.css";
-import "@fontsource/poppins/400.css";
-import "@fontsource/poppins/500.css";
-import "@fontsource/poppins/700.css";
+import MainLayout from "@/layouts/MainLayout.astro";
+import Header from "@/components/Header.astro";
import VoluntariosTable from "../../../components/admin/VoluntariosTable.vue";
+
+const { locale } = Astro.params;
---
-
-
-
-
- Dashboard de Voluntarios
-
-
-
-
-
-
-
Dashboard de Voluntarios
-
Registros migrados desde Google Sheets
-
-
sin auth
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+ Admin
+
+
+ Dashboard de Voluntarios
+
+
+
+
+
+
diff --git a/src/pages/[locale]/admin/voluntario/[numero].astro b/src/pages/[locale]/admin/voluntario/[numero].astro
new file mode 100644
index 0000000..82aa40d
--- /dev/null
+++ b/src/pages/[locale]/admin/voluntario/[numero].astro
@@ -0,0 +1,26 @@
+---
+import MainLayout from "@/layouts/MainLayout.astro";
+import Header from "@/components/Header.astro";
+import VoluntarioDetail from "@/components/admin/VoluntarioDetail.vue";
+
+const { numero } = Astro.params;
+---
+
+
+
+
+
+
+
+
+ Admin
+
+
+ Detalle de Voluntario
+
+
+
+
+
+
+
diff --git a/src/pages/api/admin/voluntarios.ts b/src/pages/api/admin/voluntarios.ts
index 849482e..5c1c7e5 100644
--- a/src/pages/api/admin/voluntarios.ts
+++ b/src/pages/api/admin/voluntarios.ts
@@ -1,6 +1,6 @@
import type { APIRoute } from "astro";
import { prisma } from "../lib/prisma";
-import type { Prisma } from "@prisma/client";
+import { Prisma } from "@prisma/client";
export const prerender = false;
@@ -28,14 +28,17 @@ const SEARCHABLE_FIELDS = [
"nacionalidad",
] as const;
-const CORE_TEXT_FILTERS = [
+const CORE_TEXT_FILTERS = ["documento_nro"] as const;
+
+const FUZZY_TEXT_FILTERS = [
"ciudad",
"estado",
"profesion",
"nivel_academico",
- "documento_nro",
] as const;
+const FUZZY_THRESHOLD = 0.35;
+
const CORE_EXACT_FILTERS = [
"sexo",
"nacionalidad",
@@ -120,6 +123,23 @@ function buildResponseFilter(key: string, values: string[]): Prisma.FormularioWh
return or.length === 1 ? or[0] : { OR: or };
}
+async function fuzzyIds(field: string, value: string): Promise {
+ const col = Prisma.raw(field);
+ const rows = await prisma.$queryRaw<{ id: string }[]>`
+ SELECT id::text AS id
+ FROM formularios
+ WHERE ${col} IS NOT NULL
+ AND ${col} <> ''
+ AND length(${col}) >= 2
+ AND (
+ similarity(lower(${col}), lower(${value})) > ${FUZZY_THRESHOLD}
+ OR ${col} ILIKE ${`%${value}%`}
+ )
+ LIMIT 5000
+ `;
+ return rows.map((r) => r.id);
+}
+
export const GET: APIRoute = async ({ url }) => {
try {
const sp = url.searchParams;
@@ -149,6 +169,14 @@ export const GET: APIRoute = async ({ url }) => {
if (v) conditions.push({ [field]: { contains: v, mode: "insensitive" } });
}
+ for (const field of FUZZY_TEXT_FILTERS) {
+ const v = (sp.get(field) || "").trim();
+ if (v) {
+ const ids = await fuzzyIds(field, v);
+ conditions.push(ids.length ? { id: { in: ids } } : { id: { in: [] } });
+ }
+ }
+
for (const field of CORE_EXACT_FILTERS) {
const v = (sp.get(field) || "").trim();
if (v) conditions.push({ [field]: { in: splitList(v) } });
@@ -159,16 +187,6 @@ export const GET: APIRoute = async ({ url }) => {
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([
diff --git a/src/pages/api/admin/voluntarios/[numero].ts b/src/pages/api/admin/voluntarios/[numero].ts
new file mode 100644
index 0000000..03ddc48
--- /dev/null
+++ b/src/pages/api/admin/voluntarios/[numero].ts
@@ -0,0 +1,97 @@
+import type { APIRoute } from "astro";
+import { prisma } from "../../lib/prisma";
+import { t, type I18nKey } from "../../../../i18n";
+
+export const prerender = false;
+
+export const GET: APIRoute = async ({ params }) => {
+ try {
+ const numero = Number(params.numero);
+ if (!Number.isInteger(numero) || numero <= 0) {
+ return Response.json(
+ { success: false, message: "Número de voluntario inválido" },
+ { status: 404 }
+ );
+ }
+
+ const [voluntario, secciones] = await Promise.all([
+ prisma.formulario.findUnique({
+ where: { numero_voluntario: numero },
+ include: { respuestas: { orderBy: { created_at: "asc" } } },
+ }),
+ prisma.seccion.findMany({
+ orderBy: { orden: "asc" },
+ include: { campos: { orderBy: { orden: "asc" } } },
+ }),
+ ]);
+
+ if (!voluntario) {
+ return Response.json(
+ { success: false, message: "Voluntario no encontrado" },
+ { status: 404 }
+ );
+ }
+
+ const sections = secciones.map((s) => ({
+ key: s.title_key.replace(/^form\./, ""),
+ title: t("es", s.title_key as I18nKey),
+ fields: s.campos.map((c) => ({
+ key: c.key,
+ label: t("es", c.label_key as I18nKey),
+ tipo: c.tipo,
+ options: Array.isArray(c.options)
+ ? (c.options as { value?: string; label?: string }[])
+ .filter((o) => o && typeof o === "object")
+ .map((o) => ({
+ value: o.value ?? "",
+ label: o.label ? t("es", o.label as I18nKey) : o.value ?? "",
+ }))
+ : [],
+ })),
+ }));
+
+ return Response.json({
+ success: true,
+ data: {
+ id: voluntario.id,
+ numero_voluntario: voluntario.numero_voluntario,
+ nombre: voluntario.nombre,
+ segundo_nombre: voluntario.segundo_nombre,
+ apellido: voluntario.apellido,
+ documento_nro: voluntario.documento_nro,
+ documento_tipo: voluntario.documento_tipo,
+ correo: voluntario.correo,
+ fecha_nacimiento: voluntario.fecha_nacimiento,
+ nacionalidad: voluntario.nacionalidad,
+ sexo: voluntario.sexo,
+ direccion: voluntario.direccion,
+ ciudad: voluntario.ciudad,
+ estado: voluntario.estado,
+ pais: voluntario.pais,
+ codigo_postal: voluntario.codigo_postal,
+ telefono: voluntario.telefono,
+ whatsapp: voluntario.whatsapp,
+ profesion: voluntario.profesion,
+ lugar_trabajo: voluntario.lugar_trabajo,
+ nivel_academico: voluntario.nivel_academico,
+ status: voluntario.status,
+ form_version: voluntario.form_version,
+ email_sent_at: voluntario.email_sent_at,
+ submitted_at: voluntario.submitted_at,
+ updated_at: voluntario.updated_at,
+ respuestas: voluntario.respuestas.map((r) => ({
+ seccion: r.seccion,
+ key: r.key,
+ value: r.value,
+ })),
+ },
+ form: { sections },
+ });
+ } catch (error) {
+ console.error("Error en /api/admin/voluntarios/[numero]:", error);
+ return Response.json(
+ { success: false, message: "Error al obtener el voluntario" },
+ { status: 500 }
+ );
+ }
+};
diff --git a/src/pages/api/admin/voluntarios/distinct.ts b/src/pages/api/admin/voluntarios/distinct.ts
new file mode 100644
index 0000000..c62b3df
--- /dev/null
+++ b/src/pages/api/admin/voluntarios/distinct.ts
@@ -0,0 +1,56 @@
+import type { APIRoute } from "astro";
+import { prisma } from "../../lib/prisma";
+import { Prisma } from "@prisma/client";
+
+export const prerender = false;
+
+const DISTINCT_FIELDS = new Set([
+ "ciudad",
+ "estado",
+ "profesion",
+ "nivel_academico",
+ "nacionalidad",
+ "pais",
+]);
+
+const SIMILARITY_THRESHOLD = 0.35;
+
+export const GET: APIRoute = async ({ url }) => {
+ try {
+ const sp = url.searchParams;
+ const field = (sp.get("field") || "").trim();
+ const q = (sp.get("q") || "").trim();
+ const limit = Math.min(100, Math.max(1, parseInt(sp.get("limit") || "25", 10)));
+
+ if (!DISTINCT_FIELDS.has(field)) {
+ return Response.json(
+ { success: false, message: "Campo no permitido" },
+ { status: 400 }
+ );
+ }
+
+ const col = Prisma.raw(field);
+
+ const rows = await prisma.$queryRaw<{ value: string; count: number }[]>`
+ SELECT ${col} AS value, COUNT(*)::int AS count
+ FROM formularios
+ WHERE ${col} IS NOT NULL AND ${col} <> ''
+ ${q.length >= 2 ? Prisma.sql`AND (similarity(lower(${col}), lower(${q})) > ${SIMILARITY_THRESHOLD} OR ${col} ILIKE ${`%${q}%`})` : Prisma.empty}
+ GROUP BY ${col}
+ ORDER BY count DESC
+ LIMIT ${limit}
+ `;
+
+ return Response.json({
+ success: true,
+ field,
+ data: rows.map((r) => ({ value: r.value, count: r.count })),
+ });
+ } catch (error) {
+ console.error("Error en /api/admin/voluntarios/distinct:", error);
+ return Response.json(
+ { success: false, message: "Error al obtener valores" },
+ { status: 500 }
+ );
+ }
+};
diff --git a/src/pages/api/admin/voluntarios/stats.ts b/src/pages/api/admin/voluntarios/stats.ts
new file mode 100644
index 0000000..67d2466
--- /dev/null
+++ b/src/pages/api/admin/voluntarios/stats.ts
@@ -0,0 +1,138 @@
+import type { APIRoute } from "astro";
+import { prisma } from "../../lib/prisma";
+import { t, type I18nKey } from "../../../../i18n";
+
+export const prerender = false;
+
+const OPTION_FIELDS = [
+ "idioma",
+ "areas_colaborar",
+ "dias_disponibles",
+ "horario_preferido",
+ "sexo",
+ "fuera_ciudad",
+ "misiones_internacionales",
+ "condicion_medica",
+ "voluntariado_anterior",
+] as const;
+
+async function multiValueDist(key: string): Promise<{ value: string; count: number }[]> {
+ const rows = await prisma.$queryRaw<{ v: string; cnt: number }[]>`
+ SELECT trim(unnest(string_to_array(value, ','))) AS v, count(DISTINCT formulario_id)::int AS cnt
+ FROM respuestas
+ WHERE key = ${key} AND btrim(value) <> ''
+ GROUP BY v
+ ORDER BY cnt DESC
+ `;
+ return rows.map((r) => ({ value: r.v, count: r.cnt }));
+}
+
+async function valueDist(key: string): Promise<{ value: string; count: number }[]> {
+ const rows = await prisma.respuesta.groupBy({
+ by: ["value"],
+ where: { key, value: { not: "" } },
+ _count: { _all: true },
+ });
+ return rows
+ .filter((r) => r.value !== null)
+ .map((r) => ({ value: r.value!, count: r._count._all }))
+ .sort((a, b) => b.count - a.count);
+}
+
+export const GET: APIRoute = async () => {
+ try {
+ const campos = await prisma.campo.findMany({
+ where: { key: { in: OPTION_FIELDS as unknown as string[] } },
+ });
+
+ const optionMaps: Record> = {};
+ for (const c of campos) {
+ optionMaps[c.key] = {};
+ if (Array.isArray(c.options)) {
+ for (const o of c.options as { value?: string; label?: string }[]) {
+ if (o && o.value) {
+ optionMaps[c.key][o.value] = o.label ? t("es", o.label as I18nKey) : o.value;
+ }
+ }
+ }
+ }
+
+ const withLabels = (
+ dist: { value: string; count: number }[],
+ key: string
+ ): { value: string; label: string; count: number }[] =>
+ dist.map((d) => ({
+ value: d.value,
+ label: optionMaps[key]?.[d.value] || d.value,
+ count: d.count,
+ }));
+
+ const [total, statuses, sexo, paises, [idiomas, areas, dias, horarios], condiciones, edad] =
+ await Promise.all([
+ prisma.formulario.count(),
+ prisma.formulario.groupBy({ by: ["status"], _count: { _all: true } }),
+ prisma.formulario.groupBy({ by: ["sexo"], _count: { _all: true } }),
+ prisma.formulario.groupBy({
+ by: ["pais"],
+ _count: { _all: true },
+ where: { pais: { not: null } },
+ orderBy: { _count: { pais: "desc" } },
+ take: 12,
+ }),
+ Promise.all([
+ multiValueDist("idioma"),
+ multiValueDist("areas_colaborar"),
+ multiValueDist("dias_disponibles"),
+ multiValueDist("horario_preferido"),
+ ]),
+ Promise.all(
+ ["fuera_ciudad", "misiones_internacionales", "condicion_medica", "voluntariado_anterior"].map(
+ (k) => valueDist(k)
+ )
+ ),
+ prisma.$queryRaw<{ promedio: number | null }[]>`
+ SELECT round(avg(extract(epoch from (now() - fecha_nacimiento)) / 31557600), 1) AS promedio
+ FROM formularios
+ WHERE fecha_nacimiento IS NOT NULL
+ `,
+ ]);
+
+ const condKeys = [
+ "fuera_ciudad",
+ "misiones_internacionales",
+ "condicion_medica",
+ "voluntariado_anterior",
+ ] as const;
+ const condicionesObj: Record> = {};
+ condKeys.forEach((k, i) => {
+ condicionesObj[k] = Object.fromEntries(condiciones[i].map((c) => [c.value, c.count]));
+ });
+
+ const formatPais = paises
+ .filter((p) => p.pais !== null)
+ .map((p) => ({ value: p.pais!, count: p._count._all }));
+
+ return Response.json({
+ success: true,
+ total,
+ edad: { promedio: edad[0]?.promedio ?? null },
+ idiomas: withLabels(idiomas, "idioma"),
+ areas: withLabels(areas, "areas_colaborar"),
+ dias: withLabels(dias, "dias_disponibles"),
+ horarios: withLabels(horarios, "horario_preferido"),
+ sexo: withLabels(
+ sexo.filter((s) => s.sexo !== null).map((s) => ({ value: s.sexo!, count: s._count._all })),
+ "sexo"
+ ),
+ paises: formatPais,
+ statuses: statuses.map((s) => ({ value: s.status, count: s._count._all })),
+ condiciones: condicionesObj,
+ });
+ } catch (error) {
+ console.error("Error en /api/admin/voluntarios/stats:", error);
+ return Response.json(
+ { success: false, message: "Error al obtener estadísticas" },
+ { status: 500 }
+ );
+ }
+};
diff --git a/src/pages/api/emailPostulacion/send.ts b/src/pages/api/emailPostulacion/send.ts
index d89e270..e7fcd3f 100644
--- a/src/pages/api/emailPostulacion/send.ts
+++ b/src/pages/api/emailPostulacion/send.ts
@@ -1,7 +1,8 @@
import type { APIRoute } from "astro";
-import { appendToSheet, emailExists } from "../lib/googleSheets";
+import { appendToSheet } from "../lib/googleSheets";
import { sendEmailCf } from "../lib/email";
import { prisma } from "../lib/prisma";
+import { emailExistsInDb } from "../lib/formularioDb";
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
@@ -23,7 +24,7 @@ export const POST: APIRoute = async ({ request }) => {
return Response.redirect("/?error=datos", 303);
}
- const exists = await emailExists(email);
+ const exists = await emailExistsInDb(email);
if (exists) {
return Response.json({
success: false,
diff --git a/src/pages/api/formulario/send.ts b/src/pages/api/formulario/send.ts
index da9484c..b33c10d 100644
--- a/src/pages/api/formulario/send.ts
+++ b/src/pages/api/formulario/send.ts
@@ -1,7 +1,7 @@
import type { APIRoute } from "astro";
import { appendRegistrationToSheet } from "../lib/googleSheets";
import { sendEmailCf } from "../lib/email";
-import { saveFormularioToSupabase } from "../lib/formularioDb";
+import { saveFormularioToSupabase, emailExistsInDb } from "../lib/formularioDb";
export const prerender = false;
const COLUMNS = [
@@ -102,17 +102,19 @@ export const POST: APIRoute = async ({ request }) => {
timeZone: "America/Puerto_Rico",
});
- const form = await saveFormularioToSupabase(
- formData,
- body.responses || []
- );
- if (emailExistsInRegistry(formData.correo)) {
+ const exists = await emailExistsInDb(formData.correo);
+ if (exists) {
return Response.json(
{ success: false, message: "Este correo ya está registrado. No se permiten 2 registros." },
{ status: 409 }
);
}
+ const form = await saveFormularioToSupabase(
+ formData,
+ body.responses || []
+ );
+
const row = COLUMNS.map((key) => flattenValue(formData[key]));
row.push(timestamp);
@@ -127,9 +129,6 @@ export const POST: APIRoute = async ({ request }) => {
...result,
numero_voluntario: form.numero_voluntario,
});
- addEmailToRegistry(formData.correo);
-
- return Response.json(result);
} catch (error) {
console.error("Error en /api/formulario/send:", error);
return Response.json(
diff --git a/src/pages/api/lib/emailsRegistry.ts b/src/pages/api/lib/emailsRegistry.ts
deleted file mode 100644
index a4e4c4b..0000000
--- a/src/pages/api/lib/emailsRegistry.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import fs from "node:fs";
-import path from "node:path";
-
-const REGISTRY_PATH = path.resolve(process.cwd(), "data", "emails.txt");
-
-export function emailExistsInRegistry(email: string): boolean {
- try {
- if (!fs.existsSync(REGISTRY_PATH)) return false;
- const content = fs.readFileSync(REGISTRY_PATH, "utf-8");
- return content.split("\n").some((line) => line.trim().toLowerCase() === email.trim().toLowerCase());
- } catch {
- return false;
- }
-}
-
-export function addEmailToRegistry(email: string): void {
- const dir = path.dirname(REGISTRY_PATH);
- if (!fs.existsSync(dir)) {
- fs.mkdirSync(dir, { recursive: true });
- }
- fs.appendFileSync(REGISTRY_PATH, email.trim().toLowerCase() + "\n");
-}
diff --git a/src/pages/api/lib/formularioDb.ts b/src/pages/api/lib/formularioDb.ts
index ea9ffec..6fa7131 100644
--- a/src/pages/api/lib/formularioDb.ts
+++ b/src/pages/api/lib/formularioDb.ts
@@ -35,6 +35,16 @@ function isBlank(v: unknown): boolean {
return v === undefined || v === null || v === "";
}
+export async function emailExistsInDb(email: string): Promise {
+ const normalized = (email || "").trim().toLowerCase();
+ if (!normalized) return false;
+ const found = await prisma.formulario.findFirst({
+ where: { correo: { equals: normalized, mode: "insensitive" } },
+ select: { id: true },
+ });
+ return Boolean(found);
+}
+
export async function saveFormularioToSupabase(
formData: FormData,
responses: RespuestaPayload[]
diff --git a/src/pages/api/voluntarios/count.ts b/src/pages/api/voluntarios/count.ts
index 4cc10b3..5b5bed7 100644
--- a/src/pages/api/voluntarios/count.ts
+++ b/src/pages/api/voluntarios/count.ts
@@ -1,11 +1,11 @@
import type { APIRoute } from "astro";
-import { getVolunteerCount } from "../lib/googleSheets";
+import { prisma } from "../lib/prisma";
export const prerender = false;
export const GET: APIRoute = async () => {
try {
- const count = await getVolunteerCount();
+ const count = await prisma.formulario.count();
return Response.json(
{ success: true, count },
diff --git a/src/styles/global.css b/src/styles/global.css
index 5073308..ae6376b 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -2,6 +2,40 @@
@plugin '@tailwindcss/typography';
@plugin "daisyui";
+@plugin "daisyui/theme" {
+ name: "cdrpj";
+ default: false;
+ color-scheme: light;
+ --color-base-100: #ffffff;
+ --color-base-200: #ebe6d2;
+ --color-base-300: #d8d1b9;
+ --color-base-content: #003421;
+ --color-primary: #cba16a;
+ --color-primary-content: #003421;
+ --color-secondary: #003421;
+ --color-secondary-content: #ebe6d2;
+ --color-accent: #22523f;
+ --color-accent-content: #ebe6d2;
+ --color-neutral: #22523f;
+ --color-neutral-content: #ebe6d2;
+ --color-info: #22523f;
+ --color-info-content: #ebe6d2;
+ --color-success: #4a8c6f;
+ --color-success-content: #ffffff;
+ --color-warning: #cba16a;
+ --color-warning-content: #003421;
+ --color-error: #b03a2e;
+ --color-error-content: #ffffff;
+ --radius-selector: 0rem;
+ --radius-field: 0rem;
+ --radius-box: 0rem;
+ --size-selector: .25rem;
+ --size-field: .25rem;
+ --border: 1px;
+ --depth: 1;
+ --noise: 0;
+}
+
@theme {
--font-primary: "Poppins", sans-serif;
--font-secondary: "Kameron Variable", sans-serif;