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:
2026-05-29 00:53:38 -05:00
parent cb79ad6d45
commit 6aecbd37d2
6 changed files with 297 additions and 4 deletions

View File

@@ -104,6 +104,7 @@
{ title: 'Cuadres de tarro', route: '/cuadres_de_tarro', icon: 'mdi-chart-bar'},
{ title: 'CSV Tryton', route: '/ventas_para_tryton', icon: 'mdi-file-table'},
{ title: 'Compra adm', route: '/compra_admin', icon: 'mdi-cart'},
{ title: 'Gestión de Productos', route: '/admin/products', icon: 'mdi-package-variant'},
{ title: 'Actualizar Productos De Tryton', route: '/sincronizar_productos_tryton', icon: 'trytonIcon'},
{ title: 'Actualizar Clientes De Tryton', route: '/sincronizar_clientes_tryton', icon: 'trytonIcon'},
{ title: 'Actualizar Ventas Tryton', route: '/sincronizar_ventas_tryton', icon: 'trytonIcon'}

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

View File

@@ -0,0 +1,10 @@
<template>
<ProductsManagement v-if="authStore.isAdmin"/>
</template>
<script setup>
import { useAuthStore } from '@/stores/auth';
import ProductsManagement from '@/components/ProductsManagement.vue';
const authStore = useAuthStore();
</script>

View File

@@ -19,6 +19,7 @@ const ADMIN_ROUTES = [
'/cuadres_de_tarro',
'/compra_admin',
'/cuadrar_tarro',
'/admin/products',
]
const router = createRouter({

View File

@@ -7,8 +7,12 @@ class Api {
return this.apiImplementation.getCustomers();
}
getProducts() {
return this.apiImplementation.getProducts();
getProducts(active = 'all') {
return this.apiImplementation.getProducts(active);
}
updateProduct(productId, data) {
return this.apiImplementation.updateProduct(productId, data);
}
getPaymentMethods() {

View File

@@ -14,16 +14,31 @@ class DjangoApi {
return http.post(url, payload).then((r) => r.data);
}
patchRequest(url, payload) {
return http.patch(url, payload).then((r) => r.data);
}
getCustomers() {
const url = this.base + "/don_confiao/api/customers/";
return this.getRequest(url);
}
getProducts() {
const url = this.base + "/don_confiao/api/products/";
getProducts(active = 'all') {
let url = this.base + "/don_confiao/api/products/";
// Agregar query parameter según filtro
if (active !== 'all') {
url += `?active=${active}`;
}
return this.getRequest(url);
}
updateProduct(productId, data) {
const url = this.base + `/don_confiao/api/products/${productId}/`;
return this.patchRequest(url, data);
}
getPaymentMethods() {
const url =
this.base + "/don_confiao/payment_methods/all/select_format";