605 lines
21 KiB
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>
|