40 Commits

Author SHA1 Message Date
mono
523d0b9d08 #45 feat: show 'Resumen del pedido' and hide planilla alert for catalog orders 2026-07-11 19:52:34 -05:00
mono
0806731a1f #45 feat: rename checkout to 'Finalizar Pedido' and add contact info in checkout modal 2026-07-11 19:47:04 -05:00
mono
beb5e153f3 #45 feat: update login prompt message with contact info and icons 2026-07-11 19:39:36 -05:00
mono
3e19b3d272 #45 feat: hide total price when quantity is 0 2026-07-11 19:05:07 -05:00
mono
b21d3a0ca6 #45 fix: cart toggle button not working on mobile 2026-07-11 18:59:46 -05:00
mono
3029e6d4b9 #45 feat: clean catalog view on mobile devices 2026-07-11 18:56:45 -05:00
92483a110a Merge pull request 'fix/catalog-image-overlap' (#44) from fix/catalog-image-overlap into main
Reviewed-on: #44
2026-07-03 19:21:40 -05:00
mono
a89af0f0bb #fix: búsqueda insensible a tildes en el catálogo
Normaliza los strings eliminando diacríticos (NFD) antes de comparar,
para que 'cafe' encuentre 'café' y viceversa.
2026-07-03 19:06:40 -05:00
mono
b2a47c44dc #fix: scroll al inicio del grid al cambiar página en el catálogo
El método scrollToTop usaba scrollIntoView en un elemento sticky,
lo que no generaba desplazamiento. Ahora calcula la posición
absoluta del grid de productos y hace scroll suave.
2026-07-03 18:58:00 -05:00
mono
2c2082266e #fix: corregir solapamiento de imagen en tarjetas del catálogo
- Cambiar height: 100% por aspect-ratio: 1 en .product-image-container
- Eliminar media queries duplicados y obsoletos
- El contenedor ahora se ajusta al tamaño real del v-img
2026-07-03 18:52:33 -05:00
4b006aaa01 #44 feat: restringir acceso a compras para usuarios con rol publico 2026-06-22 12:50:14 -05:00
762fde2cd0 #44 feat: ocultar carrito y deshabilitar controles para usuarios no autenticados en catálogo 2026-06-22 11:46:50 -05:00
bb26f55829 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
2026-06-13 18:05:48 -05:00
mono
5397ab2255 #40 fix: upload de imágenes con multipart/form-data 2026-06-13 17:59:14 -05:00
mono
a2ab4fceb7 #40 feat: add catalogue images CRUD and display in product catalog 2026-06-13 16:32:44 -05:00
78dfea8714 feat: controlar visibilidad de botones según autenticación en Wellcome
- Agregar lógica condicional para mostrar botón 'Ir a Comprar' solo a usuarios admin autenticados
- Reordenar botones: 'Ver Catálogo' primero, 'Ir a Comprar' segundo
- Importar y usar useAuthStore para verificar isAuthenticated e isAdmin
- 'Ver Catálogo' siempre visible para todos los usuarios
- 'Ir a Comprar' visible solo cuando isAuthenticated && isAdmin
2026-06-03 16:22:20 -05:00
490cb7b53d feat: separar ventas de catálogo en tabs por estado de sincronización
- Agregar sistema de tabs: 'Sin Sincronizar' y 'Sincronizadas'
- Crear computeds pendingSales y syncedSales basados en external_id
- Implementar headers diferentes para cada tab con columna 'Estado'
- Agregar columna 'ID Tryton' (external_id) en tab de sincronizadas
- Refactorizar filtros comunes aplicables a ambas vistas
- Mover botón 'Sincronizar a Tryton' solo a tab Sin Sincronizar
- Agregar badges de estado: naranja 'Pendiente' y verde 'Sincronizada'
- Mostrar contadores en tiempo real en cada tab y en header
- Tab 'Sin Sincronizar' activo por defecto
- Deshabilitar botón de sincronización cuando no hay ventas pendientes
2026-05-30 21:14:52 -05:00
5e86595831 feat: agregar sincronización de ventas de catálogo a Tryton
- Agregar método sendCatalogSalesToTryton() en django-api.js y api.js
- Crear página sincronizar_catalog_sales_tryton.vue para exportar catalog_sales
- Agregar botón 'Sincronizar a Tryton' en CatalogSalesManagement header
- Reorganizar menú admin en NavBar con sección 'Sincronización Tryton'
- Separar opciones de importación (download) y exportación (upload) a Tryton
- Endpoint: /don_confiao/api/enviar_catalog_sales_a_tryton
- Mostrar resultados exitosos/fallidos similar a sincronización de ventas normales
2026-05-30 20:57:50 -05:00
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
925fadba2d refactor: unificar componentes de compra con prop isAdmin
- Agregar prop isAdmin a Purchase.vue para controlar campos editables
- Hacer campo unit_price editable solo en modo admin (:readonly="!isAdmin")
- Actualizar comprar.vue y compra_admin.vue para usar Purchase unificado
- Eliminar componente AdminPurchase.vue duplicado
- Ambas páginas ahora usan interfaz moderna con cards y diseño responsive
- Mantener seguridad con authStore.isAdmin check en compra_admin.vue
2026-05-30 19:55:00 -05:00
d049357231 feat: agregar imagen por defecto para productos sin foto en catálogo 2026-05-29 18:38:01 -05:00
a36fbd289e feat: rediseñar página de login con glassmorphism y diseño responsive
- Rediseño completo de Login.vue con burbujas animadas de colores
- Implementado glassmorphism (backdrop-filter blur) en tarjeta de login
- Migrado a Composition API con <script setup>
- Formulario mejorado: iconos mdi, variant outlined, loading state
- Diseño responsive con breakpoints mobile/tablet/desktop
- Burbuja amarilla con opacidad reducida (0.08-0.25) para mejor UX
- Fix: agregado import en autenticarse.vue
2026-05-29 16:39:46 -05:00
368b7007f6 refactor: reemplazar iconos SVG por mdi genéricos en NavBar
- Eliminar import de tryton-icon.svg
- Reemplazar trytonIcon por 'mdi-sync' en items de Tryton
- Restaurar :prepend-icon (todos los iconos son ahora mdi strings)
2026-05-29 01:30:54 -05:00
f79197baf5 fix: corregir iconos SVG en NavBar
- Cambiar string literal 'trytonIcon' por variable importada trytonIcon
- Agregar condicion startsWith('mdi-') para diferenciar iconos MDI de URLs SVG
- Los iconos MDI se renderizan con <v-icon>, SVGs con <img>
2026-05-29 01:29:02 -05:00
3f0f1fe09a fix: corregir renderizado de iconos SVG en NavBar
- Reemplazar :prepend-icon por slot #prepend para soportar iconos mdi y SVG
- Los items con trytonIcon (SVG importado) ahora se renderizan como <img>
- Los items con mdi-* se renderizan como <v-icon>
2026-05-29 01:25:10 -05:00
ed42eb324c fix: cambiar filtro por defecto a Inactivos en gestión de productos
- Default filter cambia de 'all' a 'false' (Inactivos)
- Limpieza de whitespace
2026-05-29 01:13:46 -05:00
d5e30c92b0 feat: agregar página de ventas por catálogo
- Agregar CatalogSalesManagement.vue con tabla de ventas
- Filtros por texto (ID/cliente) y rango de fechas
- Filas expandibles con detalle de productos y datos de envío
- Agregar ruta protegida /admin/catalog-sales
- Agregar endpoint getCatalogSales() en servicios API
- Agregar menú 'Ver Ventas por Catálogo' en NavBar
2026-05-29 01:06:44 -05:00
6aecbd37d2 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
2026-05-29 00:53:38 -05:00
cb79ad6d45 chore: Sintaxis 2026-05-28 23:29:36 -05:00
38c1d8c17c feat: optimize product images to e-commerce standard (600x600 → 300px display)
INDUSTRY STANDARD IMPLEMENTATION:
- Change placeholder images from 300x200 (3:2) to 600x600 (1:1 square)
- Set max-height to 300px with aspect-ratio 1:1 for consistent display
- Follow e-commerce best practices (Amazon, Shopify, Mercado Libre)

TEMPLATE CHANGES:
- catalog.vue: Update placeholder URL to 600x600 square images
- Card.vue: Add aspect-ratio="1" for perfect square display
- Card.vue: Increase max-height from 140px to 300px
- Restore padding: pa-2 → pa-3 (8px → 12px)
- Restore title size: text-subtitle-2 → text-subtitle-1
- Restore margins: mb-1 → mb-2
- Restore chip size: x-small → small
- Restore footer padding: pa-2 → pa-2 pb-3

CSS CHANGES:
- Remove fixed height from .product-image-container (use 100% for flexibility)
- Product name: 0.85rem → 0.95rem, min-height 2rem → 2.5rem
- Price label: 0.6rem → 0.65rem
- Price value: 0.9rem → 1.1rem
- Price chip: height 22px → 26px, font 0.75rem → 0.9rem
- Quantity input: 55px → 65px, font 0.85rem → 0.95rem
- Section gaps: 2px → 4px, row gaps: 1px → 2px

RESPONSIVE STRATEGY (optimized for 300px max):
- Mobile XS (<375px): Natural scaling, compact layout
- Mobile (375-559px): Natural scaling, readable text
- Tablet (560-959px): Enhanced padding and fonts
- Desktop (≥960px): Full 300px display with optimal spacing
- Desktop L/XL: Maintain 300px with enhanced typography

RESULT:
- Square images (1:1) matching industry standard
- 600x600 source allows retina displays and zoom
- 300px display on desktop (sweet spot for catalogs)
- Responsive scaling maintains aspect ratio
- Professional e-commerce appearance
2026-05-28 23:25:17 -05:00
398a4cf79d refactor: reduce product card size by 25-30% (aggressive optimization)
TEMPLATE CHANGES:
- Reduce image max-height: 180px → 140px
- Reduce padding: pa-3 → pa-2 (12px → 8px)
- Reduce title size: text-subtitle-1 → text-subtitle-2
- Reduce margins: mb-2 → mb-1 throughout
- Reduce chip size: small → x-small
- Simplify footer padding: pa-2 pb-3 → pa-2

CSS BASE CHANGES:
- Image container height: 180px → 140px (-22%)
- Product name font: 0.95rem → 0.85rem, min-height: 2.5rem → 2rem
- Price label font: 0.65rem → 0.6rem
- Price value font: 1.1rem → 0.9rem (-18%)
- Price chip: height 26px → 22px, padding 12px → 10px, font 0.9rem → 0.75rem
- Quantity input: 65px → 55px, font 0.95rem → 0.85rem, min-height 32px → 28px
- Section gaps: 4px → 2px, row gaps: 2px → 1px

RESPONSIVE BREAKPOINTS (all reduced ~25-30%):
- Mobile XS (<375px): 160px → 130px
- Mobile (375-559px): 170px → 140px
- Tablet (560-959px): 190px → 150px
- Desktop (960-1279px): 200px → 160px
- Desktop L (1280-1919px): 220px → 170px
- Desktop XL (≥1920px): 240px → 180px

RESULT: Cards are now 25-30% smaller maintaining proportions across all devices
2026-05-28 23:10:27 -05:00
6970867f7b feat: transform catalog to modern grid layout with compact product cards
- Transform Card.vue from horizontal to vertical compact design
- Reduce card height with fixed image max-height (160-240px responsive)
- Center all text content (product name, prices, actions)
- Style product name with semibold font-weight, 2-line ellipsis
- Redesign price labels: subtle gray uppercase for labels, bold primary for values
- Style quantity controls: tonal icon buttons with smooth animations
- Redesign input field: solo-filled variant, compact density, subtle shadow
- Transform catalog.vue list layout to responsive grid (v-row/v-col)
- Implement responsive columns: 1 col mobile, 2 cols tablet, 3 cols desktop
- Clean up obsolete .catalog-item styles, use native Vuetify grid spacing
- Add hover effects: translateY(-6px), scale(1.08) on image, border highlight
- Add footer background (#fafafa) for visual separation
- Optimize breakpoints: 6 responsive sizes (374px, 559px, 959px, 1280px, 1920px)
- Result: Professional, compact, balanced e-commerce catalog design
2026-05-28 23:05:57 -05:00
196a5e2068 fix: resolve mobile layout issues and redesign catalog header
- Add 'app' prop to NavBar for proper Vuetify layout integration
- Fix mobile z-index: page-header now compensates for NavBar height (64px)
- Fix cart visibility: position cart above footer instead of hidden underneath
- Redesign header: white background with subtle shadow instead of blue gradient
- Expand catalog layout: increase catalog width (md=10 lg=9) for better content space
- Optimize mobile search: expand search field to full width, hide title on mobile
- Adjust mobile padding: increase to 100px to account for footer + collapsed cart
2026-05-28 22:57:21 -05:00
619590adcc fix: optimize catalog mobile cart behavior and z-index hierarchy
- Fix cart overlay blocking header in mobile (z-index: header 900 < cart 1000)
- Add cart-is-collapsed class with translateY(calc(100% - 60px)) for bottom sheet behavior
- Ensure cart header remains visible and clickable when collapsed in mobile
- Add deep Vuetify styles for search field integration (:deep(.v-field))
- Preserve desktop sticky sidebar behavior (position: sticky, overflow-y: auto)
- Make entire cart header clickable in mobile (@click on v-card-title)
- Add visual feedback with chevron icons (mdi-chevron-up/down)
- Clean CSS organization with section comments
2026-05-28 22:44:13 -05:00
da45c4c1f7 feat: add search bar and restyle catalog header matching Nueva Compra
- Replace plain title with gradient page-header (icon, title, subtitle)
- Add search field with mdi-magnify icon and real-time name filtering
- Integrate search into the header as a single sticky unit
- Add filteredItems computed for client-side product search
- Reset to page 1 on search query change
- Show distinct message when search yields no results
- Adapt pagination to work with filtered results
2026-05-28 22:19:10 -05:00
690c8ff288 feat: redesign hero banner with glassmorphism and animated glow bubbles
- Replace gradient background with light pastel bubbles on #f8fafc
- Add 4 animated glow bubbles (blue, green, yellow, red) with radial gradients
- Implement glassmorphism card for hero content with backdrop-filter
- Add floating corner animations and center pulse animation
- Update logo to colorful variant
- Remove white text classes, use dark slate for readability
2026-05-28 21:40:29 -05:00
df291df451 fix: correct catalog purchase field name and swap api method mappings
- Rename saleline_set to catalogsaleline_set in catalog purchase payload
- Fix swapped createPurchase/createCatalogPurchase method calls in Api class
- Format http.js (indentation, quotes, trailing commas)
2026-05-28 18:57:51 -05:00
9ea01eed39 fix: resolve white screen crashes and restructure layout hierarchy
- Move NavBar from App.vue to layouts/default.vue to fix nested v-app/v-main
- Replace VSkeletonLoader with v-progress-circular to avoid genStructure crash
- Initialize payment_methods as [] and add fallback in v-select
- Remove duplicate fetchClients call from mounted()
- Add authStore.user check in admin route guard
- Replace window.location.href with router.push for SPA navigation
- Add !important to page-header gradient styles
2026-05-28 18:14:37 -05:00
e2604a1837 feat: redesign purchase page with improved UI/UX and responsive layout 2026-05-28 17:31:26 -05:00
d86919e930 feat: improve home page with hero banner, logo, and catalog CTA 2026-05-28 17:24:45 -05:00
36 changed files with 3205 additions and 1142 deletions

View File

@@ -1,2 +1,3 @@
VITE_API_IMPLEMENTATION=django
VITE_DJANGO_BASE_URL=http://localhost:7000
VITE_CONTACT_PHONE=3023567797

View File

@@ -1,2 +1,3 @@
VITE_API_IMPLEMENTATION=django
VITE_DJANGO_BASE_URL=http://localhost:7000
VITE_CONTACT_PHONE=

View File

@@ -3,9 +3,11 @@ FROM node:20-alpine AS builder
ARG VITE_DJANGO_BASE_URL
ARG VITE_API_IMPLEMENTATION
ARG VITE_CONTACT_PHONE
ENV VITE_DJANGO_BASE_URL=$VITE_DJANGO_BASE_URL
ENV VITE_API_IMPLEMENTATION=$VITE_API_IMPLEMENTATION
ENV VITE_CONTACT_PHONE=$VITE_CONTACT_PHONE
WORKDIR /app

View File

@@ -59,6 +59,7 @@ set +a
BUILD_ARGS="--build-arg VITE_DJANGO_BASE_URL=$VITE_DJANGO_BASE_URL"
BUILD_ARGS="$BUILD_ARGS --build-arg VITE_API_IMPLEMENTATION=$VITE_API_IMPLEMENTATION"
BUILD_ARGS="$BUILD_ARGS --build-arg VITE_CONTACT_PHONE=$VITE_CONTACT_PHONE"
FULL_IMAGE_NAME="$GITEA_REGISTRY/$GITEA_USER/$IMAGE_NAME"

View File

@@ -1,19 +1,11 @@
<template>
<v-app>
<NavBar />
<v-main>
<router-view />
</v-main>
<router-view />
</v-app>
</template>
<script>
import NavBar from './components/NavBar.vue';
export default {
name: 'App',
components: {
NavBar,
},
}
</script>

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View File

@@ -1,341 +0,0 @@
<template>
<v-container>
<v-form ref="purchase" v-model="valid" @change="onFormChange">
<v-row>
<v-col>
<v-autocomplete
v-model="purchase.customer"
:items="filteredClients"
:search="client_search"
no-data-text="No se hallaron clientes"
item-title="name"
item-value="id"
@update:model-value="onFormChange"
label="Cliente"
:rules="[rules.required]"
required
class="mr-4"
></v-autocomplete>
<!-- <v-btn color="primary" @click="openModal">Agregar Cliente</v-btn> -->
<CreateCustomerModal ref="customerModal" @customerCreated="handleNewCustomer"/>
</v-col>
<v-col lg="4">
<v-text-field
v-model="purchase.date"
label="Fecha"
type="datetime-local"
:rules="[rules.required]"
required
readonly
></v-text-field>
</v-col>
</v-row>
<v-textarea
v-model="purchase.notes"
label="Notas"
rows="2"
></v-textarea>
<v-divider></v-divider>
<v-container>
<v-toolbar>
<v-toolbar-title secondary>Productos</v-toolbar-title>
p </v-toolbar>
<v-container v-for="(line, index) in purchase.saleline_set" :key="line.id">
<v-row>
<v-col
lg="9">
<v-autocomplete
v-model="line.product"
:items="filteredProducts"
:search="product_search"
@update:modelValue="onProductChange(index)"
no-data-text="No se hallaron productos"
item-title="name"
item-value="id"
item-subtitle="Price"
label="Producto"
:rules="[rules.required]"
required
>
<template v-slot:item="{ props, item }">
<v-list-item v-bind="props" :title="item.raw.name" :subtitle="formatPrice(item.raw.price)"></v-list-item>
</template>
</v-autocomplete>
</v-col>
<v-col
lg="2"
>
<v-text-field
v-model.number="line.quantity"
label="Cantidad"
type="number"
:rules="[rules.required,rules.positive]"
required
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col>
<v-text-field
v-model.number="line.unit_price"
label="Precio"
type="number"
:rules="[rules.required]"
prefix="$"
required
></v-text-field>
</v-col>
<v-col>
<v-text-field
v-model="line.measuring_unit"
label="UdM"
persistent-placeholder="true"
readonly
></v-text-field>
</v-col>
<v-col>
<v-text-field
type="number"
:value="calculateSubtotal(line)"
label="Subtotal"
prefix="$"
readonly
disable
persistent-placeholder="true"
></v-text-field>
</v-col>
<v-col>
<v-btn @click="removeLine(index)" color="red">Eliminar</v-btn>
</v-col>
</v-row>
<v-alert type="warning" :duration="2000" closable v-model="show_alert_lines">
No se puede eliminar la única línea.
</v-alert>
</v-container>
<v-btn @click="addLine" color="blue">Agregar</v-btn>
</v-container>
<v-divider></v-divider>
<v-text-field
:value="calculateTotal"
label="Total"
prefix="$"
readonly
persistent-placeholder="true"
></v-text-field>
<v-container v-if="calculateTotal > 0">
<v-select
:items="payment_methods"
v-model="purchase.payment_method"
item-title="text"
item-value="value"
label="Pago en"
:rules="[rules.required]"
required
></v-select>
<v-btn @click="openCasherModal" v-if="purchase.payment_method === 'CASH'">Calcular Devuelta</v-btn>
<CasherModal :total_purchase="calculateTotal" ref="casherModal"</CasherModal>
</v-container>
<v-btn @click="submit" color="green">Comprar</v-btn>
<v-alert type="error" :duration="2000" closable v-model="show_alert_purchase">
Verifique los campos obligatorios.
</v-alert>
</v-form>
</v-container>
</template>
<script>
import CustomerForm from './CreateCustomerModal.vue';
import CasherModal from './CasherModal.vue';
import { inject } from 'vue';
export default {
name: 'DonConfiao',
components: {
CustomerForm,
CasherModal,
},
props: {
msg: String
},
data() {
return {
api: inject('api'),
valid: false,
form_changed: false,
show_alert_lines: false,
show_alert_purchase: false,
client_search: '',
product_search: '',
payment_methods: null,
purchase: {
date: this.getCurrentDate(),
customer: null,
notes: '',
payment_method: null,
saleline_set: [{product:'', unit_price: 0, quantity: 0, unit: ''}],
},
rules: {
required: value => !!value || 'Requerido.',
positive: value => value > 0 || 'La cantidad debe ser mayor que 0.',
},
menuItems: [
{ title: 'Inicio', route: '/'},
{ title: 'Compras', route:'/compras'},
],
clients: [],
products: [],
};
},
created() {
this.fetchClients();
this.fetchProducts();
this.fetchPaymentMethods();
},
watch: {
group () {
this.drawer = false
},
},
beforeMount() {
window.addEventListener('beforeunload', this.confirmLeave);
},
beforeDestroy() {
window.removeEventListener('beforeunload', this.confirmLeave);
},
computed: {
calculateTotal() {
return this.purchase.saleline_set.reduce((total, saleline) => {
return total + this.calculateSubtotal(saleline);
}, 0);
},
filteredClients() {
return this.clients.filter(client => {
if (this.client_search === '') {
return [];
} else {
return client.name.toLowerCase().includes(this.client_search.toLowerCase());
}
});
},
filteredProducts() {
return this.products.filter(product => {
if (this.product_search === '') {
return [];
} else {
return product.name.toLowerCase().includes(this.product_search.toLowerCase());
}
});
},
},
methods: {
openModal() {
this.$refs.customerModal.openModal();
},
onFormChange() {
this.form_changed = true;
},
openCasherModal() {
this.$refs.casherModal.dialog = true
},
confirmLeave(event) {
if (this.form_changed) {
const message = '¿seguro que quieres salir? Perderas la información diligenciada';
event.preventDefault();
event.returnValue = message;
return message;
}
},
getCurrentDate() {
const today = new Date();
const gmtOffSet = -5;
const localDate = new Date(today.getTime() + (gmtOffSet * 60 * 60 * 1000));
// Formatear la fecha y hora en el formato YYYY-MM-DDTHH:MM
const formattedDate = localDate.toISOString().slice(0,16);
return formattedDate;
},
onProductChange(index) {
const selectedProductId = this.purchase.saleline_set[index].product;
const selectedProduct = this.products.find(p => p.id == selectedProductId);
this.purchase.saleline_set[index].unit_price = selectedProduct.price;
this.purchase.saleline_set[index].measuring_unit = selectedProduct.measuring_unit;
},
fetchClients() {
this.api.getCustomers()
.then(data => {
this.clients = data;
})
.catch(error => {
console.error(error);
});
},
handleNewCustomer(newCustomer){
this.clients.push(newCustomer);
this.purchase.customer = newCustomer.id;
},
fetchProducts() {
this.api.getProducts()
.then(data => {
this.products = data;
})
.catch(error => {
console.error(error);
});
},
fetchPaymentMethods() {
this.api.getPaymentMethods()
.then(data => {
this.payment_methods = data;
})
.catch(error => {
console.error(error);
});
},
addLine() {
this.purchase.saleline_set.push({ product: '', unit_price: 0, quantity:0, measuring_unit: ''});
},
removeLine(index) {
if (this.purchase.saleline_set.length > 1) {
this.purchase.saleline_set.splice(index, 1);
} else {
this.show_alert_lines = true;
setTimeout(() => {
this.show_alert_lines = false;
}, 2000);
}
},
calculateSubtotal(line) {
return line.unit_price * line.quantity;
},
async submit() {
this.$refs.purchase.validate();
if (this.valid) {
this.api.createPurchase(this.purchase)
.then(data => {
console.log('Compra enviada:', data);
this.$router.push({
path: "/summary_purchase",
query : {id: parseInt(data.id)}
});
})
.catch(error => console.error('Error al enviarl la compra:', error));
} else {
this.show_alert_purchase = true;
setTimeout(() => {
this.show_alert_purchase = false;
}, 4000);
}
},
navigate(route) {
this.$router.push(route);
},
formatPrice(price) {
return new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'COP' }).format(price);
},
},
mounted() {
this.fetchClients();
}
};
</script>
<style>
</style>

