cdrdpyj/src/components/admin/VoluntariosTable.vue

605 lines
21 KiB
Vue

<template>
<div class="space-y-4">
<!-- Filtros -->
<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>
<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="flex gap-2">
<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 bg-tertiary text-colorPrimary border-0">{{ activeFilterCount }}</span>
</button>
</div>
</div>
<!-- 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-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">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">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>
<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">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>
<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-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>
<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>
<th class="w-20 text-right">Acción</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<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="11" class="text-center py-10 text-error">{{ error }}</td>
</tr>
<tr v-else-if="rows.length === 0">
<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 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>
<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>
<td class="text-right">
<button class="btn btn-ghost btn-xs" title="Ver detalle" @click.stop="openDetail(row)">Ver</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Paginación -->
<div class="flex items-center justify-between flex-wrap gap-3">
<p class="text-sm text-amber-200">
{{ 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>
<!-- 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, watch, onMounted, onUnmounted } from "vue";
import SearchableCombobox from "./SearchableCombobox.vue";
import VoluntarioDetail from "./VoluntarioDetail.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 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([]);
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 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 statuses = ref([]);
const formVersions = ref([]);
let searchTimer = null;
const statusLabel = computed(() =>
selectedStatuses.value.length ? selectedStatuses.value.join(", ") : "Todos"
);
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() {
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);
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);
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) {
statuses.value = json.statuses;
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 = "";
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();
window.addEventListener("popstate", onPopstate);
window.addEventListener("keydown", onKeydown);
});
onUnmounted(() => {
window.removeEventListener("popstate", onPopstate);
window.removeEventListener("keydown", onKeydown);
});
</script>