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:
@@ -104,6 +104,7 @@
|
|||||||
{ title: 'Cuadres de tarro', route: '/cuadres_de_tarro', icon: 'mdi-chart-bar'},
|
{ title: 'Cuadres de tarro', route: '/cuadres_de_tarro', icon: 'mdi-chart-bar'},
|
||||||
{ title: 'CSV Tryton', route: '/ventas_para_tryton', icon: 'mdi-file-table'},
|
{ title: 'CSV Tryton', route: '/ventas_para_tryton', icon: 'mdi-file-table'},
|
||||||
{ title: 'Compra adm', route: '/compra_admin', icon: 'mdi-cart'},
|
{ 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 Productos De Tryton', route: '/sincronizar_productos_tryton', icon: 'trytonIcon'},
|
||||||
{ title: 'Actualizar Clientes De Tryton', route: '/sincronizar_clientes_tryton', icon: 'trytonIcon'},
|
{ title: 'Actualizar Clientes De Tryton', route: '/sincronizar_clientes_tryton', icon: 'trytonIcon'},
|
||||||
{ title: 'Actualizar Ventas Tryton', route: '/sincronizar_ventas_tryton', icon: 'trytonIcon'}
|
{ title: 'Actualizar Ventas Tryton', route: '/sincronizar_ventas_tryton', icon: 'trytonIcon'}
|
||||||
|
|||||||
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>
|
||||||
10
src/pages/admin/products.vue
Normal file
10
src/pages/admin/products.vue
Normal 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>
|
||||||
@@ -19,6 +19,7 @@ const ADMIN_ROUTES = [
|
|||||||
'/cuadres_de_tarro',
|
'/cuadres_de_tarro',
|
||||||
'/compra_admin',
|
'/compra_admin',
|
||||||
'/cuadrar_tarro',
|
'/cuadrar_tarro',
|
||||||
|
'/admin/products',
|
||||||
]
|
]
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
|
|||||||
@@ -7,8 +7,12 @@ class Api {
|
|||||||
return this.apiImplementation.getCustomers();
|
return this.apiImplementation.getCustomers();
|
||||||
}
|
}
|
||||||
|
|
||||||
getProducts() {
|
getProducts(active = 'all') {
|
||||||
return this.apiImplementation.getProducts();
|
return this.apiImplementation.getProducts(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProduct(productId, data) {
|
||||||
|
return this.apiImplementation.updateProduct(productId, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
getPaymentMethods() {
|
getPaymentMethods() {
|
||||||
|
|||||||
@@ -14,16 +14,31 @@ class DjangoApi {
|
|||||||
return http.post(url, payload).then((r) => r.data);
|
return http.post(url, payload).then((r) => r.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
patchRequest(url, payload) {
|
||||||
|
return http.patch(url, payload).then((r) => r.data);
|
||||||
|
}
|
||||||
|
|
||||||
getCustomers() {
|
getCustomers() {
|
||||||
const url = this.base + "/don_confiao/api/customers/";
|
const url = this.base + "/don_confiao/api/customers/";
|
||||||
return this.getRequest(url);
|
return this.getRequest(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
getProducts() {
|
getProducts(active = 'all') {
|
||||||
const url = this.base + "/don_confiao/api/products/";
|
let url = this.base + "/don_confiao/api/products/";
|
||||||
|
|
||||||
|
// Agregar query parameter según filtro
|
||||||
|
if (active !== 'all') {
|
||||||
|
url += `?active=${active}`;
|
||||||
|
}
|
||||||
|
|
||||||
return this.getRequest(url);
|
return this.getRequest(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateProduct(productId, data) {
|
||||||
|
const url = this.base + `/don_confiao/api/products/${productId}/`;
|
||||||
|
return this.patchRequest(url, data);
|
||||||
|
}
|
||||||
|
|
||||||
getPaymentMethods() {
|
getPaymentMethods() {
|
||||||
const url =
|
const url =
|
||||||
this.base + "/don_confiao/payment_methods/all/select_format";
|
this.base + "/don_confiao/payment_methods/all/select_format";
|
||||||
|
|||||||
Reference in New Issue
Block a user