View File

@@ -1,39 +1,62 @@
<template>
<v-footer height="40" app>
<a
v-for="item in items"
:key="item.title"
:href="item.href"
:title="item.title"
class="d-inline-block mx-2 social-link"
rel="noopener noreferrer"
target="_blank"
<div>
<!-- Footer completo: visible siempre en desktop, toggle en mobile -->
<v-footer
v-if="expanded || !isMobile"
height="40"
app
class="app-footer"
:class="{ 'footer-overlay': isMobile }"
>
<v-icon
:icon="item.icon"
:size="item.icon === '$vuetify' ? 24 : 16"
/>
</a>
<div
class="text-caption text-disabled"
style="position: absolute; right: 16px;"
>
&copy; 2016-{{ (new Date()).getFullYear() }} <span class="d-none d-sm-inline-block">Vuetify, LLC</span>
<a
class="text-decoration-none on-surface"
href="https://vuetifyjs.com/about/licensing/"
v-for="item in items"
:key="item.title"
:href="item.href"
:title="item.title"
class="d-inline-block mx-2 social-link"
rel="noopener noreferrer"
target="_blank"
>
MIT License
<v-icon
:icon="item.icon"
:size="item.icon === '$vuetify' ? 24 : 16"
/>
</a>
</div>
</v-footer>
<div
class="text-caption text-disabled"
style="position: absolute; right: 16px;"
>
&copy; 2016-{{ (new Date()).getFullYear() }} <span class="d-none d-sm-inline-block">Vuetify, LLC</span>
<a
class="text-decoration-none on-surface"
href="https://vuetifyjs.com/about/licensing/"
rel="noopener noreferrer"
target="_blank"
>
MIT License
</a>
</div>
</v-footer>
<!-- Botón flotante: solo en mobile -->
<v-btn
v-if="isMobile"
icon
size="small"
class="footer-toggle-btn"
:color="expanded ? 'primary' : undefined"
@click="expanded = !expanded"
>
<v-icon :icon="expanded ? 'mdi-close' : '$vuetify'" :size="expanded ? 20 : 24" />
</v-btn>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
const items = [
{
title: 'Vuetify Documentation',
@@ -65,15 +88,48 @@
icon: `mdi-reddit`,
href: 'https://reddit.com/r/vuetifyjs',
},
]
];
const expanded = ref(false);
const windowWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1200);
const isMobile = computed(() => windowWidth.value < 960);
const onResize = () => {
windowWidth.value = window.innerWidth;
};
onMounted(() => {
window.addEventListener('resize', onResize);
});
onUnmounted(() => {
window.removeEventListener('resize', onResize);
});
</script>
<style scoped lang="sass">
.social-link :deep(.v-icon)
color: rgba(var(--v-theme-on-background), var(--v-disabled-opacity))
text-decoration: none
transition: .2s ease-in-out
.social-link :deep(.v-icon)
color: rgba(var(--v-theme-on-background), var(--v-disabled-opacity))
text-decoration: none
transition: .2s ease-in-out
&:hover
color: rgba(25, 118, 210, 1)
&:hover
color: rgba(25, 118, 210, 1)
/* Botón flotante para mobile */
.footer-toggle-btn
position: fixed
bottom: 12px
right: 12px
z-index: 2000
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25)
/* Overlay del footer en mobile */
.footer-overlay
position: fixed !important
bottom: 0
left: 0
right: 0
z-index: 1999
</style>

