multiple fixes, create dashboard finish table TODO: middleware and auth

This commit is contained in:
Esteban Paz 2026-08-15 21:11:40 -05:00
parent 54a65d8ac9
commit 3351fb3330
22 changed files with 1413 additions and 192 deletions

3
.gitignore vendored
View File

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

View File

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

View File

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

View File

@ -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) {
</li>
))
}
{isAdmin && (
<li>
<a
class="hover:text-colorPrimary transition"
href="#"
>
Acceder
</a>
</li>
)}
</ul>
<div class="w-50">
<Button
@ -202,6 +213,16 @@ function translatePath(newLocale: string) {
variant="primary"
/>
</div>
{isAdmin && (
<div class="hidden md:block">
<Button
class="px-4 py-2 uppercase"
title="Acceder"
url="#"
variant="secondary"
/>
</div>
)}
<div class="dropdown dropdown-end md:block hidden">
<div
tabindex="0"

View File

@ -0,0 +1,209 @@
<template>
<div class="space-y-8">
<div v-if="loading" class="flex justify-center py-16">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
<div v-else-if="error" class="text-center py-10 text-error">{{ error }}</div>
<template v-else-if="stats">
<!-- Resumen -->
<section>
<h3 class="text-xs font-bold text-tertiary uppercase tracking-wide flex items-center gap-2 mb-3">
<span class="inline-block w-1 h-3.5 bg-colorSecondary"></span>Resumen
</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="stat bg-colorPrimary ring-1 ring-colorSecondary/40 rounded-box p-4">
<div class="stat-title text-tertiary/70 text-xs font-medium">Total voluntarios</div>
<div class="stat-value text-tertiary font-primary">{{ fmt(stats.total) }}</div>
</div>
<div class="stat bg-colorPrimary ring-1 ring-colorSecondary/40 rounded-box p-4">
<div class="stat-title text-tertiary/70 text-xs font-medium">Edad promedio</div>
<div class="stat-value text-tertiary font-primary">{{ stats.edad.promedio ?? "—" }} años</div>
</div>
<div class="stat bg-colorPrimary ring-1 ring-colorSecondary/40 rounded-box p-4">
<div class="stat-title text-tertiary/70 text-xs font-medium">Femenino</div>
<div class="stat-value text-tertiary font-primary">{{ fmt(sexo.f) }}</div>
<div class="stat-desc text-tertiary/60">{{ pct(sexo.f, sexo.total) }}% del total</div>
</div>
<div class="stat bg-colorPrimary ring-1 ring-colorSecondary/40 rounded-box p-4">
<div class="stat-title text-tertiary/70 text-xs font-medium">Masculino</div>
<div class="stat-value text-tertiary font-primary">{{ fmt(sexo.m) }}</div>
<div class="stat-desc text-tertiary/60">{{ pct(sexo.m, sexo.total) }}% del total</div>
</div>
</div>
</section>
<!-- Idiomas -->
<section>
<h3 class="text-xs font-bold text-tertiary uppercase tracking-wide flex items-center gap-2 mb-3">
<span class="inline-block w-1 h-3.5 bg-colorSecondary"></span>Idiomas
</h3>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
<div
v-for="lang in stats.idiomas"
:key="lang.value"
class="stat bg-colorPrimary ring-1 ring-colorSecondary/40 rounded-box p-4"
>
<div class="stat-title text-tertiary/70 text-xs font-medium">{{ lang.label }}</div>
<div class="stat-value text-tertiary font-primary text-2xl">{{ fmt(lang.count) }}</div>
<div class="stat-desc text-tertiary/60">{{ pct(lang.count, stats.total) }}% del total</div>
</div>
</div>
</section>
<!-- Distribuciones -->
<section>
<h3 class="text-xs font-bold text-tertiary uppercase tracking-wide flex items-center gap-2 mb-3">
<span class="inline-block w-1 h-3.5 bg-colorSecondary"></span>Distribuciones
</h3>
<div class="grid md:grid-cols-2 gap-4">
<div class="card bg-colorPrimary ring-1 ring-colorSecondary/40 rounded-box">
<div class="card-body p-4 sm:p-5">
<h4 class="card-title text-tertiary text-sm font-bold">Áreas de colaboración</h4>
<div class="space-y-2">
<div v-for="item in topAreas" :key="item.value" class="flex items-center gap-3">
<span class="w-32 sm:w-44 shrink-0 text-sm text-tertiary truncate" :title="item.label">{{ item.label }}</span>
<progress class="progress progress-secondary flex-1" :value="item.count" :max="areasMax"></progress>
<span class="w-12 shrink-0 text-right text-sm font-semibold text-tertiary">{{ fmt(item.count) }}</span>
</div>
</div>
</div>
</div>
<div class="card bg-colorPrimary ring-1 ring-colorSecondary/40 rounded-box">
<div class="card-body p-4 sm:p-5">
<h4 class="card-title text-tertiary text-sm font-bold">Días disponibles</h4>
<div class="space-y-2">
<div v-for="item in stats.dias" :key="item.value" class="flex items-center gap-3">
<span class="w-32 sm:w-44 shrink-0 text-sm text-tertiary truncate" :title="item.label">{{ item.label }}</span>
<progress class="progress progress-secondary flex-1" :value="item.count" :max="diasMax"></progress>
<span class="w-12 shrink-0 text-right text-sm font-semibold text-tertiary">{{ fmt(item.count) }}</span>
</div>
</div>
</div>
</div>
<div class="card bg-colorPrimary ring-1 ring-colorSecondary/40 rounded-box">
<div class="card-body p-4 sm:p-5">
<h4 class="card-title text-tertiary text-sm font-bold">Horario preferido</h4>
<div class="space-y-2">
<div v-for="item in stats.horarios" :key="item.value" class="flex items-center gap-3">
<span class="w-32 sm:w-44 shrink-0 text-sm text-tertiary truncate" :title="item.label">{{ item.label }}</span>
<progress class="progress progress-secondary flex-1" :value="item.count" :max="horariosMax"></progress>
<span class="w-12 shrink-0 text-right text-sm font-semibold text-tertiary">{{ fmt(item.count) }}</span>
</div>
</div>
</div>
</div>
<div class="card bg-colorPrimary ring-1 ring-colorSecondary/40 rounded-box">
<div class="card-body p-4 sm:p-5">
<h4 class="card-title text-tertiary text-sm font-bold">Países</h4>
<div class="space-y-2">
<div v-for="item in stats.paises" :key="item.value" class="flex items-center gap-3">
<span class="w-32 sm:w-44 shrink-0 text-sm text-tertiary truncate" :title="item.value">{{ item.value }}</span>
<progress class="progress progress-secondary flex-1" :value="item.count" :max="paisesMax"></progress>
<span class="w-12 shrink-0 text-right text-sm font-semibold text-tertiary">{{ fmt(item.count) }}</span>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Disponibilidad y salud -->
<section>
<h3 class="text-xs font-bold text-tertiary uppercase tracking-wide flex items-center gap-2 mb-3">
<span class="inline-block w-1 h-3.5 bg-colorSecondary"></span>Disponibilidad y salud
</h3>
<div class="card bg-colorPrimary ring-1 ring-colorSecondary/40 rounded-box">
<div class="card-body p-4 sm:p-6 grid md:grid-cols-2 gap-x-8 gap-y-5">
<div v-for="m in condMetrics" :key="m.title" class="space-y-1.5">
<div class="flex justify-between text-sm text-tertiary">
<span class="font-medium">{{ m.title }}</span>
<span> {{ m.pct }}% · No {{ 100 - m.pct }}%</span>
</div>
<progress class="progress progress-secondary w-full" :value="m.si" :max="m.total"></progress>
<p class="text-xs text-tertiary/60">
: {{ fmt(m.si) }} · No: {{ fmt(m.no) }}
</p>
</div>
<div class="space-y-1.5">
<div class="flex justify-between text-sm text-tertiary">
<span class="font-medium">Sexo</span>
<span>F {{ pct(sexo.f, sexo.total) }}% · M {{ pct(sexo.m, sexo.total) }}%</span>
</div>
<div class="flex w-full h-3 rounded-none overflow-hidden bg-tertiary/15">
<div class="bg-tertiary h-full" :style="{ width: pct(sexo.f, sexo.total) + '%' }"></div>
<div class="bg-colorSecondary h-full" :style="{ width: pct(sexo.m, sexo.total) + '%' }"></div>
</div>
<p class="text-xs text-tertiary/60">
F: {{ fmt(sexo.f) }} · M: {{ fmt(sexo.m) }}
</p>
</div>
</div>
</div>
</section>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from "vue";
const loading = ref(false);
const error = ref("");
const stats = ref(null);
const fmt = (n) => Number(n || 0).toLocaleString("es");
const pct = (n, total) => (total ? Math.round(((n || 0) / total) * 100) : 0);
const sexo = computed(() => {
const s = stats.value?.sexo || [];
const f = s.find((x) => x.value === "F")?.count || 0;
const m = s.find((x) => x.value === "M")?.count || 0;
return { f, m, total: f + m };
});
const topAreas = computed(() => (stats.value?.areas || []).slice(0, 8));
const areasMax = computed(() => topAreas.value[0]?.count || 1);
const diasMax = computed(() => stats.value?.dias[0]?.count || 1);
const horariosMax = computed(() => stats.value?.horarios[0]?.count || 1);
const paisesMax = computed(() => stats.value?.paises[0]?.count || 1);
const condMetrics = computed(() => {
const c = stats.value?.condiciones;
if (!c) return [];
const mk = (title, key) => {
const si = c[key].si || 0;
const no = c[key].no || 0;
const total = si + no;
return { title, si, no, total, pct: total ? Math.round((si / total) * 100) : 0 };
};
return [
mk("Disponible fuera de ciudad", "fuera_ciudad"),
mk("Misiones internacionales", "misiones_internacionales"),
mk("Condición médica", "condicion_medica"),
mk("Voluntariado anterior", "voluntariado_anterior"),
];
});
async function fetchStats() {
loading.value = true;
error.value = "";
try {
const res = await fetch("/api/admin/voluntarios/stats");
const json = await res.json();
if (!json.success) throw new Error(json.message || "Error al cargar estadísticas");
stats.value = json;
} catch (e) {
error.value = e.message;
} finally {
loading.value = false;
}
}
onMounted(fetchStats);
</script>

View File

@ -0,0 +1,178 @@
<template>
<div ref="rootEl" class="relative">
<div
class="combobox-input input input-sm flex flex-wrap items-center gap-1 w-full cursor-text"
@click="focusInput"
>
<template v-if="multiple">
<span
v-for="item in currentValue"
:key="item"
class="badge badge-sm badge-neutral gap-1"
>
{{ item }}
<button type="button" class="btn btn-ghost btn-xs btn-circle" @click.stop="toggle(item)"></button>
</span>
</template>
<input
ref="inputEl"
v-model="query"
type="text"
:placeholder="placeholder"
class="flex-1 min-w-[4rem]"
@input="onInput"
@focus="open = true"
/>
</div>
<ul
v-if="open"
class="absolute z-30 mt-1 w-full max-h-64 overflow-y-auto bg-base-100 rounded-box shadow ring-1 ring-base-300"
@mousedown.prevent
>
<li v-if="loading" class="px-3 py-2 text-sm text-base-content/50 flex items-center gap-2">
<span class="loading loading-spinner loading-xs"></span> Cargando
</li>
<li v-else-if="options.length === 0" class="px-3 py-2 text-sm text-base-content/50">
{{ multiple ? "Sin opciones" : "Sin sugerencias — sigue escribiendo" }}
</li>
<li v-for="opt in options" :key="opt.value">
<label
v-if="multiple"
class="flex items-center gap-2 px-3 py-1.5 hover:bg-base-200 cursor-pointer"
>
<input
type="checkbox"
class="checkbox checkbox-xs"
:checked="isSelected(opt.value)"
@change="toggle(opt.value)"
/>
<span class="flex-1 text-sm">
{{ opt.value }}
<span class="text-xs text-base-content/50">({{ opt.count }})</span>
</span>
</label>
<button
v-else
type="button"
class="flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-sm hover:bg-base-200"
@click="pick(opt.value)"
>
<span class="truncate">{{ opt.value }}</span>
<span class="text-xs text-base-content/50 shrink-0">({{ opt.count }})</span>
</button>
</li>
</ul>
</div>
</template>
<script setup>
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
const props = defineProps({
field: { type: String, required: true },
placeholder: { type: String, default: "Escribir…" },
multiple: { type: Boolean, default: false },
limit: { type: Number, default: 25 },
});
const modelValue = defineModel({ type: [String, Array], default: "" });
const rootEl = ref(null);
const inputEl = ref(null);
const query = ref("");
const open = ref(false);
const loading = ref(false);
const options = ref([]);
let fetchTimer = null;
let fetchVersion = 0;
const currentValue = () => {
const v = modelValue.value;
return Array.isArray(v) ? v : typeof v === "string" ? v : "";
};
function focusInput() {
inputEl.value?.focus();
}
function isSelected(v) {
return currentValue().includes(v);
}
function toggle(v) {
const arr = [...currentValue()];
const i = arr.indexOf(v);
if (i >= 0) arr.splice(i, 1);
else arr.push(v);
modelValue.value = arr;
query.value = "";
scheduleFetch();
}
function pick(v) {
modelValue.value = v;
query.value = v;
open.value = false;
}
function onInput() {
if (!props.multiple) modelValue.value = query.value;
scheduleFetch();
}
async function fetchOptions() {
loading.value = true;
const version = ++fetchVersion;
const params = new URLSearchParams();
params.set("field", props.field);
params.set("limit", String(props.limit));
if (query.value.trim().length >= 2) params.set("q", query.value.trim());
try {
const res = await fetch(`/api/admin/voluntarios/distinct?${params.toString()}`);
const json = await res.json();
if (version !== fetchVersion) return;
options.value = json.success ? json.data : [];
} catch {
if (version === fetchVersion) options.value = [];
} finally {
if (version === fetchVersion) loading.value = false;
}
}
function scheduleFetch() {
clearTimeout(fetchTimer);
fetchTimer = setTimeout(fetchOptions, 250);
}
watch(
() => modelValue.value,
(val) => {
if (!props.multiple && query.value !== val) query.value = typeof val === "string" ? val : "";
}
);
function onDocClick(e) {
if (rootEl.value && !rootEl.value.contains(e.target)) open.value = false;
}
onMounted(() => {
document.addEventListener("mousedown", onDocClick);
scheduleFetch();
});
onBeforeUnmount(() => {
document.removeEventListener("mousedown", onDocClick);
clearTimeout(fetchTimer);
});
</script>
<style scoped>
.combobox-input {
height: auto;
min-height: 2rem;
padding-top: 0.125rem;
padding-bottom: 0.125rem;
}
</style>

View File

@ -0,0 +1,284 @@
<template>
<div class="space-y-4">
<div v-if="loading" class="flex justify-center py-16">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
<div v-else-if="error" class="text-center py-10 text-error">{{ error }}</div>
<div v-else-if="data" class="space-y-6">
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
<h3 class="text-lg sm:text-xl font-bold">
{{ data.nombre }} {{ data.segundo_nombre }} {{ data.apellido }}
</h3>
<span class="font-mono text-sm opacity-70">#{{ data.numero_voluntario }}</span>
<span class="badge badge-sm" :class="badgeClass(data.status)">{{ data.status }}</span>
</div>
<section
v-for="sec in sectionsWithData"
:key="sec.key"
class="rounded-box ring-1 ring-base-300 bg-base-100/60 overflow-hidden"
>
<h4 class="font-medium text-sm uppercase tracking-wide px-4 py-3 border-b border-base-200">
{{ sec.title }}
</h4>
<dl class="divide-y divide-base-200/70 text-sm">
<div
v-for="f in sec.fields"
:key="f.key"
class="px-4 py-2.5 grid sm:grid-cols-[220px_1fr] gap-x-4 gap-y-1"
>
<dt class="text-base-content/60">{{ f.label }}</dt>
<dd class="min-w-0 break-words">
<div v-if="f.isList" :class="f.isLongList ? 'space-y-1.5' : 'flex flex-wrap gap-1.5'">
<div
v-if="f.isLongList"
v-for="(item, i) in f.displayValues"
:key="'list-' + i"
class="text-sm border-l-2 border-base-300 pl-2"
>
{{ item }}
</div>
<span
v-else
v-for="(item, i) in f.displayValues"
:key="'chip-' + i"
class="badge badge-sm badge-ghost border border-base-300 whitespace-normal text-left"
>
{{ item }}
</span>
</div>
<span v-else>{{ f.displayText }}</span>
</dd>
</div>
</dl>
</section>
<section
v-if="extraRows.length"
class="rounded-box ring-1 ring-base-300 bg-base-100/60 overflow-hidden"
>
<h4 class="font-medium text-sm uppercase tracking-wide px-4 py-3 border-b border-base-200">
Otros datos
</h4>
<dl class="divide-y divide-base-200/70 text-sm">
<div
v-for="r in extraRows"
:key="r.rawKey"
class="px-4 py-2.5 grid sm:grid-cols-[220px_1fr] gap-x-4 gap-y-1"
>
<dt class="text-base-content/60">{{ r.key }}</dt>
<dd class="min-w-0 break-words">{{ r.value }}</dd>
</div>
</dl>
</section>
<section class="rounded-box ring-1 ring-base-300 bg-base-100/60 overflow-hidden">
<h4 class="font-medium text-sm uppercase tracking-wide px-4 py-3 border-b border-base-200">
Sistema
</h4>
<dl class="divide-y divide-base-200/70 text-sm">
<div class="px-4 py-2.5 grid sm:grid-cols-[220px_1fr] gap-x-4 gap-y-1">
<dt class="text-base-content/60">ID</dt>
<dd class="font-mono break-all">{{ data.id }}</dd>
</div>
<div class="px-4 py-2.5 grid sm:grid-cols-[220px_1fr] gap-x-4 gap-y-1">
<dt class="text-base-content/60">Versión de formulario</dt>
<dd>{{ data.form_version || "—" }}</dd>
</div>
<div class="px-4 py-2.5 grid sm:grid-cols-[220px_1fr] gap-x-4 gap-y-1">
<dt class="text-base-content/60">Correo enviado</dt>
<dd>{{ formatDateTime(data.email_sent_at) }}</dd>
</div>
<div class="px-4 py-2.5 grid sm:grid-cols-[220px_1fr] gap-x-4 gap-y-1">
<dt class="text-base-content/60">Registrado</dt>
<dd>{{ formatDateTime(data.submitted_at) }}</dd>
</div>
<div class="px-4 py-2.5 grid sm:grid-cols-[220px_1fr] gap-x-4 gap-y-1">
<dt class="text-base-content/60">Actualizado</dt>
<dd>{{ formatDateTime(data.updated_at) }}</dd>
</div>
</dl>
</section>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from "vue";
const props = defineProps({
numero: { type: [Number, String], required: true },
});
const CORE_COLUMN_MAP = {
nombre: "nombre",
segundo_nombre: "segundo_nombre",
apellido: "apellido",
correo: "correo",
fecha_nacimiento: "fecha_nacimiento",
nacionalidad: "nacionalidad",
sexo: "sexo",
direccion_completa: "direccion",
ciudad: "ciudad",
estado: "estado",
pais: "pais",
postal: "codigo_postal",
telefono: "telefono",
whatsapp: "whatsapp",
profesion: "profesion",
lugar_trabajo_actual: "lugar_trabajo",
nivel_academico: "nivel_academico",
};
const loading = ref(false);
const error = ref("");
const data = ref(null);
const sections = ref([]);
function pretty(v) {
if (v === "si") return "Sí";
if (v === "no") return "No";
return v;
}
function fallbackLabel(key) {
if (key.startsWith("idioma_nivel_")) return `Nivel de ${key.slice("idioma_nivel_".length)}`;
if (key === "locale") return "Idioma de la solicitud";
return key;
}
const respuestasByKey = computed(() => {
const map = {};
for (const r of data.value?.respuestas || []) {
(map[r.key] ||= []).push(r.value);
}
return map;
});
function fieldTokens(field) {
let values;
if (field.key === "documentos") {
const s = [data.value.documento_tipo, data.value.documento_nro].filter(Boolean).join(" ");
values = s ? [s] : [];
} else if (CORE_COLUMN_MAP[field.key]) {
const v = data.value[CORE_COLUMN_MAP[field.key]];
values = v === null || v === undefined || v === "" ? [] : [String(v)];
} else {
values = respuestasByKey.value[field.key] || [];
}
const tokens = [];
for (const raw of values) {
for (const part of String(raw).split(",")) {
const tok = part.trim();
if (!tok) continue;
const opt = (field.options || []).find((o) => o.value === tok);
let display = (opt ? opt.label : pretty(tok)).replace(/<[^>]*>/g, "");
if (field.tipo === "date" || field.key === "fecha_nacimiento") {
const d = new Date(display);
if (!isNaN(d)) {
display = d.toLocaleDateString("es", { year: "numeric", month: "2-digit", day: "2-digit" });
}
}
tokens.push(display);
}
}
return tokens;
}
const sectionsWithData = computed(() => {
if (!data.value) return [];
return sections.value
.map((sec) => ({
...sec,
fields: sec.fields
.map((f) => {
const tokens = fieldTokens(f);
if (!tokens.length) return null;
return {
...f,
isList: f.tipo === "checkbox" && tokens.length > 1,
isLongList: f.tipo === "checkbox" && tokens.length > 1 && tokens.some((t) => t.length > 40),
displayValues: tokens,
displayText: tokens.join(", "),
};
})
.filter(Boolean),
}))
.filter((sec) => sec.fields.length);
});
const extraRows = computed(() => {
if (!data.value) return [];
const known = new Set();
for (const sec of sections.value) for (const f of sec.fields) known.add(f.key);
const rows = [];
for (const [key, vals] of Object.entries(respuestasByKey.value)) {
if (known.has(key)) continue;
const tokens = [];
for (const raw of vals) {
for (const part of String(raw).split(",")) {
const t = part.trim();
if (t) tokens.push(pretty(t));
}
}
if (!tokens.length) continue;
rows.push({ key: fallbackLabel(key), rawKey: key, value: tokens.join(", ") });
}
return rows;
});
async function fetchData() {
loading.value = true;
error.value = "";
try {
const res = await fetch(`/api/admin/voluntarios/${props.numero}`);
const json = await res.json();
if (!json.success) throw new Error(json.message || "Voluntario no encontrado");
data.value = json.data;
sections.value = json.form.sections;
} catch (e) {
error.value = e.message;
} finally {
loading.value = false;
}
}
watch(
() => props.numero,
() => {
data.value = null;
fetchData();
}
);
function formatDateTime(iso) {
if (!iso) return "—";
const d = new Date(iso);
if (isNaN(d)) return "—";
return d.toLocaleString("es", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
function badgeClass(s) {
switch (s) {
case "aprobado":
return "badge-success";
case "rechazado":
return "badge-error";
case "legacy":
return "badge-warning";
default:
return "badge-neutral";
}
}
onMounted(fetchData);
</script>

View File

@ -1,7 +1,7 @@
<template>
<div class="space-y-4">
<!-- Filtros -->
<div class="bg-base-200/60 p-4 rounded-box space-y-3">
<div class="bg-colorPrimary border-l-4 border-colorSecondary p-4 rounded-box space-y-3 shadow-lg shadow-tertiary/20">
<div class="flex flex-col md:flex-row gap-3 items-end">
<div class="flex-1 w-full">
<label class="label py-0 text-xs font-medium">Buscar</label>
@ -36,97 +36,76 @@
</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">
<button class="btn btn-ghost btn-sm text-tertiary" @click="clearFilters">Limpiar</button>
<button class="btn btn-primary btn-sm" @click="showFilters = !showFilters">
{{ showFilters ? "Ocultar filtros" : "Filtros" }}
<span v-if="activeFilterCount" class="badge badge-sm badge-primary">{{ activeFilterCount }}</span>
<span v-if="activeFilterCount" class="badge badge-sm bg-tertiary text-colorPrimary border-0">{{ 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>
<!-- Chips de filtros activos -->
<div v-if="activeChips.length" class="flex flex-wrap gap-1.5">
<span
v-for="chip in activeChips"
:key="chip.key"
class="badge badge-sm bg-white gap-1 border border-colorSecondary/60 text-tertiary"
>
{{ chip.label }}
<button type="button" class="opacity-60 hover:opacity-100" @click="chip.remove()"></button>
</span>
</div>
<!-- Panel de filtros -->
<div v-if="showFilters" class="border-t border-colorSecondary/40 pt-3 space-y-4">
<div>
<p class="text-xs font-bold text-tertiary mb-2 uppercase tracking-wide flex items-center gap-2">
<span class="inline-block w-1 h-3.5 bg-colorSecondary"></span>Fecha y edad
</p>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div>
<label class="label py-0 text-xs font-medium">Desde</label>
<input v-model="desde" type="date" class="input input-bordered input-sm w-full" @change="applyFilters" />
</div>
<div>
<label class="label py-0 text-xs font-medium">Hasta</label>
<input v-model="hasta" type="date" class="input input-bordered input-sm w-full" @change="applyFilters" />
</div>
<div>
<label class="label py-0 text-xs font-medium">Edad mín</label>
<input v-model="edadMin" type="number" min="0" max="120" class="input input-bordered input-sm w-full" @change="applyFilters" />
</div>
<div>
<label class="label py-0 text-xs font-medium">Edad máx</label>
<input v-model="edadMax" type="number" min="0" max="120" class="input input-bordered input-sm w-full" @change="applyFilters" />
</div>
</div>
</div>
<div>
<p class="text-xs font-medium text-base-content/70 mb-2">Datos personales</p>
<p class="text-xs font-bold text-tertiary mb-2 uppercase tracking-wide flex items-center gap-2">
<span class="inline-block w-1 h-3.5 bg-colorSecondary"></span>Ubicación
</p>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
<div>
<label class="label py-0 text-xs font-medium">Ciudad</label>
<input v-model="coreText.ciudad" type="text" class="input input-bordered input-sm w-full" @input="onSearch" />
<label class="label py-0 text-xs font-medium">País</label>
<SearchableCombobox
v-model="selectedPaises"
field="pais"
multiple
placeholder="Buscar país…"
@update:modelValue="applyFilters"
/>
</div>
<div>
<label class="label py-0 text-xs font-medium">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" />
<label class="label py-0 text-xs font-medium">Nacionalidad</label>
<SearchableCombobox
v-model="nacionalidad"
field="nacionalidad"
placeholder="Buscar…"
@update:modelValue="onSearch"
/>
</div>
<div>
<label class="label py-0 text-xs font-medium">Sexo</label>
@ -137,11 +116,52 @@
</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>
<label class="label py-0 text-xs font-medium">Ciudad</label>
<SearchableCombobox
v-model="coreText.ciudad"
field="ciudad"
placeholder="Buscar…"
@update:modelValue="onSearch"
/>
</div>
<div>
<label class="label py-0 text-xs font-medium">Estado</label>
<SearchableCombobox
v-model="coreText.estado"
field="estado"
placeholder="Buscar…"
@update:modelValue="onSearch"
/>
</div>
</div>
</div>
<div>
<p class="text-xs font-bold text-tertiary mb-2 uppercase tracking-wide flex items-center gap-2">
<span class="inline-block w-1 h-3.5 bg-colorSecondary"></span>Perfil
</p>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
<div>
<label class="label py-0 text-xs font-medium">Profesión</label>
<SearchableCombobox
v-model="coreText.profesion"
field="profesion"
placeholder="Buscar…"
@update:modelValue="onSearch"
/>
</div>
<div>
<label class="label py-0 text-xs font-medium">Nivel académico</label>
<SearchableCombobox
v-model="coreText.nivel_academico"
field="nivel_academico"
placeholder="Buscar…"
@update:modelValue="onSearch"
/>
</div>
<div>
<label class="label py-0 text-xs font-medium">Documento</label>
<input v-model="coreText.documento_nro" type="text" class="input input-bordered input-sm w-full" @input="onSearch" />
</div>
<div>
<label class="label py-0 text-xs font-medium">Versión de formulario</label>
@ -154,7 +174,9 @@
</div>
<div>
<p class="text-xs font-medium text-base-content/70 mb-2">Preferencias y condiciones</p>
<p class="text-xs font-bold text-tertiary mb-2 uppercase tracking-wide flex items-center gap-2">
<span class="inline-block w-1 h-3.5 bg-colorSecondary"></span>Preferencias y condiciones
</p>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
<div v-for="f in responseFields" :key="f">
<label class="label py-0 text-xs font-medium">{{ respLabels[f] }}</label>
@ -183,21 +205,22 @@
<th>Áreas</th>
<th>Status</th>
<th class="cursor-pointer select-none" @click="toggleSort('submitted_at')">Fecha</th>
<th class="w-20 text-right">Acción</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="10" class="text-center py-10">
<td colspan="11" class="text-center py-10">
<span class="loading loading-spinner loading-md text-primary"></span>
</td>
</tr>
<tr v-else-if="error">
<td colspan="10" class="text-center py-10 text-error">{{ error }}</td>
<td colspan="11" 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>
<td colspan="11" class="text-center py-10 text-base-content/40">Sin resultados</td>
</tr>
<tr v-for="row in rows" :key="row.id" class="hover:bg-base-200/50">
<tr v-for="row in rows" :key="row.id" class="hover:bg-base-200/50 cursor-pointer" @click="openDetail(row)">
<td class="font-mono">{{ row.numero_voluntario }}</td>
<td class="font-medium whitespace-nowrap">{{ row.nombre_completo }}</td>
<td class="max-w-[220px] truncate">{{ row.correo }}</td>
@ -210,6 +233,9 @@
<span class="badge badge-sm" :class="badgeClass(row.status)">{{ row.status || "pendiente" }}</span>
</td>
<td class="whitespace-nowrap">{{ formatDate(row.submitted_at) }}</td>
<td class="text-right">
<button class="btn btn-ghost btn-xs" title="Ver detalle" @click.stop="openDetail(row)">Ver</button>
</td>
</tr>
</tbody>
</table>
@ -217,7 +243,7 @@
<!-- Paginación -->
<div class="flex items-center justify-between flex-wrap gap-3">
<p class="text-sm text-base-content/60">
<p class="text-sm text-amber-200">
{{ total }} registros · página {{ page }} de {{ totalPages || 1 }}
</p>
<div class="join">
@ -234,11 +260,31 @@
<button class="join-item btn btn-sm" :disabled="page >= totalPages" @click="goPage(page + 1)">»</button>
</div>
</div>
<!-- Modal de detalle -->
<Teleport v-if="detailNumero !== null" to="body">
<div class="fixed inset-0 z-[60] overflow-y-auto">
<div class="fixed inset-0 bg-black/60" @click="closeDetail"></div>
<div class="relative min-h-full flex items-center justify-center p-4 sm:p-6">
<div class="w-full max-w-3xl max-h-[85vh] overflow-y-auto rounded-box bg-base-100 shadow-2xl ring-1 ring-base-300">
<div class="sticky top-0 z-10 flex items-center justify-between px-4 py-3 bg-base-100 border-b border-base-200">
<span class="text-sm font-semibold">Detalle del voluntario</span>
<button class="btn btn-ghost btn-sm" @click="closeDetail"></button>
</div>
<div class="p-4">
<VoluntarioDetail :numero="detailNumero" />
</div>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from "vue";
import { ref, reactive, computed, watch, onMounted, onUnmounted } from "vue";
import SearchableCombobox from "./SearchableCombobox.vue";
import VoluntarioDetail from "./VoluntarioDetail.vue";
const rows = ref([]);
const total = ref(0);
@ -248,6 +294,38 @@ const limit = 25;
const loading = ref(false);
const error = ref("");
const detailNumero = ref(null);
let returnPath = "";
function slugUrl(numero) {
const m = window.location.pathname.match(/^\/([a-z]{2})\/admin/);
const locale = m ? m[1] : "es";
return `/${locale}/admin/voluntario/${numero}`;
}
function openDetail(row) {
returnPath = window.location.pathname;
detailNumero.value = row.numero_voluntario;
history.pushState({ voluntario: true }, "", slugUrl(row.numero_voluntario));
}
function closeDetail() {
detailNumero.value = null;
history.replaceState(null, "", returnPath || window.location.pathname);
}
function onPopstate() {
if (detailNumero.value !== null) detailNumero.value = null;
}
function onKeydown(e) {
if (e.key === "Escape" && detailNumero.value !== null) closeDetail();
}
watch(detailNumero, (v) => {
document.body.style.overflow = v === null ? "" : "hidden";
});
const search = ref("");
const selectedStatuses = ref([]);
const selectedPaises = ref([]);
@ -259,8 +337,6 @@ const desde = ref("");
const hasta = ref("");
const edadMin = ref("");
const edadMax = ref("");
const hasWhatsapp = ref("");
const emailSent = ref("");
const coreText = reactive({
ciudad: "",
@ -313,9 +389,7 @@ const respFilterOptions = {
voluntariado_anterior: ["si", "no"],
};
const paises = ref([]);
const statuses = ref([]);
const nacionalidades = ref([]);
const formVersions = ref([]);
let searchTimer = null;
@ -323,22 +397,69 @@ 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;
const activeFilterCount = computed(() => activeChips.value.length);
const activeChips = computed(() => {
const chips = [];
const add = (key, label, remove) => chips.push({ key, label, remove });
if (search.value.trim()) add("q", `Búsqueda: ${search.value.trim()}`, () => {
search.value = "";
onSearch();
});
for (const s of selectedStatuses.value) add(`status-${s}`, `Status: ${s}`, () => {
selectedStatuses.value = selectedStatuses.value.filter((x) => x !== s);
applyFilters();
});
for (const p of selectedPaises.value) add(`pais-${p}`, `País: ${p}`, () => {
selectedPaises.value = selectedPaises.value.filter((x) => x !== p);
applyFilters();
});
if (desde.value) add("desde", `Desde: ${desde.value}`, () => {
desde.value = "";
applyFilters();
});
if (hasta.value) add("hasta", `Hasta: ${hasta.value}`, () => {
hasta.value = "";
applyFilters();
});
if (edadMin.value) add("edad_min", `Edad mín: ${edadMin.value}`, () => {
edadMin.value = "";
applyFilters();
});
if (edadMax.value) add("edad_max", `Edad máx: ${edadMax.value}`, () => {
edadMax.value = "";
applyFilters();
});
for (const [k, v] of Object.entries(coreText)) {
if (v) {
const label = { ciudad: "Ciudad", estado: "Estado", profesion: "Profesión", nivel_academico: "Nivel académico", documento_nro: "Documento" }[k];
add(`core-${k}`, `${label}: ${v}`, () => {
coreText[k] = "";
applyFilters();
});
}
}
if (sexo.value) add("sexo", `Sexo: ${sexo.value}`, () => {
sexo.value = "";
applyFilters();
});
if (nacionalidad.value) add("nacionalidad", `Nacionalidad: ${nacionalidad.value}`, () => {
nacionalidad.value = "";
applyFilters();
});
if (formVersion.value) add("form_version", `Versión: ${formVersion.value}`, () => {
formVersion.value = "";
applyFilters();
});
for (const f of responseFields) {
if (respFilters[f]) add(`resp-${f}`, `${respLabels[f]}: ${respFilters[f]}`, () => {
respFilters[f] = "";
applyFilters();
});
}
return chips;
});
async function fetchData() {
@ -356,9 +477,7 @@ async function fetchData() {
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]);
for (const [f, v] of Object.entries(coreText)) if (v) params.set(f, v);
if (sexo.value) params.set("sexo", sexo.value);
if (nacionalidad.value) params.set("nacionalidad", nacionalidad.value);
if (formVersion.value) params.set("form_version", formVersion.value);
@ -384,9 +503,7 @@ async function fetchOptions() {
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 {
@ -415,8 +532,6 @@ function clearFilters() {
hasta.value = "";
edadMin.value = "";
edadMax.value = "";
hasWhatsapp.value = "";
emailSent.value = "";
Object.keys(coreText).forEach((k) => (coreText[k] = ""));
sexo.value = "";
nacionalidad.value = "";
@ -478,5 +593,12 @@ function badgeClass(s) {
onMounted(() => {
fetchOptions();
fetchData();
window.addEventListener("popstate", onPopstate);
window.addEventListener("keydown", onKeydown);
});
onUnmounted(() => {
window.removeEventListener("popstate", onPopstate);
window.removeEventListener("keydown", onKeydown);
});
</script>

View File

@ -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) =>
);
---
<html lang={currentLocale} dir={direction} class="scroll-smooth">
<html lang={currentLocale} dir={direction} data-theme={theme ?? undefined} class="scroll-smooth">
<BaseHead
title={title}
description={description}

View File

@ -0,0 +1,30 @@
---
import MainLayout from "@/layouts/MainLayout.astro";
import Header from "@/components/Header.astro";
import AdminDashboard from "@/components/admin/AdminDashboard.vue";
const { locale } = Astro.params;
---
<MainLayout theme="cdrpj" title="Dashboard de Voluntarios" noindex>
<div class="top-16 relative mb container mx-auto">
<Header />
</div>
<main class="container mx-auto px-4 py-10 mt-8">
<div class="flex flex-col lg:w-1/2 items-center mx-auto mb-6">
<h1 class="text-white text-2xl uppercase font-bold text-center mb-3 font-primary">
Admin
</h1>
<h2 class="text-colorPrimary text-3xl lg:text-5xl font-bold text-center font-secondary">
Dashboard de Voluntarios
</h2>
</div>
<nav class="flex justify-center gap-2 mb-10">
<a href={`/${locale}/admin/dashboard`} class="btn btn-primary btn-sm">Dashboard</a>
<a href={`/${locale}/admin`} class="btn btn-ghost btn-sm text-colorPrimary">Voluntarios</a>
</nav>
<div class="max-w-5xl mx-auto">
<AdminDashboard client:load />
</div>
</main>
</MainLayout>

View File

@ -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;
---
<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>
<MainLayout theme="cdrpj" title="Dashboard de Voluntarios" noindex>
<div class="top-16 relative mb container mx-auto">
<Header />
</div>
<main class="container mx-auto px-4 py-10 mt-8">
<div class="flex flex-col lg:w-1/2 items-center mx-auto mb-6">
<h1 class="text-white text-2xl uppercase font-bold text-center mb-3 font-primary">
Admin
</h1>
<h2 class="text-colorPrimary text-3xl lg:text-5xl font-bold text-center font-secondary">
Dashboard de Voluntarios
</h2>
</div>
<nav class="flex justify-center gap-2 mb-10">
<a href={`/${locale}/admin/dashboard`} class="btn btn-ghost btn-sm text-colorPrimary">Dashboard</a>
<a href={`/${locale}/admin`} class="btn btn-primary btn-sm">Voluntarios</a>
</nav>
<VoluntariosTable client:load />
</main>
</MainLayout>

View File

@ -0,0 +1,26 @@
---
import MainLayout from "@/layouts/MainLayout.astro";
import Header from "@/components/Header.astro";
import VoluntarioDetail from "@/components/admin/VoluntarioDetail.vue";
const { numero } = Astro.params;
---
<MainLayout theme="cdrpj" title="Detalle de Voluntario" noindex>
<div class="top-16 relative mb container mx-auto">
<Header />
</div>
<main class="container mx-auto px-4 py-10 mt-8">
<div class="flex flex-col lg:w-1/2 items-center mx-auto mb-10">
<h1 class="text-white text-2xl uppercase font-bold text-center mb-3 font-primary">
Admin
</h1>
<h2 class="text-colorPrimary text-3xl lg:text-5xl font-bold text-center font-secondary">
Detalle de Voluntario
</h2>
</div>
<div class="max-w-3xl mx-auto">
<VoluntarioDetail numero={numero} client:load />
</div>
</main>
</MainLayout>

View File

@ -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<string[]> {
const col = Prisma.raw(field);
const rows = await prisma.$queryRaw<{ id: string }[]>`
SELECT id::text AS id
FROM formularios
WHERE ${col} IS NOT NULL
AND ${col} <> ''
AND length(${col}) >= 2
AND (
similarity(lower(${col}), lower(${value})) > ${FUZZY_THRESHOLD}
OR ${col} ILIKE ${`%${value}%`}
)
LIMIT 5000
`;
return rows.map((r) => r.id);
}
export const GET: APIRoute = async ({ url }) => {
try {
const sp = url.searchParams;
@ -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([

View File

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

View File

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

View File

@ -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<string, Record<string, string>> = {};
for (const c of campos) {
optionMaps[c.key] = {};
if (Array.isArray(c.options)) {
for (const o of c.options as { value?: string; label?: string }[]) {
if (o && o.value) {
optionMaps[c.key][o.value] = o.label ? t("es", o.label as I18nKey) : o.value;
}
}
}
}
const withLabels = (
dist: { value: string; count: number }[],
key: string
): { value: string; label: string; count: number }[] =>
dist.map((d) => ({
value: d.value,
label: optionMaps[key]?.[d.value] || d.value,
count: d.count,
}));
const [total, statuses, sexo, paises, [idiomas, areas, dias, horarios], condiciones, edad] =
await Promise.all([
prisma.formulario.count(),
prisma.formulario.groupBy({ by: ["status"], _count: { _all: true } }),
prisma.formulario.groupBy({ by: ["sexo"], _count: { _all: true } }),
prisma.formulario.groupBy({
by: ["pais"],
_count: { _all: true },
where: { pais: { not: null } },
orderBy: { _count: { pais: "desc" } },
take: 12,
}),
Promise.all([
multiValueDist("idioma"),
multiValueDist("areas_colaborar"),
multiValueDist("dias_disponibles"),
multiValueDist("horario_preferido"),
]),
Promise.all(
["fuera_ciudad", "misiones_internacionales", "condicion_medica", "voluntariado_anterior"].map(
(k) => valueDist(k)
)
),
prisma.$queryRaw<{ promedio: number | null }[]>`
SELECT round(avg(extract(epoch from (now() - fecha_nacimiento)) / 31557600), 1) AS promedio
FROM formularios
WHERE fecha_nacimiento IS NOT NULL
`,
]);
const condKeys = [
"fuera_ciudad",
"misiones_internacionales",
"condicion_medica",
"voluntariado_anterior",
] as const;
const condicionesObj: Record<string, Record<string, number>> = {};
condKeys.forEach((k, i) => {
condicionesObj[k] = Object.fromEntries(condiciones[i].map((c) => [c.value, c.count]));
});
const formatPais = paises
.filter((p) => p.pais !== null)
.map((p) => ({ value: p.pais!, count: p._count._all }));
return Response.json({
success: true,
total,
edad: { promedio: edad[0]?.promedio ?? null },
idiomas: withLabels(idiomas, "idioma"),
areas: withLabels(areas, "areas_colaborar"),
dias: withLabels(dias, "dias_disponibles"),
horarios: withLabels(horarios, "horario_preferido"),
sexo: withLabels(
sexo.filter((s) => s.sexo !== null).map((s) => ({ value: s.sexo!, count: s._count._all })),
"sexo"
),
paises: formatPais,
statuses: statuses.map((s) => ({ value: s.status, count: s._count._all })),
condiciones: condicionesObj,
});
} catch (error) {
console.error("Error en /api/admin/voluntarios/stats:", error);
return Response.json(
{ success: false, message: "Error al obtener estadísticas" },
{ status: 500 }
);
}
};

View File

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

View File

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

View File

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

View File

@ -35,6 +35,16 @@ function isBlank(v: unknown): boolean {
return v === undefined || v === null || v === "";
}
export async function emailExistsInDb(email: string): Promise<boolean> {
const normalized = (email || "").trim().toLowerCase();
if (!normalized) return false;
const found = await prisma.formulario.findFirst({
where: { correo: { equals: normalized, mode: "insensitive" } },
select: { id: true },
});
return Boolean(found);
}
export async function saveFormularioToSupabase(
formData: FormData,
responses: RespuestaPayload[]

View File

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

View File

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