Merge pull request '#40 feat: add catalogue images CRUD and display in product catalog' (#43) from feature/40-catalogue-images into main
Reviewed-on: #43
This commit is contained in:
383
src/components/CatalogueImagesManagement.vue
Normal file
383
src/components/CatalogueImagesManagement.vue
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
<template>
|
||||||
|
<v-container fluid>
|
||||||
|
<v-row align="center">
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<h1 class="text-h4">Imágenes de Catálogo</h1>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6" class="text-md-right">
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
variant="elevated"
|
||||||
|
prepend-icon="mdi-plus"
|
||||||
|
@click="openCreateDialog"
|
||||||
|
>
|
||||||
|
Agregar Imagen
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-card>
|
||||||
|
<v-data-table
|
||||||
|
:headers="headers"
|
||||||
|
:items="images"
|
||||||
|
:loading="loading"
|
||||||
|
density="compact"
|
||||||
|
item-value="id"
|
||||||
|
items-per-page="25"
|
||||||
|
:items-per-page-options="[10, 25, 50, 100]"
|
||||||
|
>
|
||||||
|
<template #item.image="{ item }">
|
||||||
|
<v-img
|
||||||
|
:src="item.image"
|
||||||
|
max-width="60"
|
||||||
|
aspect-ratio="1"
|
||||||
|
cover
|
||||||
|
class="rounded"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.product="{ item }">
|
||||||
|
{{ productMap[item.product] || `ID: ${item.product}` }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.uploaded_at="{ item }">
|
||||||
|
{{ formatDate(item.uploaded_at) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.actions="{ item }">
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="openEditDialog(item)"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-pencil</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
color="error"
|
||||||
|
@click="openDeleteDialog(item)"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-delete</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #loading>
|
||||||
|
<v-skeleton-loader type="table-row@10" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #no-data>
|
||||||
|
<v-alert type="info" variant="tonal" class="my-4">
|
||||||
|
No hay imágenes de catálogo
|
||||||
|
</v-alert>
|
||||||
|
</template>
|
||||||
|
</v-data-table>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-dialog v-model="dialog.show" max-width="500" persistent>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>
|
||||||
|
{{ dialog.isEdit ? 'Editar' : 'Agregar' }} Imagen de Catálogo
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-form ref="formRef">
|
||||||
|
<v-select
|
||||||
|
v-model="form.product"
|
||||||
|
:items="productOptions"
|
||||||
|
label="Producto"
|
||||||
|
:rules="[(v) => !!v || 'Seleccione un producto']"
|
||||||
|
item-title="name"
|
||||||
|
item-value="id"
|
||||||
|
variant="outlined"
|
||||||
|
:disabled="dialog.isEdit"
|
||||||
|
class="mb-3"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-file-input
|
||||||
|
ref="fileInputRef"
|
||||||
|
label="Imagen"
|
||||||
|
accept="image/*"
|
||||||
|
:rules="[(v) => !!v || 'Seleccione una imagen']"
|
||||||
|
variant="outlined"
|
||||||
|
prepend-icon="mdi-camera"
|
||||||
|
class="mb-3"
|
||||||
|
@update:model-value="onFileSelected"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-img
|
||||||
|
v-if="form.preview"
|
||||||
|
:src="form.preview"
|
||||||
|
max-height="200"
|
||||||
|
contain
|
||||||
|
class="mb-3 rounded"
|
||||||
|
/>
|
||||||
|
</v-form>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="closeDialog">Cancelar</v-btn>
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
variant="elevated"
|
||||||
|
:loading="submitting"
|
||||||
|
:disabled="submitting"
|
||||||
|
@click="submitForm"
|
||||||
|
>
|
||||||
|
{{ dialog.isEdit ? 'Actualizar' : 'Agregar' }}
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<v-dialog v-model="deleteDialog.show" max-width="400">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>Confirmar Eliminación</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
¿Está seguro de eliminar esta imagen de catálogo?
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="deleteDialog.show = false">
|
||||||
|
Cancelar
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
color="error"
|
||||||
|
variant="elevated"
|
||||||
|
:loading="deleting"
|
||||||
|
:disabled="deleting"
|
||||||
|
@click="confirmDelete"
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<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, inject, onMounted, computed, watch } from "vue";
|
||||||
|
|
||||||
|
const api = inject("api");
|
||||||
|
|
||||||
|
const images = ref([]);
|
||||||
|
const products = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const submitting = ref(false);
|
||||||
|
const deleting = ref(false);
|
||||||
|
const formRef = ref(null);
|
||||||
|
|
||||||
|
const snackbar = ref({ show: false, message: "", color: "success" });
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
{ title: "ID", key: "id", sortable: true },
|
||||||
|
{ title: "Producto", key: "product", sortable: true },
|
||||||
|
{ title: "Imagen", key: "image", sortable: false },
|
||||||
|
{ title: "Subida el", key: "uploaded_at", sortable: true },
|
||||||
|
{ title: "Acciones", key: "actions", sortable: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const dialog = ref({
|
||||||
|
show: false,
|
||||||
|
isEdit: false,
|
||||||
|
editingItem: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileInputRef = ref(null);
|
||||||
|
const selectedFile = ref(null);
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
product: null,
|
||||||
|
preview: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteDialog = ref({
|
||||||
|
show: false,
|
||||||
|
item: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
let previewObjectUrl = null;
|
||||||
|
|
||||||
|
const productMap = computed(() => {
|
||||||
|
const map = {};
|
||||||
|
for (const p of products.value) {
|
||||||
|
map[p.id] = p.name;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
const productOptions = computed(() => {
|
||||||
|
return products.value.map((p) => ({
|
||||||
|
name: p.name,
|
||||||
|
id: p.id,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
selectedFile,
|
||||||
|
(file) => {
|
||||||
|
if (previewObjectUrl) {
|
||||||
|
URL.revokeObjectURL(previewObjectUrl);
|
||||||
|
previewObjectUrl = null;
|
||||||
|
}
|
||||||
|
if (file) {
|
||||||
|
previewObjectUrl = URL.createObjectURL(file);
|
||||||
|
form.value.preview = previewObjectUrl;
|
||||||
|
} else if (dialog.value.isEdit && dialog.value.editingItem) {
|
||||||
|
form.value.preview = dialog.value.editingItem.image;
|
||||||
|
} else {
|
||||||
|
form.value.preview = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function onFileSelected(file) {
|
||||||
|
selectedFile.value = file;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
if (!dateStr) return "-";
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleString("es-CO");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadImages() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const data = await api.getCatalogueImages();
|
||||||
|
images.value = data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error al cargar imágenes:", error);
|
||||||
|
showSnackbar("Error al cargar imágenes", "error");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProducts() {
|
||||||
|
try {
|
||||||
|
const data = await api.getProducts("all");
|
||||||
|
products.value = data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error al cargar productos:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
dialog.value = { show: true, isEdit: false, editingItem: null };
|
||||||
|
selectedFile.value = null;
|
||||||
|
form.value = { product: null, preview: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(item) {
|
||||||
|
dialog.value = { show: true, isEdit: true, editingItem: item };
|
||||||
|
selectedFile.value = null;
|
||||||
|
form.value = {
|
||||||
|
product: item.product,
|
||||||
|
preview: item.image,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDialog() {
|
||||||
|
if (previewObjectUrl) {
|
||||||
|
URL.revokeObjectURL(previewObjectUrl);
|
||||||
|
previewObjectUrl = null;
|
||||||
|
}
|
||||||
|
selectedFile.value = null;
|
||||||
|
dialog.value.show = false;
|
||||||
|
dialog.value.isEdit = false;
|
||||||
|
dialog.value.editingItem = null;
|
||||||
|
form.value = { product: null, preview: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitForm() {
|
||||||
|
const formComponent = formRef.value;
|
||||||
|
if (formComponent) {
|
||||||
|
const { valid } = await formComponent.validate();
|
||||||
|
if (!valid) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("product", form.value.product);
|
||||||
|
if (selectedFile.value) {
|
||||||
|
fd.append("image", selectedFile.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dialog.value.isEdit) {
|
||||||
|
await api.updateCatalogueImage(dialog.value.editingItem.id, fd);
|
||||||
|
showSnackbar("Imagen actualizada exitosamente", "success");
|
||||||
|
} else {
|
||||||
|
await api.createCatalogueImage(fd);
|
||||||
|
showSnackbar("Imagen agregada exitosamente", "success");
|
||||||
|
}
|
||||||
|
|
||||||
|
closeDialog();
|
||||||
|
await loadImages();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error al guardar imagen:", error);
|
||||||
|
showSnackbar("Error al guardar imagen", "error");
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeleteDialog(item) {
|
||||||
|
deleteDialog.value = { show: true, item };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
deleting.value = true;
|
||||||
|
try {
|
||||||
|
await api.deleteCatalogueImage(deleteDialog.value.item.id);
|
||||||
|
showSnackbar("Imagen eliminada exitosamente", "success");
|
||||||
|
deleteDialog.value.show = false;
|
||||||
|
await loadImages();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error al eliminar imagen:", error);
|
||||||
|
showSnackbar("Error al eliminar imagen", "error");
|
||||||
|
} finally {
|
||||||
|
deleting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSnackbar(message, color) {
|
||||||
|
snackbar.value = { show: true, message, color };
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadImages();
|
||||||
|
loadProducts();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.text-md-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.text-md-right {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -106,8 +106,9 @@
|
|||||||
{ 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: 'Gestión de Productos', route: '/admin/products', icon: 'mdi-package-variant'},
|
||||||
{ title: 'Ver Ventas por Catálogo', route: '/admin/catalog-sales', icon: 'mdi-cart-arrow-down'},
|
{ title: 'Imágenes de Catálogo', route: '/admin/catalogue-images', icon: 'mdi-image-multiple'},
|
||||||
|
{ title: 'Ver Ventas por Catálogo', route: '/admin/catalog-sales', icon: 'mdi-cart-arrow-down'},
|
||||||
{ divider: true },
|
{ divider: true },
|
||||||
{ header: 'Sincronización Tryton' },
|
{ header: 'Sincronización Tryton' },
|
||||||
{ title: 'Importar Productos', route: '/sincronizar_productos_tryton', icon: 'mdi-download'},
|
{ title: 'Importar Productos', route: '/sincronizar_productos_tryton', icon: 'mdi-download'},
|
||||||
|
|||||||
10
src/pages/admin/catalogue-images.vue
Normal file
10
src/pages/admin/catalogue-images.vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<template>
|
||||||
|
<CatalogueImagesManagement v-if="authStore.isAdmin" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import CatalogueImagesManagement from '@/components/CatalogueImagesManagement.vue';
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
</script>
|
||||||
@@ -379,7 +379,7 @@ export default {
|
|||||||
this.items = data.map((product) => ({
|
this.items = data.map((product) => ({
|
||||||
...product,
|
...product,
|
||||||
quantity: 0,
|
quantity: 0,
|
||||||
img: product.img || not_image_product,
|
img: (product.catalogue_images?.length > 0) ? product.catalogue_images[0] : (product.img || not_image_product),
|
||||||
}));
|
}));
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const ADMIN_ROUTES = [
|
|||||||
'/cuadrar_tarro',
|
'/cuadrar_tarro',
|
||||||
'/admin/products',
|
'/admin/products',
|
||||||
'/admin/catalog-sales',
|
'/admin/catalog-sales',
|
||||||
|
'/admin/catalogue-images',
|
||||||
]
|
]
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
|
|||||||
@@ -82,6 +82,22 @@ class Api {
|
|||||||
getCurrentUser() {
|
getCurrentUser() {
|
||||||
return this.apiImplementation.getCurrentUser();
|
return this.apiImplementation.getCurrentUser();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getCatalogueImages() {
|
||||||
|
return this.apiImplementation.getCatalogueImages();
|
||||||
|
}
|
||||||
|
|
||||||
|
createCatalogueImage(data) {
|
||||||
|
return this.apiImplementation.createCatalogueImage(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCatalogueImage(id, data) {
|
||||||
|
return this.apiImplementation.updateCatalogueImage(id, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCatalogueImage(id) {
|
||||||
|
return this.apiImplementation.deleteCatalogueImage(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Api;
|
export default Api;
|
||||||
|
|||||||
@@ -130,6 +130,30 @@ class DjangoApi {
|
|||||||
const url = this.base + "/api/users/me/";
|
const url = this.base + "/api/users/me/";
|
||||||
return this.getRequest(url);
|
return this.getRequest(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getCatalogueImages() {
|
||||||
|
const url = this.base + "/don_confiao/api/catalogue_images/";
|
||||||
|
return this.getRequest(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
createCatalogueImage(data) {
|
||||||
|
const url = this.base + "/don_confiao/api/catalogue_images/";
|
||||||
|
return http.post(url, data, {
|
||||||
|
headers: { 'Content-Type': undefined },
|
||||||
|
}).then((r) => r.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCatalogueImage(id, data) {
|
||||||
|
const url = this.base + `/don_confiao/api/catalogue_images/${id}/`;
|
||||||
|
return http.put(url, data, {
|
||||||
|
headers: { 'Content-Type': undefined },
|
||||||
|
}).then((r) => r.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCatalogueImage(id) {
|
||||||
|
const url = this.base + `/don_confiao/api/catalogue_images/${id}/`;
|
||||||
|
return http.delete(url).then((r) => r.data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default DjangoApi;
|
export default DjangoApi;
|
||||||
|
|||||||
Reference in New Issue
Block a user