View File

@@ -0,0 +1,527 @@
<template>
<v-container fluid>
<!-- Header Principal -->
<v-row align="center" class="mb-4">
<v-col cols="12">
<h1 class="text-h4">Ventas por Catálogo</h1>
<div class="text-caption text-grey mt-1">
{{ totalPending }} sin sincronizar {{ totalSynced }} sincronizadas
</div>
</v-col>
</v-row>
<!-- Tabs -->
<v-tabs v-model="activeTab" class="mb-4" color="primary">
<v-tab value="pending">
Sin Sincronizar
<v-chip class="ml-2" size="small" color="orange" variant="flat">{{ totalPending }}</v-chip>
</v-tab>
<v-tab value="synced">
Sincronizadas
<v-chip class="ml-2" size="small" color="success" variant="flat">{{ totalSynced }}</v-chip>
</v-tab>
</v-tabs>
<!-- Window para contenido de tabs -->
<v-window v-model="activeTab">
<!-- Tab: Sin Sincronizar -->
<v-window-item value="pending">
<!-- Botón Sincronizar -->
<v-row align="center" class="mb-4">
<v-col>
<v-btn
color="primary"
prepend-icon="mdi-sync"
@click="$router.push('/sincronizar_catalog_sales_tryton')"
:disabled="totalPending === 0"
>
Sincronizar a Tryton
</v-btn>
</v-col>
</v-row>
<!-- Filtros -->
<v-row>
<v-col cols="12" md="4">
<v-text-field
v-model="searchQuery"
label="Buscar por ID o cliente"
prepend-inner-icon="mdi-magnify"
clearable
density="compact"
variant="outlined"
hide-details
></v-text-field>
</v-col>
<v-col cols="6" md="3">
<v-text-field
v-model="dateFrom"
label="Fecha desde"
type="date"
density="compact"
variant="outlined"
hide-details
clearable
></v-text-field>
</v-col>
<v-col cols="6" md="3">
<v-text-field
v-model="dateTo"
label="Fecha hasta"
type="date"
density="compact"
variant="outlined"
hide-details
clearable
></v-text-field>
</v-col>
<v-col cols="12" md="2" class="d-flex align-center">
<v-btn
@click="clearFilters"
variant="text"
color="grey"
size="small"
prepend-icon="mdi-filter-remove"
>
Limpiar filtros
</v-btn>
</v-col>
</v-row>
<!-- Tabla de ventas sin sincronizar -->
<v-row>
<v-col cols="12">
<v-card>
<v-data-table
v-model:expanded="expandedPending"
:headers="pendingHeaders"
:items="filteredPendingSales"
:loading="loading"
density="compact"
item-value="id"
items-per-page="25"
:items-per-page-options="[10, 25, 50, 100]"
show-expand
>
<!-- Fecha formateada -->
<template #item.date="{ item }">
{{ formatDate(item.date) }}
</template>
<!-- Total formateado -->
<template #item.total="{ item }">
${{ Number(item.total).toLocaleString('es-CO') }}
</template>
<!-- Cliente -->
<template #item.customer="{ item }">
<span v-if="item.customer_name">{{ item.customer_name }}</span>
<v-chip v-else size="small" color="grey" variant="flat">
ID: {{ item.customer }}
</v-chip>
</template>
<!-- Estado -->
<template #item.status="{ item }">
<v-chip size="small" color="orange" variant="flat">
<v-icon start size="small">mdi-clock-outline</v-icon>
Pendiente
</v-chip>
</template>
<!-- Fila expandida: detalle de productos -->
<template #expanded-row="{ columns, item }">
<tr>
<td :colspan="columns.length" class="pa-4 bg-grey-lighten-4">
<v-row>
<v-col cols="12" md="6">
<strong>Datos de envío</strong>
<v-list density="compact">
<v-list-item v-if="item.customer_name">
<template #prepend><v-icon>mdi-account</v-icon></template>
<v-list-item-title>{{ item.customer_name }}</v-list-item-title>
</v-list-item>
<v-list-item v-if="item.customer_address">
<template #prepend><v-icon>mdi-map-marker</v-icon></template>
<v-list-item-title>{{ item.customer_address }}</v-list-item-title>
</v-list-item>
<v-list-item v-if="item.customer_phone">
<template #prepend><v-icon>mdi-phone</v-icon></template>
<v-list-item-title>{{ item.customer_phone }}</v-list-item-title>
</v-list-item>
<v-list-item v-if="item.pickup_method">
<template #prepend><v-icon>mdi-truck</v-icon></template>
<v-list-item-title>
{{ item.pickup_method === 'DELIVERY' ? 'Domicilio' : 'Recoge en tienda' }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-col>
<v-col cols="12" md="6">
<strong>Productos</strong>
<v-table density="compact">
<thead>
<tr>
<th class="text-left">Producto ID</th>
<th class="text-right">Precio</th>
<th class="text-right">Cantidad</th>
<th class="text-right">Subtotal</th>
</tr>
</thead>
<tbody>
<tr v-for="line in item.catalogsaleline_set" :key="line.id">
<td>{{ line.product }}</td>
<td class="text-right">${{ Number(line.unit_price).toLocaleString('es-CO') }}</td>
<td class="text-right">{{ line.quantity }}</td>
<td class="text-right">
${{ (Number(line.unit_price) * Number(line.quantity)).toLocaleString('es-CO') }}
</td>
</tr>
</tbody>
</v-table>
</v-col>
</v-row>
</td>
</tr>
</template>
<!-- Loading -->
<template #loading>
<v-skeleton-loader type="table-row@10"></v-skeleton-loader>
</template>
<!-- No data -->
<template #no-data>
<v-alert type="info" variant="tonal" class="my-4">
No hay ventas pendientes de sincronizar
</v-alert>
</template>
</v-data-table>
</v-card>
</v-col>
</v-row>
</v-window-item>
<!-- Tab: Sincronizadas -->
<v-window-item value="synced">
<!-- Filtros -->
<v-row class="mt-4">
<v-col cols="12" md="4">
<v-text-field
v-model="searchQuery"
label="Buscar por ID o cliente"
prepend-inner-icon="mdi-magnify"
clearable
density="compact"
variant="outlined"
hide-details
></v-text-field>
</v-col>
<v-col cols="6" md="3">
<v-text-field
v-model="dateFrom"
label="Fecha desde"
type="date"
density="compact"
variant="outlined"
hide-details
clearable
></v-text-field>
</v-col>
<v-col cols="6" md="3">
<v-text-field
v-model="dateTo"
label="Fecha hasta"
type="date"
density="compact"
variant="outlined"
hide-details
clearable
></v-text-field>
</v-col>
<v-col cols="12" md="2" class="d-flex align-center">
<v-btn
@click="clearFilters"
variant="text"
color="grey"
size="small"
prepend-icon="mdi-filter-remove"
>
Limpiar filtros
</v-btn>
</v-col>
</v-row>
<!-- Tabla de ventas sincronizadas -->
<v-row>
<v-col cols="12">
<v-card>
<v-data-table
v-model:expanded="expandedSynced"
:headers="syncedHeaders"
:items="filteredSyncedSales"
:loading="loading"
density="compact"
item-value="id"
items-per-page="25"
:items-per-page-options="[10, 25, 50, 100]"
show-expand
>
<!-- Fecha formateada -->
<template #item.date="{ item }">
{{ formatDate(item.date) }}
</template>
<!-- Total formateado -->
<template #item.total="{ item }">
${{ Number(item.total).toLocaleString('es-CO') }}
</template>
<!-- Cliente -->
<template #item.customer="{ item }">
<span v-if="item.customer_name">{{ item.customer_name }}</span>
<v-chip v-else size="small" color="grey" variant="flat">
ID: {{ item.customer }}
</v-chip>
</template>
<!-- Estado -->
<template #item.status="{ item }">
<v-chip size="small" color="success" variant="flat">
<v-icon start size="small">mdi-check-circle</v-icon>
Sincronizada
</v-chip>
</template>
<!-- ID Tryton -->
<template #item.external_id="{ item }">
<v-chip size="small" variant="outlined" color="primary">
{{ item.external_id }}
</v-chip>
</template>
<!-- Fila expandida: detalle de productos -->
<template #expanded-row="{ columns, item }">
<tr>
<td :colspan="columns.length" class="pa-4 bg-grey-lighten-4">
<v-row>
<v-col cols="12" md="6">
<strong>Datos de envío</strong>
<v-list density="compact">
<v-list-item v-if="item.customer_name">
<template #prepend><v-icon>mdi-account</v-icon></template>
<v-list-item-title>{{ item.customer_name }}</v-list-item-title>
</v-list-item>
<v-list-item v-if="item.customer_address">
<template #prepend><v-icon>mdi-map-marker</v-icon></template>
<v-list-item-title>{{ item.customer_address }}</v-list-item-title>
</v-list-item>
<v-list-item v-if="item.customer_phone">
<template #prepend><v-icon>mdi-phone</v-icon></template>
<v-list-item-title>{{ item.customer_phone }}</v-list-item-title>
</v-list-item>
<v-list-item v-if="item.pickup_method">
<template #prepend><v-icon>mdi-truck</v-icon></template>
<v-list-item-title>
{{ item.pickup_method === 'DELIVERY' ? 'Domicilio' : 'Recoge en tienda' }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-col>
<v-col cols="12" md="6">
<strong>Productos</strong>
<v-table density="compact">
<thead>
<tr>
<th class="text-left">Producto ID</th>
<th class="text-right">Precio</th>
<th class="text-right">Cantidad</th>
<th class="text-right">Subtotal</th>
</tr>
</thead>
<tbody>
<tr v-for="line in item.catalogsaleline_set" :key="line.id">
<td>{{ line.product }}</td>
<td class="text-right">${{ Number(line.unit_price).toLocaleString('es-CO') }}</td>
<td class="text-right">{{ line.quantity }}</td>
<td class="text-right">
${{ (Number(line.unit_price) * Number(line.quantity)).toLocaleString('es-CO') }}
</td>
</tr>
</tbody>
</v-table>
</v-col>
</v-row>
</td>
</tr>
</template>
<!-- Loading -->
<template #loading>
<v-skeleton-loader type="table-row@10"></v-skeleton-loader>
</template>
<!-- No data -->
<template #no-data>
<v-alert type="info" variant="tonal" class="my-4">
No hay ventas sincronizadas
</v-alert>
</template>
</v-data-table>
</v-card>
</v-col>
</v-row>
</v-window-item>
</v-window>
<!-- Snackbar -->
<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, computed, inject, onMounted } from 'vue';
const api = inject('api');
const catalogSales = ref([]);
const loading = ref(false);
const expandedPending = ref([]);
const expandedSynced = ref([]);
const searchQuery = ref('');
const dateFrom = ref('');
const dateTo = ref('');
const snackbar = ref({ show: false, message: '', color: 'success' });
const activeTab = ref('pending'); // Tab activo por defecto
// Headers para tabla de ventas sin sincronizar
const pendingHeaders = [
{ title: 'ID', key: 'id', sortable: true },
{ title: 'Fecha', key: 'date', sortable: true },
{ title: 'Cliente', key: 'customer_name', sortable: true },
{ title: 'Total', key: 'total', sortable: true },
{ title: 'Estado', key: 'status', sortable: false },
{ title: '', key: 'data-table-expand' },
];
// Headers para tabla de ventas sincronizadas
const syncedHeaders = [
{ title: 'ID', key: 'id', sortable: true },
{ title: 'Fecha', key: 'date', sortable: true },
{ title: 'Cliente', key: 'customer_name', sortable: true },
{ title: 'Total', key: 'total', sortable: true },
{ title: 'Estado', key: 'status', sortable: false },
{ title: 'ID Tryton', key: 'external_id', sortable: true },
{ title: '', key: 'data-table-expand' },
];
// Ventas sin sincronizar (sin external_id)
const pendingSales = computed(() => {
return catalogSales.value.filter(sale => !sale.external_id);
});
// Ventas sincronizadas (con external_id)
const syncedSales = computed(() => {
return catalogSales.value.filter(sale => sale.external_id);
});
// Contadores
const totalPending = computed(() => pendingSales.value.length);
const totalSynced = computed(() => syncedSales.value.length);
// Función común para aplicar filtros
function applyFilters(sales) {
let result = sales;
// Filtro por texto (ID o nombre de cliente)
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase().trim();
result = result.filter(sale => {
const customerName = (sale.customer_name || '').toLowerCase();
const customerId = String(sale.customer);
const saleId = String(sale.id);
return customerName.includes(query) || customerId.includes(query) || saleId.includes(query);
});
}
// Filtro por fecha desde
if (dateFrom.value) {
const from = new Date(dateFrom.value);
result = result.filter(sale => new Date(sale.date) >= from);
}
// Filtro por fecha hasta
if (dateTo.value) {
const to = new Date(dateTo.value + 'T23:59:59');
result = result.filter(sale => new Date(sale.date) <= to);
}
return result;
}
// Ventas pendientes filtradas
const filteredPendingSales = computed(() => {
return applyFilters(pendingSales.value);
});
// Ventas sincronizadas filtradas
const filteredSyncedSales = computed(() => {
return applyFilters(syncedSales.value);
});
async function loadCatalogSales() {
loading.value = true;
try {
const data = await api.getCatalogSales();
catalogSales.value = data;
} catch (error) {
console.error('Error al cargar ventas por catálogo:', error);
snackbar.value = { show: true, message: 'Error al cargar ventas por catálogo', color: 'error' };
} finally {
loading.value = false;
}
}
function formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('es-CO', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function clearFilters() {
searchQuery.value = '';
dateFrom.value = '';
dateTo.value = '';
}
onMounted(() => {
loadCatalogSales();
});
</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,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>

View File

@@ -1,70 +1,272 @@
<template>
<h1>Login</h1>
<v-container fluid class="pa-0">
<v-sheet class="hero-section d-flex align-center justify-center">
<div class="glow-bubble bubble-blue"></div>
<div class="glow-bubble bubble-green"></div>
<div class="glow-bubble bubble-yellow"></div>
<div class="glow-bubble bubble-red"></div>
<v-form ref="loginForm" @submit.prevent="onSubmit">
<v-text-field
v-model="username"
label="Usuario"
:rules="[requiredRule]"
required
/>
<v-text-field
v-model="password"
label="Contraseña"
type="password"
:rules="[requiredRule]"
required
/>
<div class="login-card">
<v-img
:src="logo"
alt="Don Confiao"
max-width="140"
class="mx-auto mb-4"
/>
<h1 class="text-h5 text-sm-h4 font-weight-bold text-center mb-1">
Iniciar Sesión
</h1>
<p class="text-body-2 text-medium-emphasis text-center mb-6">
Ingresa tus credenciales para acceder
</p>
<v-btn type="submit" color="primary">Entrar</v-btn>
<v-form ref="loginForm" @submit.prevent="onSubmit">
<v-text-field
v-model="username"
label="Usuario"
prepend-inner-icon="mdi-account"
:rules="[requiredRule]"
variant="outlined"
required
class="mb-2"
autocomplete="username"
/>
<v-text-field
v-model="password"
label="Contraseña"
prepend-inner-icon="mdi-lock"
type="password"
:rules="[requiredRule]"
variant="outlined"
required
class="mb-4"
autocomplete="current-password"
/>
<v-alert v-if="error" type="error" class="mt-2">{{ error }}</v-alert>
</v-form>
<v-btn
type="submit"
color="primary"
size="large"
block
:loading="isSubmitting"
:disabled="isSubmitting"
>
Entrar
</v-btn>
<v-alert
v-if="error"
type="error"
variant="tonal"
class="mt-4"
closable
@click:close="error = ''"
>
{{ error }}
</v-alert>
</v-form>
</div>
</v-sheet>
</v-container>
</template>
<script>
import AuthService from '@/services/auth';
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import AuthService from '@/services/auth'
import logo from '@/assets/logo_colorful.png'
export default {
name: 'DonConfiao',
const router = useRouter()
data() {
return {
username: '',
password: '',
error: '',
};
},
const username = ref('')
const password = ref('')
const error = ref('')
const isSubmitting = ref(false)
const loginForm = ref(null)
methods: {
requiredRule(value) {
return !!value || 'Este campo es obligatorio';
},
function requiredRule(value) {
return !!value || 'Este campo es obligatorio'
}
async onSubmit() {
this.error = '';
async function onSubmit() {
error.value = ''
isSubmitting.value = true
const form = this.$refs.loginForm;
const isValid = await form.validate();
try {
const form = loginForm.value
if (form) {
const { valid } = await form.validate()
if (!valid) return
}
if (!isValid) return;
if (!username.value || !password.value) {
error.value = 'Usuario y contraseña son obligatorios'
return
}
if (!this.username || !this.password) {
this.error = 'Usuario y contraseña son obligatorios';
return;
}
try {
await AuthService.login({
username: this.username,
password: this.password,
});
this.$router.push({ path: '/' });
} catch (e) {
const msg = e?.response?.data?.message ?? e.message;
this.error = msg ?? 'Error al iniciar sesión';
}
},
},
};
await AuthService.login({
username: username.value,
password: password.value,
})
router.push({ path: '/' })
} catch (e) {
const msg = e?.response?.data?.message ?? e.message
error.value = msg ?? 'Error al iniciar sesión'
} finally {
isSubmitting.value = false
}
}
</script>
<style scoped>
.hero-section {
min-height: calc(100vh - 80px);
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
position: relative;
overflow: hidden;
background-color: #f8fafc !important;
}
.glow-bubble {
position: absolute;
border-radius: 10%;
pointer-events: none;
z-index: 1;
mix-blend-mode: normal;
}
.bubble-blue {
width: 500px;
height: 500px;
background: radial-gradient(
circle,
rgba(66, 165, 245, 0.8) 10%,
rgba(66, 165, 245, 0) 80%
);
filter: blur(100px);
top: -180px;
left: -150px;
animation: floatCornerTL 8s ease-in-out infinite;
}
.bubble-green {
width: 500px;
height: 500px;
background: radial-gradient(
circle,
rgba(0, 200, 83, 0.7) 10%,
rgba(0, 200, 83, 0) 80%
);
filter: blur(100px);
bottom: -150px;
right: -120px;
animation: floatCornerBR 9s ease-in-out infinite;
animation-delay: 1.5s;
}
.bubble-yellow {
width: 500px;
height: 500px;
background: radial-gradient(
circle,
rgba(255, 213, 0, 0.3) 20%,
rgba(255, 193, 7, 0) 1000%
);
filter: blur(80px);
top: 50%;
left: 50%;
animation: glowPulseCenter 8s ease-in-out infinite;
}
.bubble-red {
width: 500px;
height: 500px;
background: radial-gradient(
circle,
rgba(239, 83, 80, 0.7) 10%,
rgba(239, 83, 80, 0) 80%
);
filter: blur(100px);
top: -150px;
right: -120px;
animation: floatCornerTR 8s ease-in-out infinite;
animation-delay: 3s;
}
.login-card {
position: relative;
z-index: 2;
width: 100%;
max-width: 420px;
padding: 2.5rem 2rem;
background: rgba(255, 255, 255, 0.25) !important;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.6);
box-shadow: 0 10px 40px -10px rgba(15, 23, 42, 0.08);
}
@media (max-width: 600px) {
.hero-section {
padding: 1rem;
min-height: calc(100vh - 64px);
}
.login-card {
padding: 1.5rem 1.25rem;
border-radius: 20px;
}
}
@keyframes floatCornerTL {
0%,
100% {
opacity: 0.15;
transform: scale(0.9) translate(0, 0);
}
50% {
opacity: 0.85;
transform: scale(1.25) translate(40px, 30px);
}
}
@keyframes floatCornerTR {
0%,
100% {
opacity: 0.15;
transform: scale(1.2) translate(0, 0);
}
50% {
opacity: 0.8;
transform: scale(0.95) translate(-30px, 40px);
}
}
@keyframes floatCornerBR {
0%,
100% {
opacity: 0.1;
transform: scale(0.85) translate(0, 0);
}
50% {
opacity: 0.75;
transform: scale(1.15) translate(-40px, -30px);
}
}
@keyframes glowPulseCenter {
0%,
100% {
opacity: 0.08;
transform: translate(-50%, -50%) scale(0.85);
}
50% {
opacity: 0.25;
transform: translate(-50%, -50%) scale(1.3);
}
}
</style>

