feat: agregar página de gestión de productos activos/inactivos
- Agregar ProductsManagement.vue con tabla de productos - Filtros: Activos, Inactivos, Todos - Búsqueda por nombre en tiempo real - Acciones por lote: activar/desactivar múltiples productos - Botones condicionales según filtro activo - Agregar ruta protegida /admin/products - Actualizar API con métodos getProducts(active) y updateProduct() - Agregar método patchRequest en django-api - Agregar menú 'Gestión de Productos' en NavBar
This commit is contained in:
262
src/components/ProductsManagement.vue
Normal file
262
src/components/ProductsManagement.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<v-container fluid>
|
||||
<!-- Header + Filtros -->
|
||||
<v-row align="center">
|
||||
<v-col cols="12" md="6">
|
||||
<h1 class="text-h4">Gestión de Productos</h1>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6" class="text-md-right">
|
||||
<!-- Chips de filtro -->
|
||||
<v-chip-group v-model="activeFilter" mandatory color="primary">
|
||||
<v-chip value="false">Inactivos</v-chip>
|
||||
<v-chip value="true">Activos</v-chip>
|
||||
<v-chip value="all">Todos</v-chip>
|
||||
</v-chip-group>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Barra de búsqueda -->
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="searchQuery"
|
||||
label="Buscar por nombre"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
clearable
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Barra de acciones por lote -->
|
||||
<v-row v-if="selected.length > 0">
|
||||
<v-col cols="12">
|
||||
<v-alert type="info" variant="tonal" border="start">
|
||||
<v-row align="center">
|
||||
<v-col cols="12" md="6">
|
||||
<strong>{{ selected.length }} producto(s) seleccionado(s)</strong>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6" class="text-md-right">
|
||||
<!-- Botón Activar: solo visible en filtro "Inactivos" -->
|
||||
<v-btn
|
||||
v-if="activeFilter === 'false'"
|
||||
@click="activateSelected"
|
||||
color="success"
|
||||
variant="elevated"
|
||||
prepend-icon="mdi-check-circle"
|
||||
:disabled="loading"
|
||||
>
|
||||
Activar seleccionados
|
||||
</v-btn>
|
||||
|
||||
<!-- Botón Desactivar: solo visible en filtro "Activos" -->
|
||||
<v-btn
|
||||
v-if="activeFilter === 'true'"
|
||||
@click="deactivateSelected"
|
||||
color="error"
|
||||
variant="elevated"
|
||||
prepend-icon="mdi-close-circle"
|
||||
:disabled="loading"
|
||||
>
|
||||
Desactivar seleccionados
|
||||
</v-btn>
|
||||
|
||||
<!-- Ambos botones visibles en filtro "Todos" -->
|
||||
<template v-if="activeFilter === 'all'">
|
||||
<v-btn
|
||||
@click="activateSelected"
|
||||
color="success"
|
||||
variant="elevated"
|
||||
prepend-icon="mdi-check-circle"
|
||||
class="mr-2"
|
||||
:disabled="loading"
|
||||
>
|
||||
Activar seleccionados
|
||||
</v-btn>
|
||||
<v-btn
|
||||
@click="deactivateSelected"
|
||||
color="error"
|
||||
variant="elevated"
|
||||
prepend-icon="mdi-close-circle"
|
||||
:disabled="loading"
|
||||
>
|
||||
Desactivar seleccionados
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-alert>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Tabla de productos -->
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-card>
|
||||
<v-data-table
|
||||
v-model="selected"
|
||||
:headers="headers"
|
||||
:items="filteredProducts"
|
||||
:loading="loading"
|
||||
show-select
|
||||
density="compact"
|
||||
item-value="id"
|
||||
items-per-page="25"
|
||||
:items-per-page-options="[10, 25, 50, 100]"
|
||||
>
|
||||
<!-- Slot para columna de precio (formato) -->
|
||||
<template #item.price="{ item }">
|
||||
${{ Number(item.price).toLocaleString("es-CO") }}
|
||||
</template>
|
||||
|
||||
<!-- Slot para columna de estado -->
|
||||
<template #item.active="{ item }">
|
||||
<v-chip
|
||||
:color="item.active ? 'success' : 'error'"
|
||||
size="small"
|
||||
variant="flat"
|
||||
>
|
||||
{{ item.active ? "Activo" : "Inactivo" }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<!-- Loading state -->
|
||||
<template #loading>
|
||||
<v-skeleton-loader type="table-row@10"></v-skeleton-loader>
|
||||
</template>
|
||||
|
||||
<!-- No data state -->
|
||||
<template #no-data>
|
||||
<v-alert type="info" variant="tonal" class="my-4">
|
||||
No hay productos para mostrar
|
||||
</v-alert>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Snackbar de feedback -->
|
||||
<v-snackbar
|
||||
v-model="snackbar.show"
|
||||
:color="snackbar.color"
|
||||
:timeout="3000"
|
||||
location="top"
|
||||
>
|
||||
{{ snackbar.message }}
|
||||
<template #actions>
|
||||
<v-btn variant="text" @click="snackbar.show = false"> Cerrar </v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, inject, onMounted, computed } from "vue";
|
||||
|
||||
// Estado
|
||||
const api = inject("api");
|
||||
const activeFilter = ref("all");
|
||||
const products = ref([]);
|
||||
const selected = ref([]);
|
||||
const loading = ref(false);
|
||||
const snackbar = ref({ show: false, message: "", color: "success" });
|
||||
const searchQuery = ref("");
|
||||
|
||||
// Headers de la tabla
|
||||
const headers = [
|
||||
{ title: "ID", key: "id", sortable: true },
|
||||
{ title: "Nombre", key: "name", sortable: true },
|
||||
{ title: "Precio", key: "price", sortable: true },
|
||||
{ title: "Estado", key: "active", sortable: true },
|
||||
];
|
||||
|
||||
// Computed - Productos filtrados por búsqueda
|
||||
const filteredProducts = computed(() => {
|
||||
if (!searchQuery.value) {
|
||||
return products.value;
|
||||
}
|
||||
|
||||
const query = searchQuery.value.toLowerCase().trim();
|
||||
return products.value.filter(product =>
|
||||
product.name.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
// Métodos
|
||||
async function loadProducts() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await api.getProducts(activeFilter.value);
|
||||
products.value = data;
|
||||
} catch (error) {
|
||||
console.error("Error al cargar productos:", error);
|
||||
showSnackbar("Error al cargar productos", "error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function activateSelected() {
|
||||
await updateSelectedStatus(true);
|
||||
}
|
||||
|
||||
async function deactivateSelected() {
|
||||
await updateSelectedStatus(false);
|
||||
}
|
||||
|
||||
async function updateSelectedStatus(active) {
|
||||
loading.value = true;
|
||||
try {
|
||||
// Actualizar productos en paralelo
|
||||
await Promise.all(
|
||||
selected.value.map((id) => api.updateProduct(id, { active })),
|
||||
);
|
||||
|
||||
const action = active ? "activado(s)" : "desactivado(s)";
|
||||
showSnackbar(
|
||||
`${selected.value.length} producto(s) ${action} exitosamente`,
|
||||
"success",
|
||||
);
|
||||
|
||||
// Limpiar selección y recargar
|
||||
selected.value = [];
|
||||
await loadProducts();
|
||||
} catch (error) {
|
||||
console.error("Error al actualizar productos:", error);
|
||||
showSnackbar("Error al actualizar productos", "error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showSnackbar(message, color) {
|
||||
snackbar.value = { show: true, message, color };
|
||||
}
|
||||
|
||||
// Watchers
|
||||
watch(activeFilter, () => {
|
||||
selected.value = [];
|
||||
searchQuery.value = "";
|
||||
loadProducts();
|
||||
});
|
||||
|
||||
// Inicialización
|
||||
onMounted(() => {
|
||||
loadProducts();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-md-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.text-md-right {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user