26 Commits

Author SHA1 Message Date
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
28 changed files with 2683 additions and 947 deletions

View File

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

@@ -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> <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>
<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-form ref="loginForm" @submit.prevent="onSubmit"> <v-form ref="loginForm" @submit.prevent="onSubmit">
<v-text-field <v-text-field
v-model="username" v-model="username"
label="Usuario" label="Usuario"
prepend-inner-icon="mdi-account"
:rules="[requiredRule]" :rules="[requiredRule]"
variant="outlined"
required required
class="mb-2"
autocomplete="username"
/> />
<v-text-field <v-text-field
v-model="password" v-model="password"
label="Contraseña" label="Contraseña"
prepend-inner-icon="mdi-lock"
type="password" type="password"
:rules="[requiredRule]" :rules="[requiredRule]"
variant="outlined"
required required
class="mb-4"
autocomplete="current-password"
/> />
<v-btn type="submit" color="primary">Entrar</v-btn> <v-btn
type="submit"
color="primary"
size="large"
block
:loading="isSubmitting"
:disabled="isSubmitting"
>
Entrar
</v-btn>
<v-alert v-if="error" type="error" class="mt-2">{{ error }}</v-alert> <v-alert
v-if="error"
type="error"
variant="tonal"
class="mt-4"
closable
@click:close="error = ''"
>
{{ error }}
</v-alert>
</v-form> </v-form>
</div>
</v-sheet>
</v-container>
</template> </template>
<script> <script setup>
import AuthService from '@/services/auth'; import { ref } from 'vue'
import { useRouter } from 'vue-router'
import AuthService from '@/services/auth'
import logo from '@/assets/logo_colorful.png'
export default { const router = useRouter()
name: 'DonConfiao',
data() { const username = ref('')
return { const password = ref('')
username: '', const error = ref('')
password: '', const isSubmitting = ref(false)
error: '', const loginForm = ref(null)
};
},
methods: { function requiredRule(value) {
requiredRule(value) { return !!value || 'Este campo es obligatorio'
return !!value || 'Este campo es obligatorio'; }
},
async onSubmit() { async function onSubmit() {
this.error = ''; error.value = ''
isSubmitting.value = true
const form = this.$refs.loginForm;
const isValid = await form.validate();
if (!isValid) return;
if (!this.username || !this.password) {
this.error = 'Usuario y contraseña son obligatorios';
return;
}
try { try {
await AuthService.login({ const form = loginForm.value
username: this.username, if (form) {
password: this.password, const { valid } = await form.validate()
}); if (!valid) return
this.$router.push({ path: '/' });
} catch (e) {
const msg = e?.response?.data?.message ?? e.message;
this.error = msg ?? 'Error al iniciar sesión';
} }
},
}, if (!username.value || !password.value) {
}; error.value = 'Usuario y contraseña son obligatorios'
return
}
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> </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,5 +1,5 @@
<template> <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-app-bar-nav-icon variant="text" @click.stop="drawer = !drawer"></v-app-bar-nav-icon>
<v-toolbar-title>Menu</v-toolbar-title> <v-toolbar-title>Menu</v-toolbar-title>
<v-spacer></v-spacer> <v-spacer></v-spacer>
@@ -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 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-item v-if="isAuthenticated && isAdmin && showAdminMenu">
<v-list> <v-list>
<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-list-item
v-for="item in menuAdminItems" v-else
:key="item.title"
:title="item.title" :title="item.title"
:prepend-icon="item.icon" :prepend-icon="item.icon"
@click="navigateAdmin(item.route)" @click="navigateAdmin(item.route)"
></v-list-item> ></v-list-item>
</template>
</v-list> </v-list>
</v-list-item> </v-list-item>
</v-list> </v-list>
@@ -77,7 +80,6 @@
</template> </template>
<script> <script>
import trytonIcon from '../assets/icons/tryton-icon.svg';
import AuthService from '@/services/auth'; import AuthService from '@/services/auth';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { inject } from 'vue'; import { inject } from 'vue';
@@ -104,9 +106,15 @@
{ title: 'Cuadres de tarro', route: '/cuadres_de_tarro', icon: 'mdi-chart-bar'}, { title: 'Cuadres de tarro', route: '/cuadres_de_tarro', icon: 'mdi-chart-bar'},
{ title: 'CSV Tryton', route: '/ventas_para_tryton', icon: 'mdi-file-table'}, { title: 'CSV Tryton', route: '/ventas_para_tryton', icon: 'mdi-file-table'},
{ title: 'Compra adm', route: '/compra_admin', icon: 'mdi-cart'}, { title: 'Compra adm', route: '/compra_admin', icon: 'mdi-cart'},
{ title: 'Actualizar Productos De Tryton', route: '/sincronizar_productos_tryton', icon: 'trytonIcon'}, { title: 'Gestión de Productos', route: '/admin/products', icon: 'mdi-package-variant'},
{ title: 'Actualizar Clientes De Tryton', route: '/sincronizar_clientes_tryton', icon: 'trytonIcon'}, { title: 'Imágenes de Catálogo', route: '/admin/catalogue-images', icon: 'mdi-image-multiple'},
{ title: 'Actualizar Ventas Tryton', route: '/sincronizar_ventas_tryton', icon: 'trytonIcon'} { 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: { computed: {
@@ -163,3 +171,4 @@
} }
} }
</script> </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

@@ -13,10 +13,15 @@
<!-- Loading --> <!-- Loading -->
<template v-if="loading"> <template v-if="loading">
<v-skeleton-loader <v-sheet class="d-flex flex-column align-center justify-center pa-12 rounded-lg" elevation="2">
class="mb-4 rounded-lg" <v-progress-circular
type="card-heading, list-item-two-line, list-item-two-line" indeterminate
></v-skeleton-loader> 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>
<template v-else> <template v-else>
@@ -168,7 +173,7 @@
:rules="[rules.required]" :rules="[rules.required]"
prefix="$" prefix="$"
required required
readonly :readonly="!isAdmin"
variant="outlined" variant="outlined"
density="compact" density="compact"
hide-details="auto" hide-details="auto"
@@ -246,7 +251,7 @@
<v-row align="center"> <v-row align="center">
<v-col cols="12" md="6"> <v-col cols="12" md="6">
<v-select <v-select
:items="payment_methods" :items="payment_methods || []"
v-model="purchase.payment_method" v-model="purchase.payment_method"
item-title="text" item-title="text"
item-value="value" item-value="value"
@@ -325,7 +330,11 @@
CasherModal, CasherModal,
}, },
props: { props: {
msg: String msg: String,
isAdmin: {
type: Boolean,
default: false
}
}, },
data() { data() {
return { return {
@@ -337,7 +346,7 @@
show_alert_purchase: false, show_alert_purchase: false,
client_search: '', client_search: '',
product_search: '', product_search: '',
payment_methods: null, payment_methods: [],
purchase: { purchase: {
date: this.getCurrentDate(), date: this.getCurrentDate(),
customer: null, customer: null,
@@ -471,15 +480,13 @@
formatPrice(price) { formatPrice(price) {
return new Intl.NumberFormat('es-CO', { style: 'currency', currency: 'COP', minimumFractionDigits: 0 }).format(price); return new Intl.NumberFormat('es-CO', { style: 'currency', currency: 'COP', minimumFractionDigits: 0 }).format(price);
}, },
},
mounted() {
this.fetchClients();
} }
}; };
</script> </script>
<style> <style>
.page-header { .page-header {
background: linear-gradient(135deg, #1565C0 0%, #0D47A1 100%); background: linear-gradient(135deg, #1565C0 0%, #0D47A1 100%) !important;
color: white !important;
} }
.product-line:nth-child(odd) { .product-line:nth-child(odd) {

View File

@@ -72,7 +72,8 @@
name: 'SummaryPurchase', name: 'SummaryPurchase',
props: { props: {
msg: String, msg: String,
id: Number id: Number,
type: String
}, },
data () { data () {
return { return {
@@ -102,7 +103,11 @@
}, },
methods: { methods: {
fetchPurchase(purchaseId) { fetchPurchase(purchaseId) {
this.api.getSummaryPurchase(purchaseId) const apiMethod = this.type === 'catalog'
? this.api.getSummaryCatalogPurchase(purchaseId)
: this.api.getSummaryPurchase(purchaseId);
apiMethod
.then(data => { .then(data => {
this.purchase = data; this.purchase = data;
}) })

View File

@@ -3,17 +3,19 @@
<v-sheet <v-sheet
class="hero-section d-flex align-center justify-center text-center pa-8" class="hero-section d-flex align-center justify-center text-center pa-8"
> >
<div> <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 <v-img
:src="logo" :src="logo"
alt="Don Confiao" alt="Don Confiao"
max-width="180" max-width="180"
class="mx-auto mb-4" class="mx-auto mb-4"
/> />
<h1 class="text-h4 font-weight-bold text-white mb-2"> <h1 class="text-h4 font-weight-bold mb-2">Don Confiao te atiende</h1>
Don Confiao te atiende <p class="text-subtitle-1 font-italic font-weight-bold">
</h1>
<p class="text-subtitle-1 font-italic font-weight-bold text-white">
Economía solidaria, mercado justo, alimentación sana Economía solidaria, mercado justo, alimentación sana
</p> </p>
</div> </div>
@@ -27,10 +29,14 @@
<template #prepend> <template #prepend>
<v-icon color="green" size="48">mdi-hand-heart</v-icon> <v-icon color="green" size="48">mdi-hand-heart</v-icon>
</template> </template>
<v-card-title class="font-weight-bold">Nuestra Tienda</v-card-title> <v-card-title class="font-weight-bold"
>Nuestra Tienda</v-card-title
>
</v-card-item> </v-card-item>
<v-card-text> <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. 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-text>
</v-card> </v-card>
</v-col> </v-col>
@@ -39,14 +45,23 @@
<v-card class="h-100" elevation="2"> <v-card class="h-100" elevation="2">
<v-card-item> <v-card-item>
<template #prepend> <template #prepend>
<v-icon color="orange-darken-2" size="48">mdi-progress-wrench</v-icon> <v-icon color="orange-darken-2" size="48"
>mdi-progress-wrench</v-icon
>
</template> </template>
<v-card-title class="font-weight-bold">En Desarrollo</v-card-title> <v-card-title class="font-weight-bold"
>En Desarrollo</v-card-title
>
</v-card-item> </v-card-item>
<v-card-text> <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. 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"> <v-alert type="warning" class="mt-3" density="compact">
Si no vas a pagar tu compra recuerda que debes hacerlo en la planilla manual Si no vas a pagar tu compra recuerda que debes hacerlo en la
planilla manual
</v-alert> </v-alert>
</v-card-text> </v-card-text>
</v-card> </v-card>
@@ -61,7 +76,8 @@
<v-card-title class="font-weight-bold">Catálogo</v-card-title> <v-card-title class="font-weight-bold">Catálogo</v-card-title>
</v-card-item> </v-card-item>
<v-card-text> <v-card-text>
Explora nuestro catálogo de productos disponibles. Encuentra todo lo que necesitas y arma tu pedido fácilmente. Explora nuestro catálogo de productos disponibles. Encuentra todo
lo que necesitas y arma tu pedido fácilmente.
</v-card-text> </v-card-text>
</v-card> </v-card>
</v-col> </v-col>
@@ -71,16 +87,6 @@
<v-col cols="12" class="text-center"> <v-col cols="12" class="text-center">
<h2 class="text-h5 font-weight-bold mb-4">¿Qué deseas hacer?</h2> <h2 class="text-h5 font-weight-bold mb-4">¿Qué deseas hacer?</h2>
<div class="d-flex flex-wrap justify-center ga-4"> <div class="d-flex flex-wrap justify-center ga-4">
<v-btn
:to="{ path: 'comprar' }"
color="green"
size="x-large"
prepend-icon="mdi-cart"
variant="elevated"
class="px-8"
>
Ir a Comprar
</v-btn>
<v-btn <v-btn
:to="{ path: 'catalog' }" :to="{ path: 'catalog' }"
color="primary" color="primary"
@@ -91,6 +97,17 @@
> >
Ver Catálogo Ver Catálogo
</v-btn> </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> </div>
</v-col> </v-col>
</v-row> </v-row>
@@ -99,14 +116,176 @@
</template> </template>
<script setup> <script setup>
import ResaltedText from '@/components/ResaltedText.vue' import ResaltedText from "@/components/ResaltedText.vue";
import logo from '@/assets/logo.png' import { useAuthStore } from '@/stores/auth';
import logo from "@/assets/logo_colorful.png";
const authStore = useAuthStore();
</script> </script>
<style scoped> <style scoped>
.hero-section { .hero-section {
min-height: 320px; min-height: 500px; /* Un poco más de aire vertical */
background: linear-gradient(135deg, #1B5E20 0%, #2E7D32 30%, #E65100 100%) !important; display: flex;
color: white; 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> </style>

View File

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

View File

@@ -7,7 +7,7 @@
<v-card-title <v-card-title
class="d-flex align-center cart-title" class="d-flex align-center cart-title"
:class="{ 'cart-header-mobile': isMobile, 'cart-header-desktop': !isMobile }" :class="{ 'cart-header-mobile': isMobile, 'cart-header-desktop': !isMobile }"
> @click="isMobile && $emit('toggle-collapse')">
<!-- Icono del carrito - SIEMPRE VISIBLE --> <!-- Icono del carrito - SIEMPRE VISIBLE -->
<v-icon class="mr-2">mdi-cart</v-icon> <v-icon class="mr-2">mdi-cart</v-icon>

View File

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

View File

@@ -8,14 +8,50 @@
></div> ></div>
<v-row> <v-row>
<v-col cols="12" md="9" lg="7" :class="{ 'pb-mobile-cart': isMobile }"> <v-col cols="12" md="10" lg="9" :class="{ 'pb-mobile-cart': isMobile }">
<v-card-title> <v-sheet
<span class="headline">Catálogo</span> class="page-header d-flex align-center pa-3 pa-sm-4 pa-md-6 mb-3 mb-sm-4 rounded-lg"
</v-card-title> >
<v-icon size="28" color="primary" class="mr-2 d-sm-none flex-shrink-0"
>mdi-store</v-icon
>
<v-icon
size="36"
color="primary"
class="mr-3 d-none d-sm-inline flex-shrink-0"
>mdi-store</v-icon
>
<div
class="d-flex flex-column flex-sm-row align-start align-sm-center w-100 ga-2 ga-sm-4"
>
<div class="flex-shrink-0 d-none d-sm-block">
<h1
class="text-h6 text-sm-h5 text-md-h4 font-weight-bold text-primary mb-0"
>
Catálogo
</h1>
<p class="text-body-2 text-medium-emphasis mb-0">
Explora y agrega productos a tu compra
</p>
</div>
<v-spacer class="d-none d-sm-flex"></v-spacer>
<v-text-field
v-model="searchQuery"
prepend-inner-icon="mdi-magnify"
label="Buscar producto..."
variant="solo-filled"
density="compact"
clearable
hide-details
single-line
class="search-field flex-grow-1 flex-sm-grow-0"
/>
</div>
</v-sheet>
<!-- Controles de paginación superiores --> <!-- Controles de paginación superiores -->
<PaginationControls <PaginationControls
v-if="items.length > 0" v-if="filteredItems.length > 0"
:current-page="currentPage" :current-page="currentPage"
:total-pages="totalPages" :total-pages="totalPages"
:items-per-page="itemsPerPage" :items-per-page="itemsPerPage"
@@ -27,11 +63,16 @@
position="top" position="top"
/> />
<!-- Lista de productos paginados --> <!-- Grid de productos paginados -->
<v-list-item <v-row class="product-grid" v-if="paginatedItems.length > 0">
<v-col
v-for="item in paginatedItems" v-for="item in paginatedItems"
:key="item.id" :key="item.id"
class="catalog-item" cols="12"
sm="6"
md="6"
lg="4"
class="product-col"
> >
<Card <Card
:product="item" :product="item"
@@ -41,7 +82,8 @@
:updateQuantity="updateQuantity" :updateQuantity="updateQuantity"
@add-to-cart="addToCart" @add-to-cart="addToCart"
/> />
</v-list-item> </v-col>
</v-row>
<!-- Mensaje cuando no hay productos --> <!-- Mensaje cuando no hay productos -->
<v-alert <v-alert
@@ -52,10 +94,18 @@
> >
No hay productos disponibles en el catálogo No hay productos disponibles en el catálogo
</v-alert> </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 --> <!-- Controles de paginación inferiores -->
<PaginationControls <PaginationControls
v-if="items.length > 0" v-if="filteredItems.length > 0"
:current-page="currentPage" :current-page="currentPage"
:total-pages="totalPages" :total-pages="totalPages"
:items-per-page="itemsPerPage" :items-per-page="itemsPerPage"
@@ -68,10 +118,10 @@
/> />
</v-col> </v-col>
<v-col cols="12" md="3" lg="5"> <v-col cols="12" md="2" lg="3">
<div <div
class="cart-sidebar" class="cart-sidebar"
:class="{ collapsed: cartCollapsed && isMobile }" :class="{ 'cart-is-collapsed': cartCollapsed && isMobile }"
> >
<Cart <Cart
:cart-items="cartItems" :cart-items="cartItems"
@@ -188,6 +238,7 @@ import Cart from "@/components/catalog/Cart.vue";
import PaginationControls from "@/components/catalog/PaginationControls.vue"; import PaginationControls from "@/components/catalog/PaginationControls.vue";
import { useCartStore } from "@/stores/cart"; import { useCartStore } from "@/stores/cart";
import { inject, ref, computed, onMounted, onUnmounted } from "vue"; import { inject, ref, computed, onMounted, onUnmounted } from "vue";
import not_image_product from "@/assets/not_image_for_product.jpeg";
export default { export default {
components: { components: {
@@ -225,6 +276,7 @@ export default {
return { return {
api: inject("api"), api: inject("api"),
items: [], items: [],
searchQuery: "",
// Paginación // Paginación
currentPage: 1, currentPage: 1,
itemsPerPage: 20, itemsPerPage: 20,
@@ -257,14 +309,22 @@ export default {
cartCount() { cartCount() {
return this.cartStore.cartCount; return this.cartStore.cartCount;
}, },
// Búsqueda
filteredItems() {
if (!this.searchQuery) return this.items;
const query = this.searchQuery.toLowerCase();
return this.items.filter((item) =>
item.name.toLowerCase().includes(query),
);
},
// Paginación // Paginación
paginatedItems() { paginatedItems() {
const start = (this.currentPage - 1) * this.itemsPerPage; const start = (this.currentPage - 1) * this.itemsPerPage;
const end = start + this.itemsPerPage; const end = start + this.itemsPerPage;
return this.items.slice(start, end); return this.filteredItems.slice(start, end);
}, },
totalPages() { totalPages() {
return Math.ceil(this.items.length / this.itemsPerPage); return Math.ceil(this.filteredItems.length / this.itemsPerPage);
}, },
paginationInfo() { paginationInfo() {
const start = (this.currentPage - 1) * this.itemsPerPage + 1; const start = (this.currentPage - 1) * this.itemsPerPage + 1;
@@ -275,7 +335,7 @@ export default {
return { return {
start, start,
end, end,
total: this.items.length, total: this.filteredItems.length,
}; };
}, },
// Computed para total-visible dinámico y responsive (usado por ambos PaginationControls) // Computed para total-visible dinámico y responsive (usado por ambos PaginationControls)
@@ -306,6 +366,11 @@ export default {
this.loadItemsPerPagePreference(); this.loadItemsPerPagePreference();
this.fetchProducts(); this.fetchProducts();
}, },
watch: {
searchQuery() {
this.currentPage = 1;
},
},
methods: { methods: {
fetchProducts() { fetchProducts() {
this.api this.api
@@ -314,9 +379,7 @@ export default {
this.items = data.map((product) => ({ this.items = data.map((product) => ({
...product, ...product,
quantity: 0, quantity: 0,
img: img: (product.catalogue_images?.length > 0) ? product.catalogue_images[0] : (product.img || not_image_product),
product.img ||
`https://picsum.photos/300/200?random=${product.id}`,
})); }));
}) })
.catch((error) => { .catch((error) => {
@@ -388,7 +451,7 @@ export default {
customer: 1, customer: 1,
notes: "", notes: "",
payment_method: "CASH", payment_method: "CASH",
saleline_set: this.cartItems.map((item) => ({ catalogsaleline_set: this.cartItems.map((item) => ({
product: item.id, product: item.id,
unit_price: item.price, unit_price: item.price,
quantity: item.quantity, quantity: item.quantity,
@@ -407,7 +470,10 @@ export default {
this.personalDataDialog = false; this.personalDataDialog = false;
this.$router.push({ this.$router.push({
path: "/summary_purchase", path: "/summary_purchase",
query: { id: parseInt(data.id) }, query: {
id: parseInt(data.id),
type: 'catalog'
},
}); });
}) })
.catch((error) => { .catch((error) => {
@@ -446,7 +512,7 @@ export default {
}, },
scrollToTop() { scrollToTop() {
this.$nextTick(() => { this.$nextTick(() => {
const catalogHeader = this.$el?.querySelector(".headline"); const catalogHeader = this.$el?.querySelector(".page-header");
if (catalogHeader) { if (catalogHeader) {
catalogHeader.scrollIntoView({ catalogHeader.scrollIntoView({
behavior: "smooth", behavior: "smooth",
@@ -468,29 +534,97 @@ export default {
</script> </script>
<style scoped> <style scoped>
.headline { /* ============================================
font-weight: bold; CABECERA STICKY CON BÚSQUEDA
font-size: 1.25rem; ============================================ */
.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 === */ /* Mobile: Header sticky compensating for NavBar height, z-index menor que el cart */
.catalog-item { @media (max-width: 959px) {
padding: 0; .page-header {
margin-bottom: 12px; top: 64px;
border-radius: 0 !important;
z-index: 900;
}
} }
.catalog-item:last-child { @media (max-width: 559px) {
margin-bottom: 0; .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 { .cart-sidebar {
--footer-height: 40px;
position: fixed; position: fixed;
bottom: 0; bottom: var(--footer-height);
left: 0; left: 0;
right: 0; right: 0;
z-index: 1000; 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 { .cart-backdrop {
@@ -501,7 +635,7 @@ export default {
bottom: 0; bottom: 0;
background-color: rgba(0, 0, 0, 0.5); background-color: rgba(0, 0, 0, 0.5);
z-index: 999; z-index: 999;
animation: fadeIn 0.3s ease-in-out; animation: fadeIn 0.2s ease-out;
} }
@keyframes fadeIn { @keyframes fadeIn {
@@ -513,21 +647,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 { .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) */ GRID DE PRODUCTOS
/* Layout: ≥1024px = lg breakpoint (60% productos / 40% cart) */ ============================================ */
.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) { @media (min-width: 960px) {
.cart-sidebar { .cart-sidebar {
position: sticky; position: sticky;
top: 80px; top: 96px;
z-index: auto; z-index: 1;
max-height: calc(100vh - 100px); max-height: calc(100vh - 120px);
overflow-y: visible; overflow-y: auto;
box-shadow: none;
border-radius: 12px;
transform: none !important;
} }
.cart-backdrop { .cart-backdrop {
@@ -537,30 +724,11 @@ export default {
.pb-mobile-cart { .pb-mobile-cart {
padding-bottom: 16px !important; 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;
}
}
/* Resolución 560-959px - Tablet */
@media (min-width: 560px) and (max-width: 959px) {
.catalog-item {
margin-bottom: 14px;
}
} }
/* ============================================
MODALES
============================================ */
.product-list-scroll { .product-list-scroll {
max-height: 400px; max-height: 400px;
overflow-y: auto; overflow-y: auto;

View File

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

View File

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

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> <template>
<SummaryPurchase :id="$route.query.id"/> <SummaryPurchase :id="$route.query.id" :type="$route.query.type"/>
</template> </template>
<script setup> <script setup>

View File

@@ -19,6 +19,9 @@ const ADMIN_ROUTES = [
'/cuadres_de_tarro', '/cuadres_de_tarro',
'/compra_admin', '/compra_admin',
'/cuadrar_tarro', '/cuadrar_tarro',
'/admin/products',
'/admin/catalog-sales',
'/admin/catalogue-images',
] ]
const router = createRouter({ const router = createRouter({
@@ -35,7 +38,7 @@ router.beforeEach((to, from, next) => {
if (requiresAuth && !isAuthenticated) { if (requiresAuth && !isAuthenticated) {
next({ path: '/autenticarse', replace: true }) next({ path: '/autenticarse', replace: true })
} else if (requiresAdmin && !authStore.isAdmin) { } else if (requiresAdmin && !authStore.isAdmin && authStore.user) {
next({ path: '/', replace: true }) next({ path: '/', replace: true })
} else { } else {
next() next()

View File

@@ -7,8 +7,12 @@ class Api {
return this.apiImplementation.getCustomers(); return this.apiImplementation.getCustomers();
} }
getProducts() { getProducts(active = 'all') {
return this.apiImplementation.getProducts(); return this.apiImplementation.getProducts(active);
}
updateProduct(productId, data) {
return this.apiImplementation.updateProduct(productId, data);
} }
getPaymentMethods() { getPaymentMethods() {
@@ -19,6 +23,10 @@ class Api {
return this.apiImplementation.getSummaryPurchase(purchaseId); return this.apiImplementation.getSummaryPurchase(purchaseId);
} }
getSummaryCatalogPurchase(purchaseId) {
return this.apiImplementation.getSummaryCatalogPurchase(purchaseId);
}
getPurchasesForReconciliation() { getPurchasesForReconciliation() {
return this.apiImplementation.getPurchasesForReconciliation(); return this.apiImplementation.getPurchasesForReconciliation();
} }
@@ -32,11 +40,11 @@ class Api {
} }
createPurchase(purchase) { createPurchase(purchase) {
return this.apiImplementation.createCatalogPurchase(purchase); return this.apiImplementation.createPurchase(purchase);
} }
createCatalogPurchase(purchase) { createCatalogPurchase(purchase) {
return this.apiImplementation.createPurchase(purchase); return this.apiImplementation.createCatalogPurchase(purchase);
} }
createReconciliationJar(reconciliation) { createReconciliationJar(reconciliation) {
@@ -63,9 +71,33 @@ class Api {
return this.apiImplementation.sendSalesToTryton(); return this.apiImplementation.sendSalesToTryton();
} }
sendCatalogSalesToTryton() {
return this.apiImplementation.sendCatalogSalesToTryton();
}
getCatalogSales() {
return this.apiImplementation.getCatalogSales();
}
getCurrentUser() { getCurrentUser() {
return this.apiImplementation.getCurrentUser(); return this.apiImplementation.getCurrentUser();
} }
getCatalogueImages() {
return this.apiImplementation.getCatalogueImages();
}
createCatalogueImage(data) {
return this.apiImplementation.createCatalogueImage(data);
}
updateCatalogueImage(id, data) {
return this.apiImplementation.updateCatalogueImage(id, data);
}
deleteCatalogueImage(id) {
return this.apiImplementation.deleteCatalogueImage(id);
}
} }
export default Api; export default Api;

View File

@@ -14,16 +14,31 @@ class DjangoApi {
return http.post(url, payload).then((r) => r.data); return http.post(url, payload).then((r) => r.data);
} }
patchRequest(url, payload) {
return http.patch(url, payload).then((r) => r.data);
}
getCustomers() { getCustomers() {
const url = this.base + "/don_confiao/api/customers/"; const url = this.base + "/don_confiao/api/customers/";
return this.getRequest(url); return this.getRequest(url);
} }
getProducts() { getProducts(active = 'all') {
const url = this.base + "/don_confiao/api/products/"; let url = this.base + "/don_confiao/api/products/";
// Agregar query parameter según filtro
if (active !== 'all') {
url += `?active=${active}`;
}
return this.getRequest(url); return this.getRequest(url);
} }
updateProduct(productId, data) {
const url = this.base + `/don_confiao/api/products/${productId}/`;
return this.patchRequest(url, data);
}
getPaymentMethods() { getPaymentMethods() {
const url = const url =
this.base + "/don_confiao/payment_methods/all/select_format"; this.base + "/don_confiao/payment_methods/all/select_format";
@@ -36,6 +51,12 @@ class DjangoApi {
return this.getRequest(url); return this.getRequest(url);
} }
getSummaryCatalogPurchase(purchaseId) {
const url =
this.base + `/don_confiao/resumen_compra_catalogo_json/${purchaseId}`;
return this.getRequest(url);
}
getPurchasesForReconciliation() { getPurchasesForReconciliation() {
const url = this.base + "/don_confiao/purchases/for_reconciliation"; const url = this.base + "/don_confiao/purchases/for_reconciliation";
return this.getRequest(url); return this.getRequest(url);
@@ -95,10 +116,44 @@ class DjangoApi {
return this.postRequest(url, {}); 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() { getCurrentUser() {
const url = this.base + "/api/users/me/"; const url = this.base + "/api/users/me/";
return this.getRequest(url); return this.getRequest(url);
} }
getCatalogueImages() {
const url = this.base + "/don_confiao/api/catalogue_images/";
return this.getRequest(url);
}
createCatalogueImage(data) {
const url = this.base + "/don_confiao/api/catalogue_images/";
return http.post(url, data, {
headers: { 'Content-Type': undefined },
}).then((r) => r.data);
}
updateCatalogueImage(id, data) {
const url = this.base + `/don_confiao/api/catalogue_images/${id}/`;
return http.put(url, data, {
headers: { 'Content-Type': undefined },
}).then((r) => r.data);
}
deleteCatalogueImage(id) {
const url = this.base + `/don_confiao/api/catalogue_images/${id}/`;
return http.delete(url).then((r) => r.data);
}
} }
export default DjangoApi; export default DjangoApi;

View File

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