View File

@@ -1,7 +1,13 @@
<template>
<v-dialog v-model="show" max-width="400">
<v-card>
<v-card-title class="headline">Iniciar sesión</v-card-title>
<v-card-title class="headline d-flex align-center">
Iniciar sesión
<v-spacer />
<v-btn icon size="small" variant="text" @click="show = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text>
<v-form ref="form" @submit.prevent="onSubmit">
<v-text-field

View File

@@ -1,5 +1,5 @@
<template>
<v-app-bar color="primary" prominent>
<v-app-bar color="primary" prominent app>
<v-app-bar-nav-icon variant="text" @click.stop="drawer = !drawer"></v-app-bar-nav-icon>
<v-toolbar-title>Menu</v-toolbar-title>
<v-spacer></v-spacer>
@@ -54,7 +54,7 @@
nav
>
<v-list-item
v-for="item in menuItems"
v-for="item in filteredMenuItems"
:key="item.title"
:title="item.title"
:prepend-icon="item.icon"
@@ -63,13 +63,16 @@
<v-list-item prepend-icon="mdi-cog" title="Administracion" @click="toggleAdminMenu()" v-if="isAuthenticated && isAdmin"></v-list-item>
<v-list-item v-if="isAuthenticated && isAdmin && showAdminMenu">
<v-list>
<v-list-item
v-for="item in menuAdminItems"
:key="item.title"
:title="item.title"
:prepend-icon="item.icon"
@click="navigateAdmin(item.route)"
></v-list-item>
<template v-for="(item, index) in menuAdminItems" :key="index">
<v-divider v-if="item.divider"></v-divider>
<v-list-subheader v-else-if="item.header">{{ item.header }}</v-list-subheader>
<v-list-item
v-else
:title="item.title"
:prepend-icon="item.icon"
@click="navigateAdmin(item.route)"
></v-list-item>
</template>
</v-list>
</v-list-item>
</v-list>
@@ -77,7 +80,6 @@
</template>
<script>
import trytonIcon from '../assets/icons/tryton-icon.svg';
import AuthService from '@/services/auth';
import { useAuthStore } from '@/stores/auth';
import { inject } from 'vue';
@@ -104,15 +106,27 @@
{ 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: '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'}
{ title: 'Gestión de Productos', route: '/admin/products', icon: 'mdi-package-variant'},
{ 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 },
{ header: 'Sincronización Tryton' },
{ title: 'Importar Productos', route: '/sincronizar_productos_tryton', icon: 'mdi-download'},
{ title: 'Importar Clientes', route: '/sincronizar_clientes_tryton', icon: 'mdi-download'},
{ title: 'Exportar Ventas', route: '/sincronizar_ventas_tryton', icon: 'mdi-upload'},
{ title: 'Exportar Ventas Catálogo', route: '/sincronizar_catalog_sales_tryton', icon: 'mdi-upload'}
],
}),
computed: {
isAdmin() {
return this.user?.role === 'administrator';
}
},
filteredMenuItems() {
if (this.user?.role === 'publico') {
return this.menuItems.filter(item => item.route !== '/comprar');
}
return this.menuItems;
},
},
mounted() {
this.checkAuth();
@@ -163,3 +177,4 @@
}
}
</script>

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("false");
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

