Files
don_confiao_frontend/src/pages/catalog.vue
aserrador 897cbb3efc feat: soportar resumen de compras para catalog_sales y sales
- Agregar método getSummaryCatalogPurchase() en django-api.js y api.js
- Modificar SummaryPurchase.vue para aceptar prop 'type' y usar endpoint correcto
- Actualizar catalog.vue para pasar type=catalog en redirect a summary_purchase
- Actualizar summary_purchase.vue para pasar prop type desde query params
- Lógica: si type='catalog' usa /resumen_compra_catalogo_json/{id}, sino usa /resumen_compra_json/{id}
- Mantener retrocompatibilidad: sin type usa endpoint de sales normal
2026-05-30 20:32:22 -05:00

737 lines
20 KiB
Vue

<template>
<v-container fluid>
<!-- Backdrop para mobile cuando el carrito está expandido -->
<div
v-if="isMobile && !cartCollapsed"
class="cart-backdrop"
@click="cartCollapsed = true"
></div>
<v-row>
<v-col cols="12" md="10" lg="9" :class="{ 'pb-mobile-cart': isMobile }">
<v-sheet
class="page-header d-flex align-center pa-3 pa-sm-4 pa-md-6 mb-3 mb-sm-4 rounded-lg"
>
<v-icon size="28" color="primary" class="mr-2 d-sm-none flex-shrink-0"
>mdi-store</v-icon
>
<v-icon
size="36"
color="primary"
class="mr-3 d-none d-sm-inline flex-shrink-0"
>mdi-store</v-icon
>
<div
class="d-flex flex-column flex-sm-row align-start align-sm-center w-100 ga-2 ga-sm-4"
>
<div class="flex-shrink-0 d-none d-sm-block">
<h1
class="text-h6 text-sm-h5 text-md-h4 font-weight-bold text-primary mb-0"
>
Catálogo
</h1>
<p class="text-body-2 text-medium-emphasis mb-0">
Explora y agrega productos a tu compra
</p>
</div>
<v-spacer class="d-none d-sm-flex"></v-spacer>
<v-text-field
v-model="searchQuery"
prepend-inner-icon="mdi-magnify"
label="Buscar producto..."
variant="solo-filled"
density="compact"
clearable
hide-details
single-line
class="search-field flex-grow-1 flex-sm-grow-0"
/>
</div>
</v-sheet>
<!-- Controles de paginación superiores -->
<PaginationControls
v-if="filteredItems.length > 0"
:current-page="currentPage"
:total-pages="totalPages"
:items-per-page="itemsPerPage"
:items-per-page-options="itemsPerPageOptions"
:pagination-info="paginationInfo"
:total-visible-pages="totalVisiblePages"
@page-change="handlePageChange"
@items-per-page-change="handleItemsPerPageChange"
position="top"
/>
<!-- Grid de productos paginados -->
<v-row class="product-grid" v-if="paginatedItems.length > 0">
<v-col
v-for="item in paginatedItems"
:key="item.id"
cols="12"
sm="6"
md="6"
lg="4"
class="product-col"
>
<Card
:product="item"
:increase="increase"
:decrease="decrease"
:currency="currency"
:updateQuantity="updateQuantity"
@add-to-cart="addToCart"
/>
</v-col>
</v-row>
<!-- Mensaje cuando no hay productos -->
<v-alert
v-if="items.length === 0"
type="info"
class="my-4"
variant="tonal"
>
No hay productos disponibles en el catálogo
</v-alert>
<v-alert
v-else-if="filteredItems.length === 0"
type="warning"
class="my-4"
variant="tonal"
>
No se encontraron productos con ese nombre
</v-alert>
<!-- Controles de paginación inferiores -->
<PaginationControls
v-if="filteredItems.length > 0"
:current-page="currentPage"
:total-pages="totalPages"
:items-per-page="itemsPerPage"
:items-per-page-options="itemsPerPageOptions"
:pagination-info="paginationInfo"
:total-visible-pages="totalVisiblePages"
@page-change="handlePageChange"
@items-per-page-change="handleItemsPerPageChange"
position="bottom"
/>
</v-col>
<v-col cols="12" md="2" lg="3">
<div
class="cart-sidebar"
:class="{ 'cart-is-collapsed': cartCollapsed && isMobile }"
>
<Cart
:cart-items="cartItems"
:currency="currency"
:is-collapsed="cartCollapsed"
:is-mobile="isMobile"
:window-width="windowWidth"
@remove="removeFromCart"
@checkout="goToCheckout"
@update-quantity="updateCartQuantity"
@toggle-collapse="toggleCart"
/>
</div>
</v-col>
</v-row>
<!-- Modal 1: Confirmación de productos -->
<v-dialog v-model="checkoutDialog" max-width="600" persistent>
<v-card>
<v-card-title class="headline">Confirmar Compra</v-card-title>
<v-card-text>
<v-list v-if="cartItems.length > 0" class="product-list-scroll">
<v-list-item v-for="item in cartItems" :key="item.id">
<div class="d-flex justify-space-between align-center">
<div>
<div class="font-weight-medium">{{ item.name }}</div>
<div class="text-caption text-grey">
{{ currency(item.price) }} x {{ item.quantity }}
</div>
</div>
<div class="font-weight-bold text-success">
{{ currency(item.price * item.quantity) }}
</div>
</div>
</v-list-item>
</v-list>
<v-divider class="my-3"></v-divider>
<div class="d-flex justify-space-between text-h6">
<span class="font-weight-bold">Total</span>
<span class="font-weight-bold text-success">{{
currency(cartStore.cartTotal)
}}</span>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn variant="text" @click="checkoutDialog = false">Cancelar</v-btn>
<v-btn color="primary" variant="elevated" @click="onConfirmCheckout"
>Confirmar</v-btn
>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Modal 2: Datos personales -->
<v-dialog v-model="personalDataDialog" max-width="500" persistent>
<v-card>
<v-card-title class="headline">Datos de Contacto</v-card-title>
<v-card-text>
<v-form ref="personalForm">
<v-text-field
v-model="customerName"
label="Nombre completo"
:rules="[rules.required]"
required
variant="outlined"
class="mb-3"
></v-text-field>
<v-text-field
v-model="customerAddress"
label="Dirección"
variant="outlined"
class="mb-3"
></v-text-field>
<v-text-field
v-model="customerPhone"
label="Teléfono"
variant="outlined"
class="mb-3"
></v-text-field>
<v-select
v-model="pickupMethod"
:items="pickupOptions"
item-title="text"
item-value="value"
label="Recogida"
:rules="[rules.required]"
required
variant="outlined"
></v-select>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn variant="text" @click="cancelPurchase">Cancelar</v-btn>
<v-btn
color="primary"
variant="elevated"
@click="onSubmitPurchase"
:loading="isSubmitting"
:disabled="isSubmitting"
>
Finalizar Compra
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<script>
import Card from "@/components/catalog/Card.vue";
import Cart from "@/components/catalog/Cart.vue";
import PaginationControls from "@/components/catalog/PaginationControls.vue";
import { useCartStore } from "@/stores/cart";
import { inject, ref, computed, onMounted, onUnmounted } from "vue";
import not_image_product from "@/assets/not_image_for_product.jpeg";
export default {
components: {
Card,
Cart,
PaginationControls,
},
setup() {
const cartStore = useCartStore();
const cartCollapsed = ref(false); // Cambiado a false para que inicie expandido en desktop
const windowWidth = ref(window.innerWidth);
const isMobile = computed(() => windowWidth.value < 960); // Cambiado de 680 a 960
const updateWindowWidth = () => {
windowWidth.value = window.innerWidth;
};
onMounted(() => {
window.addEventListener("resize", updateWindowWidth);
});
onUnmounted(() => {
window.removeEventListener("resize", updateWindowWidth);
});
return {
cartStore,
cartCollapsed,
isMobile,
windowWidth,
};
},
data() {
return {
api: inject("api"),
items: [],
searchQuery: "",
// Paginación
currentPage: 1,
itemsPerPage: 20,
itemsPerPageOptions: [10, 20, 50, 100],
checkoutDialog: false,
personalDataDialog: false,
customerName: "",
customerAddress: "",
customerPhone: "",
pickupMethod: "STORE",
pickupOptions: [
{ text: "En Sitio", value: "STORE" },
{ text: "Domicilio", value: "DELIVERY" },
],
isSubmitting: false,
rules: {
required: (value) => !!value || "Requerido.",
},
};
},
computed: {
cartItems: {
get() {
return this.cartStore.items;
},
set(value) {
this.cartStore.items = value;
},
},
cartCount() {
return this.cartStore.cartCount;
},
// Búsqueda
filteredItems() {
if (!this.searchQuery) return this.items;
const query = this.searchQuery.toLowerCase();
return this.items.filter((item) =>
item.name.toLowerCase().includes(query),
);
},
// Paginación
paginatedItems() {
const start = (this.currentPage - 1) * this.itemsPerPage;
const end = start + this.itemsPerPage;
return this.filteredItems.slice(start, end);
},
totalPages() {
return Math.ceil(this.filteredItems.length / this.itemsPerPage);
},
paginationInfo() {
const start = (this.currentPage - 1) * this.itemsPerPage + 1;
const end = Math.min(
this.currentPage * this.itemsPerPage,
this.items.length,
);
return {
start,
end,
total: this.filteredItems.length,
};
},
// Computed para total-visible dinámico y responsive (usado por ambos PaginationControls)
totalVisiblePages() {
// Si hay pocas páginas, mostrarlas todas (IMPORTANTE para mostrar iconos de navegación)
if (this.totalPages <= 7) {
return this.totalPages;
}
// Breakpoints responsivos basados en windowWidth
// OPTIMIZADO: Reducidos para evitar que desaparezcan los iconos de navegación
const width = this.windowWidth;
if (width < 400) {
return 3; // Extra small mobile
} else if (width < 680) {
return 5; // Mobile
} else if (width < 960) {
return 5; // Tablet (REDUCIDO de 7 → 5 para evitar overflow)
} else if (width < 1280) {
return 7; // Desktop small (REDUCIDO de 9 → 7)
} else {
return 9; // Desktop large (REDUCIDO de 11 → 9)
}
},
},
created() {
this.loadItemsPerPagePreference();
this.fetchProducts();
},
watch: {
searchQuery() {
this.currentPage = 1;
},
},
methods: {
fetchProducts() {
this.api
.getProducts()
.then((data) => {
this.items = data.map((product) => ({
...product,
quantity: 0,
img: product.img || not_image_product,
}));
})
.catch((error) => {
console.error(error);
});
},
increase(item) {
item.quantity = Number(item.quantity) + 1;
this.addToCart(item);
},
decrease(item) {
item.quantity = Math.max(0, Number(item.quantity) - 1);
if (item.quantity === 0) {
this.removeFromCart(item.id);
} else {
this.addToCart(item);
}
},
updateQuantity(item) {
if (item.quantity > 0) {
this.addToCart(item);
} else {
this.removeFromCart(item.id);
}
},
addToCart(item) {
if (item.quantity <= 0) return;
this.cartStore.addItem(item);
},
removeFromCart(itemId) {
this.cartStore.removeItem(itemId);
const item = this.items.find((i) => i.id === itemId);
if (item) {
item.quantity = 0;
}
},
updateCartQuantity({ itemId, quantity }) {
this.cartStore.updateQuantity({ itemId, quantity });
const productItem = this.items.find((i) => i.id === itemId);
if (productItem) {
productItem.quantity = quantity;
}
},
goToCheckout() {
this.checkoutDialog = true;
},
onConfirmCheckout() {
this.checkoutDialog = false;
this.personalDataDialog = true;
},
cancelPurchase() {
this.checkoutDialog = false;
this.personalDataDialog = false;
this.customerName = "";
this.customerAddress = "";
this.customerPhone = "";
this.pickupMethod = "STORE";
},
async onSubmitPurchase() {
const form = this.$refs.personalForm;
if (form) {
const { valid } = await form.validate();
if (!valid) return;
}
this.isSubmitting = true;
const payload = {
date: this.getCurrentDate(),
customer: 1,
notes: "",
payment_method: "CASH",
catalogsaleline_set: this.cartItems.map((item) => ({
product: item.id,
unit_price: item.price,
quantity: item.quantity,
measuring_unit: item.measuring_unit || "Unidad",
})),
customer_name: this.customerName,
customer_address: this.customerAddress,
customer_phone: this.customerPhone,
pickup_method: this.pickupMethod,
};
this.api
.createCatalogPurchase(payload)
.then((data) => {
this.cartStore.clearCart();
this.personalDataDialog = false;
this.$router.push({
path: "/summary_purchase",
query: {
id: parseInt(data.id),
type: 'catalog'
},
});
})
.catch((error) => {
console.error("Error al crear la compra:", error);
this.isSubmitting = false;
});
},
getCurrentDate() {
const today = new Date();
const gmtOffSet = -5;
const localDate = new Date(today.getTime() + gmtOffSet * 60 * 60 * 1000);
return localDate.toISOString().slice(0, 16);
},
toggleCart() {
this.cartCollapsed = !this.cartCollapsed;
},
// Paginación
handlePageChange(newPage) {
this.currentPage = newPage;
this.scrollToTop();
},
handleItemsPerPageChange(newValue) {
this.itemsPerPage = newValue;
this.currentPage = 1;
this.saveItemsPerPagePreference(newValue);
this.scrollToTop();
},
saveItemsPerPagePreference(value) {
localStorage.setItem("catalog_items_per_page", value);
},
loadItemsPerPagePreference() {
const saved = localStorage.getItem("catalog_items_per_page");
if (saved && this.itemsPerPageOptions.includes(parseInt(saved))) {
this.itemsPerPage = parseInt(saved);
}
},
scrollToTop() {
this.$nextTick(() => {
const catalogHeader = this.$el?.querySelector(".page-header");
if (catalogHeader) {
catalogHeader.scrollIntoView({
behavior: "smooth",
block: "start",
});
}
});
},
currency(val) {
if (val == null) return "-";
return new Intl.NumberFormat("es-CO", {
style: "currency",
currency: "COP",
minimumFractionDigits: 0,
}).format(val);
},
},
};
</script>
<style scoped>
/* ============================================
CABECERA STICKY CON BÚSQUEDA
============================================ */
.page-header {
position: sticky;
top: 80px;
z-index: 5;
background: white !important;
color: #1565c0 !important;
overflow: visible;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1) !important;
}
/* Mobile: Header sticky compensating for NavBar height, z-index menor que el cart */
@media (max-width: 959px) {
.page-header {
top: 64px;
border-radius: 0 !important;
z-index: 900;
}
}
@media (max-width: 559px) {
.page-header {
padding: 12px 16px !important;
}
}
/* Estilos profundos para el campo de búsqueda de Vuetify */
.page-header :deep(.v-field) {
background-color: #f5f5f5 !important;
border-radius: 8px;
transition: background-color 0.2s ease;
}
.page-header :deep(.v-field:hover),
.page-header :deep(.v-field--focused) {
background-color: #e0e0e0 !important;
}
.page-header :deep(.v-field__input) {
color: #1565c0 !important;
}
.page-header :deep(.v-field__input::placeholder) {
color: rgba(0, 0, 0, 0.5) !important;
}
@media (max-width: 559px) {
.page-header .search-field :deep(.v-field__input) {
font-size: 0.875rem;
}
.page-header .search-field {
width: 100%;
max-width: 100%;
}
}
@media (min-width: 560px) {
.page-header .search-field {
min-width: 260px;
max-width: 360px;
}
}
@media (min-width: 960px) {
.page-header .search-field {
min-width: 320px;
max-width: 460px;
}
}
/* ============================================
CARRITO FLOTANTE (MOBILE FIRST)
============================================ */
.cart-sidebar {
--footer-height: 40px;
position: fixed;
bottom: var(--footer-height);
left: 0;
right: 0;
z-index: 1000;
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
border-radius: 16px 16px 0 0;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.15);
}
/* Cuando está colapsado en mobile, solo muestra el header (60px) */
.cart-sidebar.cart-is-collapsed {
transform: translateY(calc(100% - 60px));
}
.cart-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 999;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Espacio inferior para evitar que productos queden ocultos bajo el cart */
.pb-mobile-cart {
padding-bottom: 100px !important;
}
/* ============================================
GRID DE PRODUCTOS
============================================ */
.product-grid {
margin: 0 -8px;
}
.product-col {
padding: 8px;
display: flex;
}
/* Asegurar que las cards ocupen toda la altura */
.product-col :deep(.product-card) {
width: 100%;
}
/* Mobile: mayor espaciado vertical */
@media (max-width: 559px) {
.product-grid {
margin: 0 -6px;
}
.product-col {
padding: 6px;
}
}
/* Tablet: espaciado medio */
@media (min-width: 560px) and (max-width: 959px) {
.product-grid {
margin: 0 -8px;
}
.product-col {
padding: 8px;
}
}
/* Desktop: espaciado óptimo */
@media (min-width: 960px) {
.product-grid {
margin: 0 -12px;
}
.product-col {
padding: 12px;
}
}
/* ============================================
DISEÑO DESKTOP (>= 960px)
============================================ */
@media (min-width: 960px) {
.cart-sidebar {
position: sticky;
top: 96px;
z-index: 1;
max-height: calc(100vh - 120px);
overflow-y: auto;
box-shadow: none;
border-radius: 12px;
transform: none !important;
}
.cart-backdrop {
display: none;
}
.pb-mobile-cart {
padding-bottom: 16px !important;
}
}
/* ============================================
MODALES
============================================ */
.product-list-scroll {
max-height: 400px;
overflow-y: auto;
}
</style>