@@ -1,147 +1,321 @@
<template>
<v-container>
<v-form ref="purchase" v-model="valid" @change="onFormChange">
<v-row>
<v-col>
<v-autocomplete
v-model="purchase.customer"
:items="filteredClients"
:search="client_search"
no-data-text="No se hallaron clientes"
item-title="name"
item-value="id"
@update:model-value="onFormChange"
label="Cliente"
:rules="[rules.required]"
required
class="mr-4"
></v-autocomplete>
<!--<v-btn color="primary" @click="openModal">Agregar Cliente</v-btn>-->
<CreateCustomerModal ref="customerModal" @customerCreated="handleNewCustomer"/>
</v-col>
<v-col lg="4">
<v-text-field
v-model="purchase.date"
label="Fecha"
type="datetime-local"
:rules="[rules.required]"
required
readonly
></v-text-field>
</v-col>
</v-row>
<v-textarea
v-model="purchase.notes"
label="Notas"
rows="2"
></v-textarea>
<v-divider></v-divider>
<v-container>
<v-toolbar>
<v-toolbar-title secondary>Productos</v-toolbar-title>
</v-toolbar>
<v-container v-for="(line, index) in purchase.saleline_set" :key="line.id">
<v-row>
<v-col
lg="9">
<v-autocomplete
v-model="line.product"
:items="filteredProducts"
:search="product_search"
@update:modelValue="onProductChange(index)"
no-data-text="No se hallaron productos"
item-title="name"
item-value="id"
item-subtitle="Price"
label="Producto"
:rules="[rules.required]"
required
>
<template v-slot:item="{ props, item }">
<v-list-item v-bind="props" :title="item.raw.name" :subtitle="formatPrice(item.raw.price)"></v-list-item>
</template>
</v-autocomplete>
</v-col>
<v-col
lg="2"
>
<v-text-field
v-model.number="line.quantity"
label="Cantidad"
type="number"
:rules="[rules.required,rules.positive]"
<v-container fluid class="pa-4 pa-md-6">
<v-form ref="purchase" v-model="valid" @change="onFormChange">
<!-- Encabezado -->
<v-sheet class="page-header d-flex align-center pa-4 pa-md-6 mb-4 rounded-lg">
<v-icon start size="40" color="white" class="mr-3">mdi-cart-plus</v-icon>
<div>
<h1 class="text-h5 text-md-h4 font-weight-bold text-white mb-0">Nueva Compra</h1>
<p class="text-body-2 text-white text-medium-emphasis mb-0">Registra una nueva venta en el sistema</p>
</div>
</v-sheet>
<!-- Loading -->
<template v-if="loading">
<v-sheet class="d-flex flex-column align-center justify-center pa-12 rounded-lg" elevation="2">
<v-progress-circular
indeterminate
color="primary"
size="64"
width="6"
></v-progress-circular>
<p class="text-body-1 text-grey mt-4">Cargando datos...</p>
</v-sheet>
</template>
<template v-else>
<!-- Card: Información del Cliente -->
<v-card class="mb-4 rounded-lg" elevation="2">
<v-card-item>
<template #prepend>
<v-icon color="primary" size="36">mdi-account</v-icon>
</template>
<v-card-title class="font-weight-bold text-h6">Información del Cliente</v-card-title>
<v-card-subtitle>Datos básicos de la compra</v-card-subtitle>
</v-card-item>
<v-divider></v-divider>
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<v-autocomplete
v-model="purchase.customer"
:items="clients"
:search="client_search"
no-data-text="No se hallaron clientes"
item-title="name"
item-value="id"
@update:model-value="onFormChange"
label="Cliente"
:rules="[rules.required]"
required
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col>
<v-text-field
v-model.number="line.unit_price"
label="Precio"
type="number"
:rules="[rules.required]"
prefix="$"
required
readonly
></v-text-field>
</v-col>
<v-col>
<v-text-field
v-model="line.measuring_unit"
label="UdM"
persistent-placeholder="true"
readonly
></v-text-field>
</v-col>
<v-col>
<v-text-field
type="number"
:value="calculateSubtotal(line)"
label="Subtotal"
prefix="$"
readonly
disable
persistent-placeholder="true"
></v-text-field>
</v-col>
<v-col>
<v-btn @click="removeLine(index)" color="red">Eliminar</v-btn>
</v-col>
</v-row>
<v-alert type="warning" :duration="2000" closable v-model="show_alert_lines">
No se puede eliminar la única línea.
</v-alert>
</v-container>
<v-btn @click="addLine" color="blue">Agregar</v-btn>
</v-container>
<v-divider></v-divider>
<v-text-field
:value="calculateTotal"
label="Total"
prefix="$"
readonly
persistent-placeholder="true"
></v-text-field>
<v-container v-if="calculateTotal > 0">
<v-select
:items="payment_methods"
v-model="purchase.payment_method"
item-title="text"
item-value="value"
label="Pago en"
:rules="[rules.required]"
required
></v-select>
<v-btn @click="openCasherModal" v-if="purchase.payment_method === 'CASH'">Calcular Devuelta</v-btn>
<CasherModal :total_purchase="calculateTotal" ref="casherModal"</CasherModal>
</v-container>
<v-btn @click="submit" color="green">Comprar</v-btn>
<v-alert type="error" :duration="2000" closable v-model="show_alert_purchase">
clearable
variant="outlined"
density="comfortable"
hide-details="auto"
></v-autocomplete>
<CreateCustomerModal ref="customerModal" @customerCreated="handleNewCustomer" />
</v-col>
<v-col cols="12" md="3">
<v-text-field
v-model="purchase.date"
label="Fecha"
type="datetime-local"
:rules="[rules.required]"
required
readonly
variant="outlined"
density="comfortable"
hide-details="auto"
></v-text-field>
</v-col>
<v-col cols="12" md="3">
<v-btn
variant="tonal"
color="primary"
size="small"
class="mt-1"
prepend-icon="mdi-plus"
@click="openModal"
>
Nuevo Cliente
</v-btn>
</v-col>
</v-row>
<v-row class="mt-0">
<v-col cols="12">
<v-textarea
v-model="purchase.notes"
label="Notas"
rows="2"
variant="outlined"
density="comfortable"
hide-details="auto"
></v-textarea>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- Card: Productos -->
<v-card class="mb-4 rounded-lg" elevation="2">
<v-card-item>
<template #prepend>
<v-icon color="green-darken-1" size="36">mdi-package-variant-closed</v-icon>
</template>
<v-card-title class="font-weight-bold text-h6">Productos</v-card-title>
<v-card-subtitle>Agrega los productos de la compra</v-card-subtitle>
</v-card-item>
<v-divider></v-divider>
<!-- Encabezados de columnas (solo desktop) -->
<div class="d-none d-md-flex px-6 pt-3 text-caption font-weight-bold text-grey">
<div class="flex-grow-1 flex-shrink-1" style="min-width:0">Producto</div>
<div class="mx-2 text-center" style="width:100px; flex-shrink:0">Cantidad</div>
<div class="mx-2 text-end" style="width:110px; flex-shrink:0">Precio</div>
<div class="mx-2 text-end" style="width:110px; flex-shrink:0">Subtotal</div>
<div style="width:40px; flex-shrink:0"></div>
</div>
<v-card-text class="pa-0 pa-md-4">
<div
v-for="(line, index) in purchase.saleline_set"
:key="line.id"
class="product-line pa-3 pa-md-2"
>
<v-row no-gutters align="start" class="flex-md-nowrap">
<!-- Producto -->
<v-col cols="12" md class="mb-2 mb-md-0">
<v-autocomplete
v-model="line.product"
:items="products"
:search="product_search"
@update:modelValue="onProductChange(index)"
no-data-text="No se hallaron productos"
item-title="name"
item-value="id"
label="Producto"
:rules="[rules.required]"
required
variant="outlined"
density="compact"
hide-details="auto"
class="product-select"
>
<template v-slot:item="{ props, item }">
<v-list-item v-bind="props" :title="item.raw.name" :subtitle="formatPrice(item.raw.price)"></v-list-item>
</template>
</v-autocomplete>
</v-col>
<!-- Cantidad -->
<v-col cols="4" md="auto" class="pe-1">
<v-text-field
v-model.number="line.quantity"
label="Cant."
type="number"
:rules="[rules.required, rules.positive]"
required
variant="outlined"
density="compact"
hide-details="auto"
min="0"
class="quantity-field"
></v-text-field>
</v-col>
<!-- Precio -->
<v-col cols="4" md="auto" class="px-1">
<v-text-field
v-model.number="line.unit_price"
label="Precio"
type="number"
:rules="[rules.required]"
prefix="$"
required
:readonly="!isAdmin"
variant="outlined"
density="compact"
hide-details="auto"
class="price-field"
></v-text-field>
</v-col>
<!-- Subtotal + UdM -->
<v-col cols="4" md="auto" class="ps-1">
<v-text-field
type="number"
:value="calculateSubtotal(line)"
label="Subtotal"
prefix="$"
readonly
variant="outlined"
density="compact"
hide-details="auto"
class="subtotal-field"
></v-text-field>
<div class="text-caption text-grey text-center mt-1 d-md-none">{{ line.measuring_unit || 'Ud' }}</div>
</v-col>
<!-- Eliminar + UdM desktop -->
<v-col cols="12" md="auto" class="d-flex align-center mt-2 mt-md-0 ps-md-2">
<v-btn
@click="removeLine(index)"
color="error"
variant="text"
icon="mdi-delete"
size="small"
density="comfortable"
class="flex-shrink-0"
></v-btn>
<span class="text-caption text-grey ms-2 d-none d-md-inline">{{ line.measuring_unit || 'Ud' }}</span>
</v-col>
</v-row>
</div>
<v-alert
type="warning"
closable
v-model="show_alert_lines"
density="compact"
variant="tonal"
class="ma-3"
>
No se puede eliminar la única línea.
</v-alert>
<v-divider class="my-2"></v-divider>
<v-btn
@click="addLine"
color="primary"
variant="tonal"
prepend-icon="mdi-plus"
class="mt-2"
>
Agregar Producto
</v-btn>
</v-card-text>
</v-card>
<!-- Card: Resumen y Pago -->
<v-card v-if="calculateTotal > 0" class="mb-4 rounded-lg" elevation="2">
<v-card-item>
<template #prepend>
<v-icon color="green-darken-2" size="36">mdi-credit-card</v-icon>
</template>
<v-card-title class="font-weight-bold text-h6">Resumen y Pago</v-card-title>
</v-card-item>
<v-divider></v-divider>
<v-card-text>
<v-row align="center">
<v-col cols="12" md="6">
<v-select
:items="payment_methods || []"
v-model="purchase.payment_method"
item-title="text"
item-value="value"
label="Método de Pago"
:rules="[rules.required]"
required
variant="outlined"
density="comfortable"
hide-details="auto"
></v-select>
</v-col>
<v-col cols="12" md="6" class="text-center text-md-end">
<div class="text-body-2 text-grey mb-1">Total de la compra</div>
<div class="total-amount">{{ formatPrice(calculateTotal) }}</div>
<v-btn
v-if="purchase.payment_method === 'CASH'"
@click="openCasherModal"
color="orange-darken-2"
variant="tonal"
prepend-icon="mdi-cash"
size="small"
class="mt-1"
>
Calcular Devuelta
</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- Botón Comprar -->
<div class="d-flex flex-column flex-sm-row justify-space-between align-center ga-4 mt-6">
<div class="text-caption text-grey">
<v-icon start size="small" color="grey">mdi-information</v-icon>
Todos los campos marcados con * son obligatorios
</div>
<v-btn
@click="submit"
color="green-darken-1"
size="x-large"
prepend-icon="mdi-cart-check"
variant="elevated"
class="px-8 submit-btn"
:disabled="calculateTotal <= 0"
>
Comprar
</v-btn>
</div>
<v-alert
type="error"
closable
v-model="show_alert_purchase"
density="compact"
variant="tonal"
class="mt-4"
>
Verifique los campos obligatorios.
</v-alert>
</v-form>
</v-container>
<CasherModal :total_purchase="calculateTotal" ref="casherModal" />
</template>
</v-form>
</v-container>
</template>
<script>
@@ -149,25 +323,30 @@
import CasherModal from './CasherModal.vue';
import { inject } from 'vue';
export default {
name: 'DonConfiao',
components: {
CustomerForm,
CasherModal,
export default {
name: 'DonConfiao',
components: {
CustomerForm,
CasherModal,
},
props: {
msg: String
msg: String,
isAdmin: {
type: Boolean,
default: false
}
},
data() {
return {
api: inject('api'),
loading: true,
valid: false,
form_changed: false,
show_alert_lines: false,
show_alert_purchase: false,
client_search: '',
product_search: '',
payment_methods: null,
payment_methods: [],
purchase: {
date: this.getCurrentDate(),
customer: null,
@@ -179,54 +358,21 @@
required: value => !!value || 'Requerido.',
positive: value => value > 0 || 'La cantidad debe ser mayor que 0.',
},
menuItems: [
{ title: 'Inicio', route: '/'},
{ title: 'Compras', route:'/compras'},
],
clients: [],
products: [],
};
},
created() {
this.fetchClients();
this.fetchProducts();
this.fetchPaymentMethods();
},
watch: {
group () {
this.drawer = false
},
created() {
this.fetchClients();
this.fetchProducts();
this.fetchPaymentMethods();
},
beforeMount() {
window.addEventListener('beforeunload', this.confirmLeave);
},
beforeDestroy() {
window.removeEventListener('beforeunload', this.confirmLeave);
},
computed: {
calculateTotal() {
return this.purchase.saleline_set.reduce((total, saleline) => {
return total + this.calculateSubtotal(saleline);
}, 0);
},
filteredClients() {
return this.clients.filter(client => {
if (this.client_search === '') {
return [];
} else {
return client.name.toLowerCase().includes(this.client_search.toLowerCase());
}
});
},
filteredProducts() {
return this.products.filter(product => {
if (this.product_search === '') {
return [];
} else {
return product.name.toLowerCase().includes(this.product_search.toLowerCase());
}
});
},
},
methods: {
openModal() {
@@ -250,7 +396,6 @@
const today = new Date();
const gmtOffSet = -5;
const localDate = new Date(today.getTime() + (gmtOffSet * 60 * 60 * 1000));
// Formatear la fecha y hora en el formato YYYY-MM-DDTHH:MM
const formattedDate = localDate.toISOString().slice(0,16);
return formattedDate;
},
@@ -267,6 +412,9 @@
})
.catch(error => {
console.error(error);
})
.finally(() => {
this.loading = false;
});
},
handleNewCustomer(newCustomer){
@@ -330,13 +478,35 @@
this.$router.push(route);
},
formatPrice(price) {
return new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'COP' }).format(price);
return new Intl.NumberFormat('es-CO', { style: 'currency', currency: 'COP', minimumFractionDigits: 0 }).format(price);
},
},
mounted() {
this.fetchClients();
}
};
}
};
</script>
<style>
.page-header {
background: linear-gradient(135deg, #1565C0 0%, #0D47A1 100%) !important;
color: white !important;
}
.product-line:nth-child(odd) {
background-color: rgba(0, 0, 0, 0.02);
}
.total-amount {
font-size: 2rem;
font-weight: 700;
color: rgb(46, 125, 50);
line-height: 1.2;
}
.submit-btn {
min-width: 220px;
}
@media (max-width: 599px) {
.total-amount {
font-size: 1.6rem;
}
}
</style>

View File

@@ -8,7 +8,7 @@
</v-container>
<v-container v-show="id">
<v-toolbar>
<v-toolbar-title> Resumen de la compra {{ id }}</v-toolbar-title>
<v-toolbar-title> {{ type === 'catalog' ? 'Resumen del pedido' : 'Resumen de la compra' }} {{ id }}</v-toolbar-title>
</v-toolbar>
<v-list>
<v-list-item>
@@ -39,7 +39,7 @@
{{ currencyFormat(calculateSubtotal(item.unit_price, item.quantity)) }}
</template>
</v-data-table-virtual>
<v-alert type="info" class="my-4">
<v-alert v-if="type !== 'catalog'" type="info" class="my-4">
Recuerda adicionar a la planilla física lo siguiente
<v-data-table
:headers="headersTemplate"
@@ -72,7 +72,8 @@
name: 'SummaryPurchase',
props: {
msg: String,
id: Number
id: Number,
type: String
},
data () {
return {
@@ -102,7 +103,11 @@
},
methods: {
fetchPurchase(purchaseId) {
this.api.getSummaryPurchase(purchaseId)
const apiMethod = this.type === 'catalog'
? this.api.getSummaryCatalogPurchase(purchaseId)
: this.api.getSummaryPurchase(purchaseId);
apiMethod
.then(data => {
this.purchase = data;
})

View File

@@ -1,34 +1,291 @@
<template>
<v-container >
<v-responsive>
<v-toolbar>
<v-toolbar-title>Don Confiao te atiende</v-toolbar-title>
</v-toolbar>
<v-card>
<v-card-title>Hacer parte de la tienda la ilusión</v-card-title>
<v-card-text>
Recuerda que participando de esta tienda le apuestas a la economía solidaria, al mercado justo, a la alimentación sana, al campesinado colombiano y a un mundo mejor.
</v-card-text>
</v-card>
<v-container fluid class="pa-0">
<v-sheet
class="hero-section d-flex align-center justify-center text-center pa-8"
>
<div class="glow-bubble bubble-blue"></div>
<div class="glow-bubble bubble-green"></div>
<div class="glow-bubble bubble-yellow"></div>
<div class="glow-bubble bubble-red"></div>
<div class="hero-content">
<v-img
:src="logo"
alt="Don Confiao"
max-width="180"
class="mx-auto mb-4"
/>
<h1 class="text-h4 font-weight-bold mb-2">Don Confiao te atiende</h1>
<p class="text-subtitle-1 font-italic font-weight-bold">
Economía solidaria, mercado justo, alimentación sana
</p>
</div>
</v-sheet>
<v-card>
<v-card-title>En desarrollo</v-card-title>
<v-card-text>
Don confiao apenas esta entendiendo como funciona esta tienda y por ahora <ResaltedText>solo puede atender las compras de contado</ResaltedText>, ya sea en efectivo o consignación.
<v-container class="py-6">
<v-row>
<v-col cols="12" md="4">
<v-card class="h-100" elevation="2">
<v-card-item>
<template #prepend>
<v-icon color="green" size="48">mdi-hand-heart</v-icon>
</template>
<v-card-title class="font-weight-bold"
>Nuestra Tienda</v-card-title
>
</v-card-item>
<v-card-text>
Hacer parte de la tienda la ilusión. Participando de esta tienda
le apuestas a la economía solidaria, al mercado justo, a la
alimentación sana, al campesinado colombiano y a un mundo mejor.
</v-card-text>
</v-card>
</v-col>
<v-alert type="warning">
Si no vas a pagar tu compra recuerda que debes hacerlo en la planilla manual</v-alert>
</v-card-text>
</v-card>
<v-card>
<v-card-title>A comprar</v-card-title>
<v-card-text>
El siguiente botón te permitirá registrar tu compra. Cuando finalices te pedimos que ingrese el número de la compra, la fecha y el valor en la planilla física.
<div class="text-center">
<v-btn :to="{ path: 'comprar' }" color="green">Ir a Comprar</v-btn>
<v-col cols="12" md="4">
<v-card class="h-100" elevation="2">
<v-card-item>
<template #prepend>
<v-icon color="orange-darken-2" size="48"
>mdi-progress-wrench</v-icon
>
</template>
<v-card-title class="font-weight-bold"
>En Desarrollo</v-card-title
>
</v-card-item>
<v-card-text>
Don Confiao apenas está entendiendo cómo funciona esta tienda y
por ahora
<ResaltedText
>solo puede atender las compras de contado</ResaltedText
>, ya sea en efectivo o consignación.
<v-alert type="warning" class="mt-3" density="compact">
Si no vas a pagar tu compra recuerda que debes hacerlo en la
planilla manual
</v-alert>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="4">
<v-card class="h-100" elevation="2">
<v-card-item>
<template #prepend>
<v-icon color="blue" size="48">mdi-store</v-icon>
</template>
<v-card-title class="font-weight-bold">Catálogo</v-card-title>
</v-card-item>
<v-card-text>
Explora nuestro catálogo de productos disponibles. Encuentra todo
lo que necesitas y arma tu pedido fácilmente.
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-row class="mt-4">
<v-col cols="12" class="text-center">
<h2 class="text-h5 font-weight-bold mb-4">¿Qué deseas hacer?</h2>
<div class="d-flex flex-wrap justify-center ga-4">
<v-btn
:to="{ path: 'catalog' }"
color="primary"
size="x-large"
prepend-icon="mdi-store"
variant="elevated"
class="px-8"
>
Ver Catálogo
</v-btn>
<v-btn
v-if="authStore.isAuthenticated && authStore.isAdmin"
:to="{ path: 'comprar' }"
color="green"
size="x-large"
prepend-icon="mdi-cart"
variant="elevated"
class="px-8"
>
Ir a Comprar
</v-btn>
</div>
</v-card-text>
</v-card>
</v-responsive>
</v-col>
</v-row>
</v-container>
</v-container>
</template>
<script setup>
import ResaltedText from "@/components/ResaltedText.vue";
import { useAuthStore } from '@/stores/auth';
import logo from "@/assets/logo_colorful.png";
const authStore = useAuthStore();
</script>
<style scoped>
.hero-section {
min-height: 500px; /* Un poco más de aire vertical */
display: flex;
flex-direction: column;
justify-content: center;
padding: 4rem 2rem;
position: relative;
overflow: hidden;
/* Fondo blanco tiza ultra limpio para que los colores pastel floten */
background-color: #f8fafc !important;
}
.glow-bubble {
position: absolute;
border-radius: 10%;
pointer-events: none;
z-index: 1;
mix-blend-mode: normal;
}
.bubble-blue {
width: 500px;
height: 500px;
background: radial-gradient(
circle,
rgba(66, 165, 245, 0.8) 10%,
rgba(66, 165, 245, 0) 80%
);
filter: blur(100px);
top: -180px;
left: -150px;
animation: floatCornerTL 8s ease-in-out infinite;
}
.bubble-green {
width: 500px;
height: 500px;
background: radial-gradient(
circle,
rgba(0, 200, 83, 0.7) 10%,
rgba(0, 200, 83, 0) 80%
);
filter: blur(100px);
bottom: -150px;
right: -120px;
animation: floatCornerBR 9s ease-in-out infinite;
animation-delay: 1.5s;
}
.bubble-yellow {
width: 500px;
height: 500px;
background: radial-gradient(
circle,
rgba(255, 213, 0, 0.85) 20%,
rgba(255, 193, 7, 0) 1000%
);
filter: blur(80px);
top: 50%;
left: 50%;
animation: glowPulseCenter 8s ease-in-out infinite;
}
.bubble-red {
width: 500px;
height: 500px;
background: radial-gradient(
circle,
rgba(239, 83, 80, 0.7) 10%,
rgba(239, 83, 80, 0) 80%
);
filter: blur(100px);
top: -150px;
right: -120px;
animation: floatCornerTR 8s ease-in-out infinite;
animation-delay: 3s;
}
/* --- CONTENEDOR ELEGANTE (Glassmorphism) --- */
.hero-content {
position: relative;
z-index: 2;
color: #0f172a !important; /* Azul pizarra profundo de alta costura */
text-align: center;
max-width: 750px;
margin: 0 auto;
padding: 3rem 2.5rem;
/* El secreto elegante: un sutil escudo de cristal que desenfoca el fondo */
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.6);
box-shadow: 0 10px 40px -10px rgba(15, 23, 42, 0.08);
}
/* Eliminamos las sombras de texto redundantes gracias al escudo protector de cristal */
.hero-content h1 {
font-size: 3rem;
font-weight: 800;
letter-spacing: -0.02em; /* Tipografía compacta estilo Apple */
line-height: 1.15;
margin-bottom: 1rem;
}
.hero-content p {
font-size: 1.15rem;
color: #475569; /* Gris suavizado elegante para el subtítulo */
line-height: 1.6;
}
/* --- ANIMACIONES Ralentizadas y Cinemáticas --- */
/* --- 1. Esquina Superior Izquierda (Azul) --- */
@keyframes floatCornerTL {
0%,
100% {
opacity: 0.15;
transform: scale(0.9) translate(0, 0);
}
50% {
opacity: 0.85;
transform: scale(1.25) translate(40px, 30px); /* Movimiento fluido y orgánico */
}
}
/* --- 2. Esquina Superior Derecha (Rojo) --- */
@keyframes floatCornerTR {
0%,
100% {
opacity: 0.15;
transform: scale(1.2) translate(0, 0); /* Corregido: Escala inicial estable */
}
50% {
opacity: 0.8;
transform: scale(0.95) translate(-30px, 40px);
}
}
/* --- 3. Esquina Inferior Derecha (Verde) - CORREGIDA --- */
@keyframes floatCornerBR {
0%,
100% {
opacity: 0.1; /* Corregido: Cambiado de 0 a un estado tenue elegante */
transform: scale(0.85) translate(0, 0);
}
50% {
opacity: 0.75; /* Corregido: Cambiado de 0 a visible */
transform: scale(1.15) translate(-40px, -30px);
}
}
/* --- 4. Centro (Amarillo) - Optimizado en 3 puntos para máxima fluidez --- */
@keyframes glowPulseCenter {
0%,
100% {
opacity: 0.25;
transform: translate(-50%, -50%) scale(0.85);
}
50% {
opacity: 0.9; /* Simplificado a 50% para sincronía perfecta con las esquinas */
transform: translate(-50%, -50%) scale(1.3); /* Un destello central controlado */
}
}
</style>

View File

@@ -1,69 +1,126 @@
<template>
<v-card class="product-card">
<v-row class="product-card-row">
<!-- Columna de Imagen -->
<v-col cols="12" md="4" class="image-column">
<v-img :src="product.img" class="product-img" contain></v-img>
</v-col>
<v-card class="product-card" elevation="2" rounded="lg">
<!-- Imagen del Producto -->
<div class="product-image-container">
<v-img
:src="product.img"
:alt="product.name"
class="product-img"
cover
max-height="300"
aspect-ratio="1"
>
<template v-slot:placeholder>
<div class="d-flex align-center justify-center fill-height">
<v-progress-circular
indeterminate
color="primary"
size="32"
></v-progress-circular>
</div>
</template>
</v-img>
</div>
<!-- Columna de Detalles -->
<v-col cols="12" md="5" class="details-column">
<div class="product-details-content">
<v-tooltip location="top" :text="product.name">
<template v-slot:activator="{ props }">
<v-card-title class="product-name" v-bind="props" :title="product.name">
{{ product.name }}
</v-card-title>
</template>
</v-tooltip>
<v-card-subtitle class="product-description">{{
product.description
}}</v-card-subtitle>
<!-- Contenido de la Tarjeta -->
<v-card-text class="product-content pa-3 text-center">
<!-- Título del Producto -->
<v-tooltip location="top" :text="product.name">
<template v-slot:activator="{ props }">
<h3
class="product-name text-subtitle-1 font-weight-medium mb-2"
v-bind="props"
>
{{ product.name }}
</h3>
</template>
</v-tooltip>
<div class="prices">
<div class="price-unit">
<span class="price-label">Precio unitario</span>
<span class="price-value">{{ currency(product.price) }}</span>
</div>
<div class="price-total">
<span class="price-label">Precio total</span>
<span class="price-value total"
>{{ currency(product.price * product.quantity) }}
</span>
</div>
<!-- Sección de Precios -->
<div class="prices-section mb-2">
<!-- Precio Unitario -->
<div class="price-row mb-1">
<span class="price-label text-caption">Precio unitario</span>
<div class="price-value text-body-1 font-weight-bold text-primary">
{{ currency(product.price) }}
</div>
</div>
</v-col>
<!-- Columna de Controles -->
<v-col cols="12" md="3" class="controls-column">
<!-- Precio Total -->
<div v-if="product.quantity > 0" class="price-row">
<span class="price-label text-caption">Precio total</span>
<v-chip
color="success"
variant="flat"
size="small"
class="price-total-chip font-weight-bold mt-1"
>
{{ currency(product.price * product.quantity) }}
</v-chip>
</div>
</div>
</v-card-text>
<!-- Footer con Controles de Cantidad -->
<v-card-actions class="product-actions pa-2 pb-3 justify-center">
<template v-if="disabled">
<v-btn
color="primary"
variant="tonal"
prepend-icon="mdi-cart-plus"
class="login-add-btn"
@click="$emit('request-login')"
>
Agregar
</v-btn>
</template>
<template v-else>
<div class="quantity-controls">
<v-btn icon small class="qty-btn" @click="decrease(product)">
<v-icon small>mdi-minus</v-icon>
<v-btn
icon
size="small"
variant="tonal"
color="error"
class="qty-btn"
@click="decrease(product)"
:disabled="product.quantity === 0"
>
<v-icon size="20">mdi-minus</v-icon>
</v-btn>
<v-text-field
v-model.number="product.quantity"
type="number"
min="0"
class="quantity-input"
dense
outlined
class="quantity-input mx-1"
variant="solo-filled"
density="compact"
hide-details
single-line
flat
aria-label="Cantidad"
@input="handleQuantityChange"
/>
<v-btn icon small class="qty-btn qty-btn-add" @click="handleIncrease">
<v-icon small>mdi-plus</v-icon>
<v-btn
icon
size="small"
variant="tonal"
color="success"
class="qty-btn"
@click="handleIncrease"
>
<v-icon size="20">mdi-plus</v-icon>
</v-btn>
</div>
</v-col>
</v-row>
</template>
</v-card-actions>
</v-card>
</template>
<script>
export default {
emits: ['add-to-cart', 'request-login'],
props: {
product: {
type: Object,
@@ -85,6 +142,10 @@ export default {
type: Function,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
},
methods: {
handleIncrease() {
@@ -102,400 +163,307 @@ export default {
</script>
<style scoped>
/* ===== ESTILOS BASE - MOBILE FIRST (< 560px) ===== */
/* Card Container */
/* ============================================
CARD CONTAINER
============================================ */
.product-card {
border: 1px solid #e0e0e0;
border-radius: 8px;
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
transition: all 0.3s ease;
height: 100%;
display: flex;
flex-direction: column;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid rgba(0, 0, 0, 0.08);
background: #ffffff;
overflow: hidden;
}
.product-card:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
transform: translateY(-2px);
border-color: #bdbdbd;
transform: translateY(-6px);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.12) !important;
border-color: rgba(33, 150, 243, 0.3);
}
/* Row Container */
.product-card-row {
padding: 12px 8px;
margin: 0;
}
/* === COLUMNA DE IMAGEN === */
.image-column {
display: flex;
justify-content: center;
align-items: center;
padding: 0 0 16px 0;
/* ============================================
IMAGEN DEL PRODUCTO
============================================ */
.product-image-container {
position: relative;
width: 100%;
overflow: hidden;
background: linear-gradient(135deg, #fafafa 0%, #ffffff 100%);
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
.product-img {
width: 280px;
height: 280px;
border-radius: 12px;
object-fit: cover;
background: #fff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease;
width: 100%;
height: 100%;
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.product-card:hover .product-img {
transform: scale(1.03);
transform: scale(1.08);
}
/* === COLUMNA DE DETALLES === */
.details-column {
padding: 0;
/* ============================================
CONTENIDO DE LA TARJETA
============================================ */
.product-content {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.product-details-content {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
align-items: center;
justify-content: flex-start;
}
.product-name {
font-size: 0.95rem;
font-weight: 600;
color: #2c3e50;
font-weight: 500;
color: #1a1a1a;
line-height: 1.3;
padding: 0;
text-align: center;
word-break: break-word;
max-width: 100%;
}
.product-description {
font-size: 0.8rem;
color: #7f8c8d;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-align: center;
padding: 0;
margin-bottom: 4px;
text-overflow: ellipsis;
min-height: 2.5rem;
letter-spacing: 0.01em;
}
/* === PRECIOS === */
.prices {
/* ============================================
SECCIÓN DE PRECIOS
============================================ */
.prices-section {
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
gap: 4px;
}
.price-unit,
.price-total {
.price-row {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
gap: 2px;
}
.price-label {
font-size: 0.7rem;
color: #95a5a6;
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
letter-spacing: 0.8px;
font-size: 0.65rem;
color: #9e9e9e;
font-weight: 500;
}
.price-value {
font-size: 0.85rem;
font-weight: 600;
color: #2c3e50;
color: #1565c0;
letter-spacing: 0.02em;
font-size: 1.1rem;
}
.price-value.total {
background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%);
color: white;
padding: 3px 10px;
border-radius: 20px;
font-size: 0.95rem;
box-shadow: 0 2px 8px rgba(39, 174, 96, 0.3);
white-space: nowrap;
.price-total-chip {
letter-spacing: 0.03em;
font-size: 0.9rem;
padding: 0 12px;
height: 26px;
box-shadow: 0 2px 8px rgba(76, 175, 80, 0.25);
}
/* === COLUMNA DE CONTROLES === */
.controls-column {
display: flex;
align-items: center;
justify-content: center;
padding: 16px 0 0 0;
/* ============================================
CONTROLES DE CANTIDAD
============================================ */
.product-actions {
border-top: 1px solid rgba(0, 0, 0, 0.06);
background: #fafafa;
}
.quantity-controls {
display: inline-flex;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
background: #f5f5f5;
border-radius: 25px;
padding: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.quantity-input {
max-width: 60px;
min-width: 60px;
max-width: 65px;
min-width: 65px;
}
.quantity-input input {
.quantity-input :deep(.v-field) {
background-color: #ffffff !important;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
.quantity-input :deep(.v-field__input) {
text-align: center;
font-weight: 600;
font-size: 0.9rem;
font-weight: 700;
font-size: 0.95rem;
color: #1a1a1a;
padding: 4px 0;
min-height: 32px;
}
.quantity-input :deep(.v-field__field) {
padding: 0;
}
.qty-btn {
min-width: 32px !important;
width: 32px !important;
height: 32px !important;
border-radius: 50% !important;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
flex-shrink: 0;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.qty-btn:hover {
background: #e0e0e0;
transform: scale(1.08);
transform: scale(1.1);
}
.qty-btn-add {
background: #27ae60;
color: white;
.qty-btn:active {
transform: scale(0.95);
}
.qty-btn-add:hover {
background: #219a52 !important;
/* ============================================
RESPONSIVE BREAKPOINTS
============================================ */
/* Botón de agregar (no autenticado) */
.login-add-btn {
width: 100%;
font-weight: 600;
letter-spacing: 0.5px;
}
/* ===== RESOLUCIÓN 375-559px (Mobile Standard) ===== */
/* Móvil pequeño (< 375px) */
@media (max-width: 374px) {
.product-content {
padding: 10px;
}
.product-name {
font-size: 0.9rem;
min-height: 2.4rem;
}
.price-value {
font-size: 1rem;
}
.quantity-input {
max-width: 60px;
min-width: 60px;
}
}
/* Móvil estándar (375px - 559px) */
@media (min-width: 375px) and (max-width: 559px) {
.product-img {
width: 300px;
height: 300px;
}
.product-card-row {
padding: 14px 10px;
}
.product-name {
font-size: 1rem;
}
.product-description {
font-size: 0.85rem;
}
.quantity-input {
max-width: 65px;
min-width: 65px;
.price-value {
font-size: 0.9rem;
}
}
/* ===== RESOLUCIÓN 560-959px (Tablet) ===== */
/* Tablet (560px - 959px) */
@media (min-width: 560px) and (max-width: 959px) {
.product-card-row {
padding: 16px 12px;
}
.image-column {
padding-bottom: 20px;
}
.product-img {
width: 300px;
height: 300px;
}
.product-details-content {
gap: 14px;
.product-content {
padding: 10px;
}
.product-name {
font-size: 1.05rem;
}
.product-description {
font-size: 0.85rem;
}
.prices {
gap: 10px;
font-size: 0.9rem;
min-height: 2.1rem;
}
.price-label {
font-size: 0.75rem;
font-size: 0.62rem;
}
.price-value {
font-size: 0.9rem;
font-size: 0.95rem;
}
.price-value.total {
font-size: 1rem;
padding: 4px 12px;
}
.controls-column {
padding-top: 18px;
.price-total-chip {
font-size: 0.8rem;
height: 24px;
}
.quantity-input {
max-width: 70px;
min-width: 70px;
}
.qty-btn {
min-width: 34px !important;
width: 34px !important;
height: 34px !important;
max-width: 58px;
min-width: 58px;
}
}
/* ===== RESOLUCIÓN ≥960px (Desktop) ===== */
/* Desktop (≥ 960px) */
@media (min-width: 960px) {
.product-card {
border-radius: 12px;
}
.product-card:hover {
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.15);
transform: translateY(-4px);
}
.product-card:hover .product-img {
transform: scale(1.05);
}
.product-card-row {
padding: 16px;
align-items: center;
}
.image-column {
padding: 0 24px 0 0;
margin-bottom: 0;
max-width: 380px;
}
.product-img {
width: 350px;
height: 350px;
}
.details-column {
padding: 0 24px 0 0;
border-right: 1px solid #e0e0e0;
flex: 1;
align-items: flex-start;
min-width: 0;
}
.product-details-content {
gap: 16px;
align-items: flex-start;
min-width: 0;
.product-content {
padding: 12px 10px;
}
.product-name {
font-size: 1.15rem;
text-align: left;
}
.product-description {
font-size: 0.9rem;
text-align: left;
}
.prices {
align-items: flex-start;
gap: 10px;
}
.price-unit,
.price-total {
justify-content: flex-start;
font-size: 0.95rem;
min-height: 2.2rem;
}
.price-label {
font-size: 0.75rem;
font-size: 0.63rem;
}
.price-value {
font-size: 0.95rem;
font-size: 1rem;
}
.price-value.total {
font-size: 1.05rem;
padding: 4px 12px;
.price-total-chip {
font-size: 0.85rem;
height: 25px;
padding: 0 11px;
}
.controls-column {
justify-content: flex-end;
margin-top: 0;
padding: 0 0 0 16px;
min-width: 180px;
}
.quantity-controls {
gap: 8px;
.product-actions {
padding: 6px;
padding-bottom: 8px;
}
.quantity-input {
max-width: 70px;
min-width: 70px;
max-width: 58px;
min-width: 58px;
}
.qty-btn {
min-width: 36px !important;
width: 36px !important;
height: 36px !important;
}
.qty-btn:hover {
transform: scale(1.1);
width: 32px;
height: 32px;
}
}
/* ===== RESOLUCIÓN ≥1280px (Desktop Large) ===== */
/* Desktop Large (≥ 1280px) */
@media (min-width: 1280px) {
.product-card-row {
padding: 20px;
}
.image-column {
padding-right: 28px;
}
.details-column {
padding-right: 28px;
.product-content {
padding: 14px 12px;
}
.product-name {
font-size: 1.2rem;
font-size: 2rem;
min-height: 2.4rem;
}
.product-description {
font-size: 0.95rem;
.price-value {
font-size: 1.05rem;
}
.controls-column {
padding-left: 20px;
.price-total-chip {
font-size: 0.9rem;
height: 26px;
}
}
/* Desktop Extra Large (≥ 1920px) */
@media (min-width: 1920px) {
.product-name {
font-size: 1.05rem;
min-height: 2.5rem;
}
.price-value {
font-size: 1.1rem;
}
}
</style>

View File

@@ -7,7 +7,7 @@
<v-card-title
class="d-flex align-center cart-title"
:class="{ 'cart-header-mobile': isMobile, 'cart-header-desktop': !isMobile }"
>
@click="isMobile && $emit('toggle-collapse')">
<!-- Icono del carrito - SIEMPRE VISIBLE -->
<v-icon class="mr-2">mdi-cart</v-icon>
@@ -47,7 +47,7 @@
icon
size="small"
variant="text"
@click="$emit('toggle-collapse')"
@click.stop="$emit('toggle-collapse')"
:title="isCollapsed ? 'Expandir carrito' : 'Contraer carrito'"
>
<v-icon>{{ isCollapsed ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
@@ -133,7 +133,7 @@
<v-card-actions v-if="cartItems.length > 0" class="cart-checkout-section">
<v-btn color="primary" block @click="$emit('checkout')">
Finalizar Compra
Finalizar Pedido
</v-btn>
</v-card-actions>
</div>

View File

@@ -1,13 +1,14 @@
<template>
<v-app>
<div>
<NavBar />
<v-main>
<router-view />
</v-main>
<AppFooter />
</v-app>
</div>
</template>
<script setup>
//
import NavBar from '@/components/NavBar.vue';
import AppFooter from '@/components/AppFooter.vue';
</script>

View File

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

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

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

@@ -3,5 +3,5 @@
</template>
<script setup>
//
import Login from '@/components/Login.vue'
</script>

View File

@@ -2,46 +2,76 @@
<v-container fluid>
<!-- Backdrop para mobile cuando el carrito está expandido -->
<div
v-if="isMobile && !cartCollapsed"
v-if="isMobile && !cartCollapsed && isAuthenticated"
class="cart-backdrop"
@click="cartCollapsed = true"
></div>
<v-row>
<v-col cols="12" md="9" lg="7" :class="{ 'pb-mobile-cart': isMobile }">
<v-card-title>
<span class="headline">Catálogo</span>
</v-card-title>
<!-- Controles de paginación superiores -->
<PaginationControls
v-if="items.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"
/>
<!-- Lista de productos paginados -->
<v-list-item
v-for="item in paginatedItems"
:key="item.id"
class="catalog-item"
<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"
>
<Card
:product="item"
:increase="increase"
:decrease="decrease"
:currency="currency"
:updateQuantity="updateQuantity"
@add-to-cart="addToCart"
/>
</v-list-item>
<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>
<!-- 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"
:disabled="!isAuthenticated"
@add-to-cart="addToCart"
@request-login="showLogin"
/>
</v-col>
</v-row>
<!-- Mensaje cuando no hay productos -->
<v-alert
@@ -52,10 +82,18 @@
>
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="items.length > 0"
v-if="filteredItems.length > 0"
:current-page="currentPage"
:total-pages="totalPages"
:items-per-page="itemsPerPage"
@@ -68,12 +106,13 @@
/>
</v-col>
<v-col cols="12" md="3" lg="5">
<v-col cols="12" md="2" lg="3">
<div
class="cart-sidebar"
:class="{ collapsed: cartCollapsed && isMobile }"
:class="{ 'cart-is-collapsed': cartCollapsed && isMobile }"
>
<Cart
v-if="isAuthenticated"
:cart-items="cartItems"
:currency="currency"
:is-collapsed="cartCollapsed"
@@ -84,6 +123,63 @@
@update-quantity="updateCartQuantity"
@toggle-collapse="toggleCart"
/>
<v-card v-else-if="!isMobile || showLoginPrompt" class="login-prompt-card pa-4 text-center">
<v-btn
icon
size="x-small"
variant="text"
class="login-prompt-close"
@click="showLoginPrompt = false"
>
<v-icon size="18">mdi-close</v-icon>
</v-btn>
<v-icon size="48" color="primary" class="mb-2">mdi-cart-lock</v-icon>
<p class="text-body-2 text-medium-emphasis mb-3">
Para hacer pedidos debes de tener usuario y clave, si no lo tienes comunicate con nosotros al
<strong class="text-primary">{{ contactPhone }}</strong>
para solicitarlo. Esto es necesario porque no podemos procesar tus pedidos todos los días del mes.
Comunicate con nosotros para contarte todos los detalles y que puedas apoyar este proceso.
</p>
<div class="d-flex justify-center ga-2 mb-3">
<v-btn
icon
size="small"
color="primary"
variant="tonal"
:href="'tel:' + contactPhone"
>
<v-icon size="18">mdi-phone</v-icon>
</v-btn>
<v-btn
icon
size="small"
color="success"
variant="tonal"
:href="'https://wa.me/' + contactPhone"
target="_blank"
>
<v-icon size="18">mdi-whatsapp</v-icon>
</v-btn>
<v-btn
icon
size="small"
color="info"
variant="tonal"
:href="'https://t.me/+57' + contactPhone"
target="_blank"
>
<v-icon size="18">mdi-send</v-icon>
</v-btn>
</div>
<v-btn
color="primary"
variant="tonal"
prepend-icon="mdi-login"
@click="openLoginDialog"
>
Iniciar Sesión
</v-btn>
</v-card>
</div>
</v-col>
</v-row>
@@ -91,7 +187,7 @@
<!-- 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-title class="headline">Confirmar Pedido</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">
@@ -126,7 +222,7 @@
</v-card>
</v-dialog>
<!-- Modal 2: Datos personales -->
<!-- Modal 2: Datos personales + aviso de coordinación -->
<v-dialog v-model="personalDataDialog" max-width="500" persistent>
<v-card>
<v-card-title class="headline">Datos de Contacto</v-card-title>
@@ -163,6 +259,48 @@
variant="outlined"
></v-select>
</v-form>
<v-divider class="my-3"></v-divider>
<v-alert type="info" variant="tonal" class="mb-0">
<div class="text-body-2 mb-2">
Para coordinar la entrega de tu pedido escríbenos:
</div>
<div class="d-flex ga-2">
<v-btn
icon
size="x-small"
color="primary"
variant="tonal"
:href="'tel:' + contactPhone"
>
<v-icon size="16">mdi-phone</v-icon>
</v-btn>
<v-btn
icon
size="x-small"
color="success"
variant="tonal"
:href="'https://wa.me/57' + contactPhone"
target="_blank"
>
<v-icon size="16">mdi-whatsapp</v-icon>
</v-btn>
<v-btn
icon
size="x-small"
color="info"
variant="tonal"
:href="'https://t.me/+57' + contactPhone"
target="_blank"
>
<v-icon size="16">mdi-send</v-icon>
</v-btn>
<span class="text-body-2 font-weight-bold ml-1 d-flex align-center">
{{ contactPhone }}
</span>
</div>
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
@@ -174,11 +312,17 @@
:loading="isSubmitting"
:disabled="isSubmitting"
>
Finalizar Compra
Finalizar Pedido
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Login Dialog -->
<LoginDialog
ref="loginDialogRef"
@login-success="onLoginSuccess"
/>
</v-container>
</template>
@@ -186,18 +330,24 @@
import Card from "@/components/catalog/Card.vue";
import Cart from "@/components/catalog/Cart.vue";
import PaginationControls from "@/components/catalog/PaginationControls.vue";
import LoginDialog from "@/components/LoginDialog.vue";
import { useCartStore } from "@/stores/cart";
import { useAuthStore } from "@/stores/auth";
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,
LoginDialog,
},
setup() {
const cartStore = useCartStore();
const cartCollapsed = ref(false); // Cambiado a false para que inicie expandido en desktop
const authStore = useAuthStore();
const cartCollapsed = ref(false);
const showLoginPrompt = ref(false);
const windowWidth = ref(window.innerWidth);
const isMobile = computed(() => windowWidth.value < 960); // Cambiado de 680 a 960
@@ -216,7 +366,9 @@ export default {
return {
cartStore,
authStore,
cartCollapsed,
showLoginPrompt,
isMobile,
windowWidth,
};
@@ -225,6 +377,7 @@ export default {
return {
api: inject("api"),
items: [],
searchQuery: "",
// Paginación
currentPage: 1,
itemsPerPage: 20,
@@ -257,14 +410,29 @@ export default {
cartCount() {
return this.cartStore.cartCount;
},
isAuthenticated() {
return this.authStore.isAuthenticated;
},
contactPhone() {
return import.meta.env.VITE_CONTACT_PHONE || '';
},
// Búsqueda
filteredItems() {
if (!this.searchQuery) return this.items;
const normalize = (s) => s.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
const query = normalize(this.searchQuery);
return this.items.filter((item) =>
normalize(item.name).includes(query),
);
},
// Paginación
paginatedItems() {
const start = (this.currentPage - 1) * this.itemsPerPage;
const end = start + this.itemsPerPage;
return this.items.slice(start, end);
return this.filteredItems.slice(start, end);
},
totalPages() {
return Math.ceil(this.items.length / this.itemsPerPage);
return Math.ceil(this.filteredItems.length / this.itemsPerPage);
},
paginationInfo() {
const start = (this.currentPage - 1) * this.itemsPerPage + 1;
@@ -275,7 +443,7 @@ export default {
return {
start,
end,
total: this.items.length,
total: this.filteredItems.length,
};
},
// Computed para total-visible dinámico y responsive (usado por ambos PaginationControls)
@@ -306,6 +474,11 @@ export default {
this.loadItemsPerPagePreference();
this.fetchProducts();
},
watch: {
searchQuery() {
this.currentPage = 1;
},
},
methods: {
fetchProducts() {
this.api
@@ -314,9 +487,7 @@ export default {
this.items = data.map((product) => ({
...product,
quantity: 0,
img:
product.img ||
`https://picsum.photos/300/200?random=${product.id}`,
img: (product.catalogue_images?.length > 0) ? product.catalogue_images[0] : (product.img || not_image_product),
}));
})
.catch((error) => {
@@ -377,7 +548,6 @@ export default {
},
async onSubmitPurchase() {
const form = this.$refs.personalForm;
if (form) {
const { valid } = await form.validate();
if (!valid) return;
@@ -388,7 +558,7 @@ export default {
customer: 1,
notes: "",
payment_method: "CASH",
saleline_set: this.cartItems.map((item) => ({
catalogsaleline_set: this.cartItems.map((item) => ({
product: item.id,
unit_price: item.price,
quantity: item.quantity,
@@ -399,7 +569,6 @@ export default {
customer_phone: this.customerPhone,
pickup_method: this.pickupMethod,
};
this.api
.createCatalogPurchase(payload)
.then((data) => {
@@ -407,7 +576,10 @@ export default {
this.personalDataDialog = false;
this.$router.push({
path: "/summary_purchase",
query: { id: parseInt(data.id) },
query: {
id: parseInt(data.id),
type: 'catalog'
},
});
})
.catch((error) => {
@@ -445,14 +617,27 @@ export default {
}
},
scrollToTop() {
this.$nextTick(() => {
const catalogHeader = this.$el?.querySelector(".headline");
if (catalogHeader) {
catalogHeader.scrollIntoView({
behavior: "smooth",
block: "start",
});
}
const grid = this.$el?.querySelector(".product-grid");
if (grid) {
const top = grid.getBoundingClientRect().top + window.scrollY - 16;
window.scrollTo({ top, behavior: "smooth" });
} else {
window.scrollTo({ top: 0, behavior: "smooth" });
}
},
openLoginDialog() {
this.$refs.loginDialogRef.open();
},
showLogin() {
this.showLoginPrompt = true;
if (this.isMobile) {
this.cartCollapsed = false;
}
},
onLoginSuccess() {
this.showLoginPrompt = false;
this.api.getCurrentUser().then((user) => {
this.authStore.setUser(user);
});
},
currency(val) {
@@ -468,29 +653,97 @@ export default {
</script>
<style scoped>
.headline {
font-weight: bold;
font-size: 1.25rem;
/* ============================================
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;
}
/* === LISTADO DE PRODUCTOS === */
.catalog-item {
padding: 0;
margin-bottom: 12px;
/* 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;
}
}
.catalog-item:last-child {
margin-bottom: 0;
@media (max-width: 559px) {
.page-header {
padding: 12px 16px !important;
}
}
/* Mobile First: Estilos base (< 960px) */
/* 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: 0;
bottom: var(--footer-height);
left: 0;
right: 0;
z-index: 1000;
transition: all 0.3s ease-in-out;
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 {
@@ -501,7 +754,7 @@ export default {
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 999;
animation: fadeIn 0.3s ease-in-out;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
@@ -513,21 +766,74 @@ export default {
}
}
/* Padding para el contenido del catálogo en mobile */
/* Espacio inferior para evitar que productos queden ocultos bajo el cart */
.pb-mobile-cart {
padding-bottom: 76px !important; /* 60px sidebar + 16px margen */
padding-bottom: 100px !important;
}
/* Desktop (≥ 960px) - Sidebar sticky con scroll */
/* Layout: 960-1023px = md breakpoint (75% productos / 25% cart) */
/* Layout: ≥1024px = lg breakpoint (60% productos / 40% cart) */
/* ============================================
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: 80px;
z-index: auto;
max-height: calc(100vh - 100px);
overflow-y: visible;
top: 96px;
z-index: 1;
max-height: calc(100vh - 120px);
overflow-y: auto;
box-shadow: none;
border-radius: 12px;
transform: none !important;
}
.cart-backdrop {
@@ -537,30 +843,28 @@ export default {
.pb-mobile-cart {
padding-bottom: 16px !important;
}
.catalog-item {
margin-bottom: 16px;
}
}
/* Resolución <560px - Mobile extra small */
@media (max-width: 559px) {
.headline {
font-size: 1.1rem;
}
.catalog-item {
margin-bottom: 10px;
}
/* ============================================
LOGIN PROMPT CARD
============================================ */
.login-prompt-card {
border: 2px dashed rgba(0, 0, 0, 0.12);
background: #fafafa !important;
position: relative;
overflow-wrap: break-word;
word-break: break-word;
}
/* Resolución 560-959px - Tablet */
@media (min-width: 560px) and (max-width: 959px) {
.catalog-item {
margin-bottom: 14px;
}
.login-prompt-close {
position: absolute;
top: 8px;
right: 8px;
}
/* ============================================
MODALES
============================================ */
.product-list-scroll {
max-height: 400px;
overflow-y: auto;

View File

@@ -1,14 +1,16 @@
<template>
<AdminPurchase v-if="authStore.isAdmin"/>
<Purchase v-if="authStore.isAdmin" :isAdmin="true" />
</template>
<script >
import { useAuthStore } from '@/stores/auth';
<script setup>
import Purchase from '@/components/Purchase.vue';
import { useAuthStore } from '@/stores/auth';
export default {
setup() {
const authStore = useAuthStore();
return { authStore };
},
const authStore = useAuthStore();
definePage({
meta: {
requiresAuth: true
}
})
</script>

View File

@@ -1,8 +1,10 @@
<template>
<Purchase />
<Purchase :isAdmin="false" />
</template>
<script setup>
import Purchase from '@/components/Purchase.vue';
definePage({
meta: {
requiresAuth: true

View File

@@ -3,4 +3,5 @@
</template>
<script setup>
import Wellcome from '@/components/Wellcome.vue'
</script>

View File

@@ -0,0 +1,120 @@
<template>
<v-container v-if="authStore.isAdmin" class="fill-height">
<v-row v-if="!result && !loading" justify="center">
<v-col cols="12" md="8">
<v-card class="pa-6" elevation="4">
<v-card-title class="text-h5 font-weight-bold text-center">
🔄 Sincronización de Ventas de Catálogo
</v-card-title>
<v-card-text>
<p>
Esta acción sincronizará las <strong>ventas de catálogo</strong> desde el sistema
<strong>Tryton</strong> hacia la plataforma.
</p>
<v-alert type="warning" dense border="start" border-color="warning" class="mt-4">
<strong>Advertencia:</strong> Este proceso podría tardar varios minutos
y reemplazar datos existentes en la plataforma.
Asegúrese de que la información en Tryton esté actualizada antes de
continuar.
</v-alert>
</v-card-text>
<v-card-actions class="justify-center">
<v-btn color="primary" @click="startSync">
Iniciar Sincronización
</v-btn>
<v-btn text @click="$router.push('/')">
Cancelar
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
<v-row v-else-if="loading" justify="center" align="center">
<v-col cols="12" class="text-center">
<v-progress-circular indeterminate color="primary" size="64"></v-progress-circular>
<p class="mt-4 text-h6">Sincronizando ventas de catálogo...</p>
</v-col>
</v-row>
<v-row v-else>
<v-col cols="12">
<v-alert type="success" variant="tonal" class="mb-4">
<strong>Sincronización completada</strong>
</v-alert>
</v-col>
<v-col cols="12" md="6">
<v-card elevation="2">
<v-card-title class="bg-error text-white"> Fallidos ({{ result.failed?.length || 0 }})</v-card-title>
<v-card-text>
<v-data-table
:items="formatItems(result.failed)"
density="compact"
:headers="[{ title: 'ID', key: 'id' }]"
></v-data-table>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card elevation="2">
<v-card-title class="bg-success text-white"> Exitosos ({{ result.successful?.length || 0 }})</v-card-title>
<v-card-text>
<v-data-table
:items="formatItems(result.successful)"
density="compact"
:headers="[{ title: 'ID', key: 'id' }]"
></v-data-table>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" class="text-center mt-4">
<v-btn color="primary" @click="$router.push('/')">
Volver al inicio
</v-btn>
</v-col>
</v-row>
</v-container>
</template>
<script>
import { useAuthStore } from '@/stores/auth';
import { inject } from 'vue';
export default {
name: 'CatalogSalesToTryton',
setup() {
const authStore = useAuthStore();
return { authStore };
},
data() {
return {
api: inject('api'),
loading: false,
result: null,
}
},
methods: {
formatItems(ids) {
if (!ids || ids.length === 0) return [];
return ids.map(id => ({ id }));
},
startSync() {
this.loading = true;
this.api.sendCatalogSalesToTryton()
.then(response => {
this.result = response;
this.loading = false;
})
.catch(error => {
console.error('Error al sincronizar ventas de catálogo:', error);
this.loading = false;
});
}
}
}
</script>

View File

@@ -1,5 +1,5 @@
<template>
<SummaryPurchase :id="$route.query.id"/>
<SummaryPurchase :id="$route.query.id" :type="$route.query.type"/>
</template>
<script setup>

View File

@@ -11,6 +11,8 @@ import { setupLayouts } from 'virtual:generated-layouts'
import { routes } from 'vue-router/auto-routes'
import { useAuthStore } from '@/stores/auth'
const PUBLICO_RESTRICTED = ['/comprar']
const ADMIN_ROUTES = [
'/sincronizar_clientes_tryton',
'/sincronizar_ventas_tryton',
@@ -19,6 +21,9 @@ const ADMIN_ROUTES = [
'/cuadres_de_tarro',
'/compra_admin',
'/cuadrar_tarro',
'/admin/products',
'/admin/catalog-sales',
'/admin/catalogue-images',
]
const router = createRouter({
@@ -35,8 +40,10 @@ router.beforeEach((to, from, next) => {
if (requiresAuth && !isAuthenticated) {
next({ path: '/autenticarse', replace: true })
} else if (requiresAdmin && !authStore.isAdmin) {
} else if (requiresAdmin && !authStore.isAdmin && authStore.user) {
next({ path: '/', replace: true })
} else if (authStore.user?.role === 'publico' && PUBLICO_RESTRICTED.includes(to.path)) {
next({ path: '/catalog', replace: true })
} else {
next()
}

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() {
@@ -19,6 +23,10 @@ class Api {
return this.apiImplementation.getSummaryPurchase(purchaseId);
}
getSummaryCatalogPurchase(purchaseId) {
return this.apiImplementation.getSummaryCatalogPurchase(purchaseId);
}
getPurchasesForReconciliation() {
return this.apiImplementation.getPurchasesForReconciliation();
}
@@ -32,11 +40,11 @@ class Api {
}
createPurchase(purchase) {
return this.apiImplementation.createCatalogPurchase(purchase);
return this.apiImplementation.createPurchase(purchase);
}
createCatalogPurchase(purchase) {
return this.apiImplementation.createPurchase(purchase);
return this.apiImplementation.createCatalogPurchase(purchase);
}
createReconciliationJar(reconciliation) {
@@ -63,9 +71,33 @@ class Api {
return this.apiImplementation.sendSalesToTryton();
}
sendCatalogSalesToTryton() {
return this.apiImplementation.sendCatalogSalesToTryton();
}
getCatalogSales() {
return this.apiImplementation.getCatalogSales();
}
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;

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";
@@ -36,6 +51,12 @@ class DjangoApi {
return this.getRequest(url);
}
getSummaryCatalogPurchase(purchaseId) {
const url =
this.base + `/don_confiao/resumen_compra_catalogo_json/${purchaseId}`;
return this.getRequest(url);
}
getPurchasesForReconciliation() {
const url = this.base + "/don_confiao/purchases/for_reconciliation";
return this.getRequest(url);
@@ -95,10 +116,44 @@ class DjangoApi {
return this.postRequest(url, {});
}
sendCatalogSalesToTryton() {
const url = this.base + "/don_confiao/api/enviar_catalog_sales_a_tryton";
return this.postRequest(url, {});
}
getCatalogSales() {
const url = this.base + "/don_confiao/api/catalog_sales/";
return this.getRequest(url);
}
getCurrentUser() {
const url = this.base + "/api/users/me/";
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;

View File

@@ -1,44 +1,45 @@
import axios from 'axios';
import AuthService from '@/services/auth';
import axios from "axios";
import AuthService from "@/services/auth";
import router from "@/router";
const http = axios.create({
baseURL: import.meta.env.VITE_DJANGO_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
baseURL: import.meta.env.VITE_DJANGO_BASE_URL,
headers: {
"Content-Type": "application/json",
},
});
http.interceptors.request.use(
config => {
const token = AuthService.getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
error => Promise.reject(error)
(config) => {
const token = AuthService.getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error),
);
http.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const newAccess = await AuthService.refresh();
originalRequest.headers.Authorization = `Bearer ${newAccess}`;
return http.request(originalRequest);
} catch (refreshError) {
AuthService.logout();
window.location.href = '/autenticarse';
return Promise.reject(refreshError);
}
}
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const newAccess = await AuthService.refresh();
originalRequest.headers.Authorization = `Bearer ${newAccess}`;
return http.request(originalRequest);
} catch (refreshError) {
AuthService.logout();
router.push("/autenticarse");
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
return Promise.reject(error);
},
);
export default http;

View File

@@ -6,6 +6,7 @@ export const useAuthStore = defineStore('auth', {
}),
getters: {
isAdmin: (state) => state.user?.role === 'administrator',
isPublico: (state) => state.user?.role === 'publico',
isAuthenticated: (state) => !!state.user
},
actions: {