21 Commits

Author SHA1 Message Date
7beac90c05 feat: mejora selector de productos con autocomplete y estética del modal en gestión de imágenes de catálogo 2026-07-11 13:52:35 -05:00
92483a110a Merge pull request 'fix/catalog-image-overlap' (#44) from fix/catalog-image-overlap into main
Reviewed-on: #44
2026-07-03 19:21:40 -05:00
mono
a89af0f0bb #fix: búsqueda insensible a tildes en el catálogo
Normaliza los strings eliminando diacríticos (NFD) antes de comparar,
para que 'cafe' encuentre 'café' y viceversa.
2026-07-03 19:06:40 -05:00
mono
b2a47c44dc #fix: scroll al inicio del grid al cambiar página en el catálogo
El método scrollToTop usaba scrollIntoView en un elemento sticky,
lo que no generaba desplazamiento. Ahora calcula la posición
absoluta del grid de productos y hace scroll suave.
2026-07-03 18:58:00 -05:00
mono
2c2082266e #fix: corregir solapamiento de imagen en tarjetas del catálogo
- Cambiar height: 100% por aspect-ratio: 1 en .product-image-container
- Eliminar media queries duplicados y obsoletos
- El contenedor ahora se ajusta al tamaño real del v-img
2026-07-03 18:52:33 -05:00
4b006aaa01 #44 feat: restringir acceso a compras para usuarios con rol publico 2026-06-22 12:50:14 -05:00
762fde2cd0 #44 feat: ocultar carrito y deshabilitar controles para usuarios no autenticados en catálogo 2026-06-22 11:46:50 -05:00
bb26f55829 Merge pull request '#40 feat: add catalogue images CRUD and display in product catalog' (#43) from feature/40-catalogue-images into main
Reviewed-on: #43
2026-06-13 18:05:48 -05:00
mono
5397ab2255 #40 fix: upload de imágenes con multipart/form-data 2026-06-13 17:59:14 -05:00
mono
a2ab4fceb7 #40 feat: add catalogue images CRUD and display in product catalog 2026-06-13 16:32:44 -05:00
78dfea8714 feat: controlar visibilidad de botones según autenticación en Wellcome
- Agregar lógica condicional para mostrar botón 'Ir a Comprar' solo a usuarios admin autenticados
- Reordenar botones: 'Ver Catálogo' primero, 'Ir a Comprar' segundo
- Importar y usar useAuthStore para verificar isAuthenticated e isAdmin
- 'Ver Catálogo' siempre visible para todos los usuarios
- 'Ir a Comprar' visible solo cuando isAuthenticated && isAdmin
2026-06-03 16:22:20 -05:00
490cb7b53d feat: separar ventas de catálogo en tabs por estado de sincronización
- Agregar sistema de tabs: 'Sin Sincronizar' y 'Sincronizadas'
- Crear computeds pendingSales y syncedSales basados en external_id
- Implementar headers diferentes para cada tab con columna 'Estado'
- Agregar columna 'ID Tryton' (external_id) en tab de sincronizadas
- Refactorizar filtros comunes aplicables a ambas vistas
- Mover botón 'Sincronizar a Tryton' solo a tab Sin Sincronizar
- Agregar badges de estado: naranja 'Pendiente' y verde 'Sincronizada'
- Mostrar contadores en tiempo real en cada tab y en header
- Tab 'Sin Sincronizar' activo por defecto
- Deshabilitar botón de sincronización cuando no hay ventas pendientes
2026-05-30 21:14:52 -05:00
5e86595831 feat: agregar sincronización de ventas de catálogo a Tryton
- Agregar método sendCatalogSalesToTryton() en django-api.js y api.js
- Crear página sincronizar_catalog_sales_tryton.vue para exportar catalog_sales
- Agregar botón 'Sincronizar a Tryton' en CatalogSalesManagement header
- Reorganizar menú admin en NavBar con sección 'Sincronización Tryton'
- Separar opciones de importación (download) y exportación (upload) a Tryton
- Endpoint: /don_confiao/api/enviar_catalog_sales_a_tryton
- Mostrar resultados exitosos/fallidos similar a sincronización de ventas normales
2026-05-30 20:57:50 -05:00
897cbb3efc feat: soportar resumen de compras para catalog_sales y sales
- Agregar método getSummaryCatalogPurchase() en django-api.js y api.js
- Modificar SummaryPurchase.vue para aceptar prop 'type' y usar endpoint correcto
- Actualizar catalog.vue para pasar type=catalog en redirect a summary_purchase
- Actualizar summary_purchase.vue para pasar prop type desde query params
- Lógica: si type='catalog' usa /resumen_compra_catalogo_json/{id}, sino usa /resumen_compra_json/{id}
- Mantener retrocompatibilidad: sin type usa endpoint de sales normal
2026-05-30 20:32:22 -05:00
925fadba2d refactor: unificar componentes de compra con prop isAdmin
- Agregar prop isAdmin a Purchase.vue para controlar campos editables
- Hacer campo unit_price editable solo en modo admin (:readonly="!isAdmin")
- Actualizar comprar.vue y compra_admin.vue para usar Purchase unificado
- Eliminar componente AdminPurchase.vue duplicado
- Ambas páginas ahora usan interfaz moderna con cards y diseño responsive
- Mantener seguridad con authStore.isAdmin check en compra_admin.vue
2026-05-30 19:55:00 -05:00
d049357231 feat: agregar imagen por defecto para productos sin foto en catálogo 2026-05-29 18:38:01 -05:00
a36fbd289e feat: rediseñar página de login con glassmorphism y diseño responsive
- Rediseño completo de Login.vue con burbujas animadas de colores
- Implementado glassmorphism (backdrop-filter blur) en tarjeta de login
- Migrado a Composition API con <script setup>
- Formulario mejorado: iconos mdi, variant outlined, loading state
- Diseño responsive con breakpoints mobile/tablet/desktop
- Burbuja amarilla con opacidad reducida (0.08-0.25) para mejor UX
- Fix: agregado import en autenticarse.vue
2026-05-29 16:39:46 -05:00
368b7007f6 refactor: reemplazar iconos SVG por mdi genéricos en NavBar
- Eliminar import de tryton-icon.svg
- Reemplazar trytonIcon por 'mdi-sync' en items de Tryton
- Restaurar :prepend-icon (todos los iconos son ahora mdi strings)
2026-05-29 01:30:54 -05:00
f79197baf5 fix: corregir iconos SVG en NavBar
- Cambiar string literal 'trytonIcon' por variable importada trytonIcon
- Agregar condicion startsWith('mdi-') para diferenciar iconos MDI de URLs SVG
- Los iconos MDI se renderizan con <v-icon>, SVGs con <img>
2026-05-29 01:29:02 -05:00
3f0f1fe09a fix: corregir renderizado de iconos SVG en NavBar
- Reemplazar :prepend-icon por slot #prepend para soportar iconos mdi y SVG
- Los items con trytonIcon (SVG importado) ahora se renderizan como <img>
- Los items con mdi-* se renderizan como <v-icon>
2026-05-29 01:25:10 -05:00
ed42eb324c fix: cambiar filtro por defecto a Inactivos en gestión de productos
- Default filter cambia de 'all' a 'false' (Inactivos)
- Limpieza de whitespace
2026-05-29 01:13:46 -05:00
22 changed files with 1418 additions and 787 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View File

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

View File

@@ -1,170 +1,379 @@
<template>
<v-container fluid>
<!-- Header -->
<v-row align="center">
<v-col cols="12" md="6">
<h1 class="text-h4">Ventas por Catálogo</h1>
</v-col>
<v-col cols="12" md="6" class="text-md-right">
<v-chip color="primary" variant="flat">
{{ catalogSales.length }} venta(s)
</v-chip>
</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 -->
<v-row>
<!-- Header Principal -->
<v-row align="center" class="mb-4">
<v-col cols="12">
<v-card>
<v-data-table
v-model:expanded="expanded"
:headers="headers"
:items="filteredSales"
: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>
<!-- 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 por catálogo para mostrar
</v-alert>
</template>
</v-data-table>
</v-card>
<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"
@@ -186,22 +395,52 @@ import { ref, computed, inject, onMounted } from 'vue';
const api = inject('api');
const catalogSales = ref([]);
const loading = ref(false);
const expanded = ref([]);
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
const headers = [
// 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' },
];
const filteredSales = computed(() => {
let result = catalogSales.value;
// 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) {
@@ -227,6 +466,16 @@ const filteredSales = computed(() => {
}
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() {

View File

@@ -0,0 +1,395 @@
<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="520" persistent>
<v-card elevation="2" rounded="lg">
<v-card-title class="text-h6 font-weight-bold pa-6 pb-0">
<v-icon :icon="dialog.isEdit ? 'mdi-pencil' : 'mdi-plus-circle-outline'" class="mr-2" color="primary" />
{{ dialog.isEdit ? 'Editar' : 'Agregar' }} Imagen de Catálogo
</v-card-title>
<v-divider class="mt-3" />
<v-card-text class="pa-6">
<v-form ref="formRef">
<v-label class="font-weight-medium mb-1 d-block">Producto</v-label>
<v-autocomplete
v-model="form.product"
:items="productOptions"
placeholder="Buscar producto…"
:rules="[(v) => !!v || 'Seleccione un producto']"
item-title="name"
item-value="id"
variant="outlined"
:disabled="dialog.isEdit"
class="mb-5"
clearable
prepend-inner-icon="mdi-package-variant-closed"
density="comfortable"
/>
<v-label class="font-weight-medium mb-1 d-block">Imagen</v-label>
<v-file-input
ref="fileInputRef"
accept="image/*"
:rules="[(v) => !!v || 'Seleccione una imagen']"
variant="outlined"
prepend-icon="mdi-camera"
prepend-inner-icon="mdi-image-outline"
class="mb-5"
@update:model-value="onFileSelected"
density="comfortable"
hide-details="auto"
/>
<v-img
v-if="form.preview"
:src="form.preview"
max-height="220"
contain
class="rounded-lg border mt-2"
/>
</v-form>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="tonal" @click="closeDialog">Cancelar</v-btn>
<v-btn
color="primary"
variant="elevated"
:loading="submitting"
:disabled="submitting"
@click="submitForm"
class="ml-2"
>
<v-icon start :icon="dialog.isEdit ? 'mdi-content-save' : 'mdi-upload'" />
{{ dialog.isEdit ? 'Actualizar' : 'Agregar' }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="deleteDialog.show" max-width="400">
<v-card>
<v-card-title>Confirmar Eliminación</v-card-title>
<v-card-text>
¿Está seguro de eliminar esta imagen de catálogo?
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="deleteDialog.show = false">
Cancelar
</v-btn>
<v-btn
color="error"
variant="elevated"
:loading="deleting"
:disabled="deleting"
@click="confirmDelete"
>
Eliminar
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar
v-model="snackbar.show"
:color="snackbar.color"
:timeout="3000"
location="top"
>
{{ snackbar.message }}
<template #actions>
<v-btn variant="text" @click="snackbar.show = false">Cerrar</v-btn>
</template>
</v-snackbar>
</v-container>
</template>
<script setup>
import { ref, inject, onMounted, computed, watch } from "vue";
const api = inject("api");
const images = ref([]);
const products = ref([]);
const loading = ref(false);
const submitting = ref(false);
const deleting = ref(false);
const formRef = ref(null);
const snackbar = ref({ show: false, message: "", color: "success" });
const headers = [
{ title: "ID", key: "id", sortable: true },
{ title: "Producto", key: "product", sortable: true },
{ title: "Imagen", key: "image", sortable: false },
{ title: "Subida el", key: "uploaded_at", sortable: true },
{ title: "Acciones", key: "actions", sortable: false },
];
const dialog = ref({
show: false,
isEdit: false,
editingItem: null,
});
const fileInputRef = ref(null);
const selectedFile = ref(null);
const form = ref({
product: null,
preview: null,
});
const deleteDialog = ref({
show: false,
item: null,
});
let previewObjectUrl = null;
const productMap = computed(() => {
const map = {};
for (const p of products.value) {
map[p.id] = p.name;
}
return map;
});
const productOptions = computed(() => {
return products.value.map((p) => ({
name: p.name,
id: p.id,
}));
});
watch(
selectedFile,
(file) => {
if (previewObjectUrl) {
URL.revokeObjectURL(previewObjectUrl);
previewObjectUrl = null;
}
if (file) {
previewObjectUrl = URL.createObjectURL(file);
form.value.preview = previewObjectUrl;
} else if (dialog.value.isEdit && dialog.value.editingItem) {
form.value.preview = dialog.value.editingItem.image;
} else {
form.value.preview = null;
}
},
);
function onFileSelected(file) {
selectedFile.value = file;
}
function formatDate(dateStr) {
if (!dateStr) return "-";
const d = new Date(dateStr);
return d.toLocaleString("es-CO");
}
async function loadImages() {
loading.value = true;
try {
const data = await api.getCatalogueImages();
images.value = data;
} catch (error) {
console.error("Error al cargar imágenes:", error);
showSnackbar("Error al cargar imágenes", "error");
} finally {
loading.value = false;
}
}
async function loadProducts() {
try {
const data = await api.getProducts("all");
products.value = data;
} catch (error) {
console.error("Error al cargar productos:", error);
}
}
function openCreateDialog() {
dialog.value = { show: true, isEdit: false, editingItem: null };
selectedFile.value = null;
form.value = { product: null, preview: null };
}
function openEditDialog(item) {
dialog.value = { show: true, isEdit: true, editingItem: item };
selectedFile.value = null;
form.value = {
product: item.product,
preview: item.image,
};
}
function closeDialog() {
if (previewObjectUrl) {
URL.revokeObjectURL(previewObjectUrl);
previewObjectUrl = null;
}
selectedFile.value = null;
dialog.value.show = false;
dialog.value.isEdit = false;
dialog.value.editingItem = null;
form.value = { product: null, preview: null };
}
async function submitForm() {
const formComponent = formRef.value;
if (formComponent) {
const { valid } = await formComponent.validate();
if (!valid) return;
}
submitting.value = true;
try {
const fd = new FormData();
fd.append("product", form.value.product);
if (selectedFile.value) {
fd.append("image", selectedFile.value);
}
if (dialog.value.isEdit) {
await api.updateCatalogueImage(dialog.value.editingItem.id, fd);
showSnackbar("Imagen actualizada exitosamente", "success");
} else {
await api.createCatalogueImage(fd);
showSnackbar("Imagen agregada exitosamente", "success");
}
closeDialog();
await loadImages();
} catch (error) {
console.error("Error al guardar imagen:", error);
showSnackbar("Error al guardar imagen", "error");
} finally {
submitting.value = false;
}
}
function openDeleteDialog(item) {
deleteDialog.value = { show: true, item };
}
async function confirmDelete() {
deleting.value = true;
try {
await api.deleteCatalogueImage(deleteDialog.value.item.id);
showSnackbar("Imagen eliminada exitosamente", "success");
deleteDialog.value.show = false;
await loadImages();
} catch (error) {
console.error("Error al eliminar imagen:", error);
showSnackbar("Error al eliminar imagen", "error");
} finally {
deleting.value = false;
}
}
function showSnackbar(message, color) {
snackbar.value = { show: true, message, color };
}
onMounted(() => {
loadImages();
loadProducts();
});
</script>
<style scoped>
.text-md-right {
text-align: right;
}
@media (max-width: 960px) {
.text-md-right {
text-align: left;
}
}
</style>

View File

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

View File

@@ -54,7 +54,7 @@
nav
>
<v-list-item
v-for="item in menuItems"
v-for="item in filteredMenuItems"
:key="item.title"
:title="item.title"
:prepend-icon="item.icon"
@@ -63,13 +63,16 @@
<v-list-item prepend-icon="mdi-cog" title="Administracion" @click="toggleAdminMenu()" v-if="isAuthenticated && isAdmin"></v-list-item>
<v-list-item v-if="isAuthenticated && isAdmin && showAdminMenu">
<v-list>
<v-list-item
v-for="item in menuAdminItems"
:key="item.title"
:title="item.title"
:prepend-icon="item.icon"
@click="navigateAdmin(item.route)"
></v-list-item>
<template v-for="(item, index) in menuAdminItems" :key="index">
<v-divider v-if="item.divider"></v-divider>
<v-list-subheader v-else-if="item.header">{{ item.header }}</v-list-subheader>
<v-list-item
v-else
:title="item.title"
:prepend-icon="item.icon"
@click="navigateAdmin(item.route)"
></v-list-item>
</template>
</v-list>
</v-list-item>
</v-list>
@@ -77,7 +80,6 @@
</template>
<script>
import trytonIcon from '../assets/icons/tryton-icon.svg';
import AuthService from '@/services/auth';
import { useAuthStore } from '@/stores/auth';
import { inject } from 'vue';
@@ -104,17 +106,27 @@
{ title: 'Cuadres de tarro', route: '/cuadres_de_tarro', icon: 'mdi-chart-bar'},
{ title: 'CSV Tryton', route: '/ventas_para_tryton', icon: 'mdi-file-table'},
{ title: 'Compra adm', route: '/compra_admin', icon: 'mdi-cart'},
{ title: 'Gestión de Productos', route: '/admin/products', icon: 'mdi-package-variant'},
{ title: 'Ver Ventas por Catálogo', route: '/admin/catalog-sales', icon: 'mdi-cart-arrow-down'},
{ title: 'Actualizar Productos De Tryton', route: '/sincronizar_productos_tryton', icon: 'trytonIcon'},
{ title: 'Actualizar Clientes De Tryton', route: '/sincronizar_clientes_tryton', icon: 'trytonIcon'},
{ title: 'Actualizar Ventas Tryton', route: '/sincronizar_ventas_tryton', icon: 'trytonIcon'}
{ title: 'Gestión de Productos', route: '/admin/products', icon: 'mdi-package-variant'},
{ title: 'Imágenes de Catálogo', route: '/admin/catalogue-images', icon: 'mdi-image-multiple'},
{ title: 'Ver Ventas por Catálogo', route: '/admin/catalog-sales', icon: 'mdi-cart-arrow-down'},
{ divider: true },
{ header: 'Sincronización Tryton' },
{ title: 'Importar Productos', route: '/sincronizar_productos_tryton', icon: 'mdi-download'},
{ title: 'Importar Clientes', route: '/sincronizar_clientes_tryton', icon: 'mdi-download'},
{ title: 'Exportar Ventas', route: '/sincronizar_ventas_tryton', icon: 'mdi-upload'},
{ title: 'Exportar Ventas Catálogo', route: '/sincronizar_catalog_sales_tryton', icon: 'mdi-upload'}
],
}),
computed: {
isAdmin() {
return this.user?.role === 'administrator';
}
},
filteredMenuItems() {
if (this.user?.role === 'publico') {
return this.menuItems.filter(item => item.route !== '/comprar');
}
return this.menuItems;
},
},
mounted() {
this.checkAuth();

View File

@@ -50,7 +50,7 @@
>
Activar seleccionados
</v-btn>
<!-- Botón Desactivar: solo visible en filtro "Activos" -->
<v-btn
v-if="activeFilter === 'true'"
@@ -158,7 +158,7 @@ import { ref, watch, inject, onMounted, computed } from "vue";
// Estado
const api = inject("api");
const activeFilter = ref("all");
const activeFilter = ref("false");
const products = ref([]);
const selected = ref([]);
const loading = ref(false);
@@ -178,10 +178,10 @@ 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)
return products.value.filter((product) =>
product.name.toLowerCase().includes(query),
);
});

View File

@@ -173,7 +173,7 @@
:rules="[rules.required]"
prefix="$"
required
readonly
:readonly="!isAdmin"
variant="outlined"
density="compact"
hide-details="auto"
@@ -323,14 +323,18 @@
import CasherModal from './CasherModal.vue';
import { inject } from 'vue';
export default {
name: 'DonConfiao',
components: {
CustomerForm,
CasherModal,
export default {
name: 'DonConfiao',
components: {
CustomerForm,
CasherModal,
},
props: {
msg: String
msg: String,
isAdmin: {
type: Boolean,
default: false
}
},
data() {
return {

View File

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

View File

@@ -87,16 +87,6 @@
<v-col cols="12" class="text-center">
<h2 class="text-h5 font-weight-bold mb-4">¿Qué deseas hacer?</h2>
<div class="d-flex flex-wrap justify-center ga-4">
<v-btn
:to="{ path: 'comprar' }"
color="green"
size="x-large"
prepend-icon="mdi-cart"
variant="elevated"
class="px-8"
>
Ir a Comprar
</v-btn>
<v-btn
:to="{ path: 'catalog' }"
color="primary"
@@ -107,6 +97,17 @@
>
Ver Catálogo
</v-btn>
<v-btn
v-if="authStore.isAuthenticated && authStore.isAdmin"
:to="{ path: 'comprar' }"
color="green"
size="x-large"
prepend-icon="mdi-cart"
variant="elevated"
class="px-8"
>
Ir a Comprar
</v-btn>
</div>
</v-col>
</v-row>
@@ -116,7 +117,10 @@
<script setup>
import ResaltedText from "@/components/ResaltedText.vue";
import { useAuthStore } from '@/stores/auth';
import logo from "@/assets/logo_colorful.png";
const authStore = useAuthStore();
</script>
<style scoped>

View File

@@ -71,7 +71,7 @@
color="error"
class="qty-btn"
@click="decrease(product)"
:disabled="product.quantity === 0"
:disabled="disabled || product.quantity === 0"
>
<v-icon size="20">mdi-minus</v-icon>
</v-btn>
@@ -87,6 +87,7 @@
single-line
flat
aria-label="Cantidad"
:disabled="disabled"
@input="handleQuantityChange"
/>
@@ -97,6 +98,7 @@
color="success"
class="qty-btn"
@click="handleIncrease"
:disabled="disabled"
>
<v-icon size="20">mdi-plus</v-icon>
</v-btn>
@@ -128,6 +130,10 @@ export default {
type: Function,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
},
methods: {
handleIncrease() {
@@ -170,7 +176,6 @@ export default {
.product-image-container {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: linear-gradient(135deg, #fafafa 0%, #ffffff 100%);
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
@@ -330,143 +335,6 @@ export default {
/* Móvil estándar (375px - 559px) */
@media (min-width: 375px) and (max-width: 559px) {
.product-name {
font-size: 0.95rem;
}
.price-value {
font-size: 1.05rem;
}
}
/* Tablet (560px - 959px) */
@media (min-width: 560px) and (max-width: 959px) {
.product-content {
padding: 14px;
}
.product-name {
font-size: 1rem;
min-height: 2.6rem;
}
.price-label {
font-size: 0.68rem;
}
.price-value {
font-size: 1.15rem;
}
.price-total-chip {
font-size: 0.95rem;
height: 28px;
}
.quantity-input {
max-width: 70px;
min-width: 70px;
}
}
/* Desktop (≥ 960px) */
@media (min-width: 960px) {
.product-content {
padding: 16px 14px;
}
.product-name {
font-size: 1.05rem;
min-height: 2.7rem;
}
.price-label {
font-size: 0.7rem;
}
.price-value {
font-size: 1.2rem;
}
.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;
}
@@ -478,10 +346,6 @@ export default {
/* Tablet (560px - 959px) */
@media (min-width: 560px) and (max-width: 959px) {
.product-image-container {
height: 150px;
}
.product-content {
padding: 10px;
}
@@ -512,10 +376,6 @@ export default {
/* Desktop (≥ 960px) */
@media (min-width: 960px) {
.product-image-container {
height: 160px;
}
.product-content {
padding: 12px 10px;
}
@@ -557,10 +417,6 @@ export default {
/* Desktop Large (≥ 1280px) */
@media (min-width: 1280px) {
.product-image-container {
height: 170px;
}
.product-content {
padding: 14px 12px;
}
@@ -582,10 +438,6 @@ export default {
/* Desktop Extra Large (≥ 1920px) */
@media (min-width: 1920px) {
.product-image-container {
height: 180px;
}
.product-name {
font-size: 1.05rem;
min-height: 2.5rem;

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

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

View File

@@ -2,7 +2,7 @@
<v-container fluid>
<!-- Backdrop para mobile cuando el carrito está expandido -->
<div
v-if="isMobile && !cartCollapsed"
v-if="isMobile && !cartCollapsed && isAuthenticated"
class="cart-backdrop"
@click="cartCollapsed = true"
></div>
@@ -15,7 +15,10 @@
<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"
<v-icon
size="36"
color="primary"
class="mr-3 d-none d-sm-inline flex-shrink-0"
>mdi-store</v-icon
>
<div
@@ -77,6 +80,7 @@
:decrease="decrease"
:currency="currency"
:updateQuantity="updateQuantity"
:disabled="!isAuthenticated"
@add-to-cart="addToCart"
/>
</v-col>
@@ -121,6 +125,7 @@
:class="{ 'cart-is-collapsed': cartCollapsed && isMobile }"
>
<Cart
v-if="isAuthenticated"
:cart-items="cartItems"
:currency="currency"
:is-collapsed="cartCollapsed"
@@ -131,6 +136,20 @@
@update-quantity="updateCartQuantity"
@toggle-collapse="toggleCart"
/>
<v-card v-else class="login-prompt-card pa-4 text-center">
<v-icon size="48" color="primary" class="mb-2">mdi-cart-lock</v-icon>
<p class="text-body-2 text-medium-emphasis mb-3">
Inicia sesión para agregar productos al carrito y finalizar tu compra
</p>
<v-btn
color="primary"
variant="tonal"
prepend-icon="mdi-login"
@click="openLoginDialog"
>
Iniciar Sesión
</v-btn>
</v-card>
</div>
</v-col>
</v-row>
@@ -226,6 +245,12 @@
</v-card-actions>
</v-card>
</v-dialog>
<!-- Login Dialog -->
<LoginDialog
ref="loginDialogRef"
@login-success="onLoginSuccess"
/>
</v-container>
</template>
@@ -233,17 +258,22 @@
import Card from "@/components/catalog/Card.vue";
import Cart from "@/components/catalog/Cart.vue";
import PaginationControls from "@/components/catalog/PaginationControls.vue";
import LoginDialog from "@/components/LoginDialog.vue";
import { useCartStore } from "@/stores/cart";
import { useAuthStore } from "@/stores/auth";
import { inject, ref, computed, onMounted, onUnmounted } from "vue";
import not_image_product from "@/assets/not_image_for_product.jpeg";
export default {
components: {
Card,
Cart,
PaginationControls,
LoginDialog,
},
setup() {
const cartStore = useCartStore();
const authStore = useAuthStore();
const cartCollapsed = ref(false); // Cambiado a false para que inicie expandido en desktop
const windowWidth = ref(window.innerWidth);
@@ -263,6 +293,7 @@ export default {
return {
cartStore,
authStore,
cartCollapsed,
isMobile,
windowWidth,
@@ -305,12 +336,16 @@ export default {
cartCount() {
return this.cartStore.cartCount;
},
isAuthenticated() {
return this.authStore.isAuthenticated;
},
// Búsqueda
filteredItems() {
if (!this.searchQuery) return this.items;
const query = this.searchQuery.toLowerCase();
const normalize = (s) => s.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
const query = normalize(this.searchQuery);
return this.items.filter((item) =>
item.name.toLowerCase().includes(query),
normalize(item.name).includes(query),
);
},
// Paginación
@@ -375,9 +410,7 @@ export default {
this.items = data.map((product) => ({
...product,
quantity: 0,
img:
product.img ||
`https://picsum.photos/600/600?random=${product.id}`,
img: (product.catalogue_images?.length > 0) ? product.catalogue_images[0] : (product.img || not_image_product),
}));
})
.catch((error) => {
@@ -468,7 +501,10 @@ export default {
this.personalDataDialog = false;
this.$router.push({
path: "/summary_purchase",
query: { id: parseInt(data.id) },
query: {
id: parseInt(data.id),
type: 'catalog'
},
});
})
.catch((error) => {
@@ -506,14 +542,20 @@ export default {
}
},
scrollToTop() {
this.$nextTick(() => {
const catalogHeader = this.$el?.querySelector(".page-header");
if (catalogHeader) {
catalogHeader.scrollIntoView({
behavior: "smooth",
block: "start",
});
}
const grid = this.$el?.querySelector(".product-grid");
if (grid) {
const top = grid.getBoundingClientRect().top + window.scrollY - 16;
window.scrollTo({ top, behavior: "smooth" });
} else {
window.scrollTo({ top: 0, behavior: "smooth" });
}
},
openLoginDialog() {
this.$refs.loginDialogRef.open();
},
onLoginSuccess() {
this.api.getCurrentUser().then((user) => {
this.authStore.setUser(user);
});
},
currency(val) {
@@ -581,7 +623,7 @@ export default {
.page-header .search-field :deep(.v-field__input) {
font-size: 0.875rem;
}
.page-header .search-field {
width: 100%;
max-width: 100%;
@@ -669,7 +711,7 @@ export default {
.product-grid {
margin: 0 -6px;
}
.product-col {
padding: 6px;
}
@@ -680,7 +722,7 @@ export default {
.product-grid {
margin: 0 -8px;
}
.product-col {
padding: 8px;
}
@@ -691,7 +733,7 @@ export default {
.product-grid {
margin: 0 -12px;
}
.product-col {
padding: 12px;
}
@@ -721,6 +763,14 @@ export default {
}
}
/* ============================================
LOGIN PROMPT CARD
============================================ */
.login-prompt-card {
border: 2px dashed rgba(0, 0, 0, 0.12);
background: #fafafa !important;
}
/* ============================================
MODALES
============================================ */

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,6 +23,10 @@ class Api {
return this.apiImplementation.getSummaryPurchase(purchaseId);
}
getSummaryCatalogPurchase(purchaseId) {
return this.apiImplementation.getSummaryCatalogPurchase(purchaseId);
}
getPurchasesForReconciliation() {
return this.apiImplementation.getPurchasesForReconciliation();
}
@@ -67,6 +71,10 @@ class Api {
return this.apiImplementation.sendSalesToTryton();
}
sendCatalogSalesToTryton() {
return this.apiImplementation.sendCatalogSalesToTryton();
}
getCatalogSales() {
return this.apiImplementation.getCatalogSales();
}
@@ -74,6 +82,22 @@ class Api {
getCurrentUser() {
return this.apiImplementation.getCurrentUser();
}
getCatalogueImages() {
return this.apiImplementation.getCatalogueImages();
}
createCatalogueImage(data) {
return this.apiImplementation.createCatalogueImage(data);
}
updateCatalogueImage(id, data) {
return this.apiImplementation.updateCatalogueImage(id, data);
}
deleteCatalogueImage(id) {
return this.apiImplementation.deleteCatalogueImage(id);
}
}
export default Api;

View File

@@ -51,6 +51,12 @@ class DjangoApi {
return this.getRequest(url);
}
getSummaryCatalogPurchase(purchaseId) {
const url =
this.base + `/don_confiao/resumen_compra_catalogo_json/${purchaseId}`;
return this.getRequest(url);
}
getPurchasesForReconciliation() {
const url = this.base + "/don_confiao/purchases/for_reconciliation";
return this.getRequest(url);
@@ -110,6 +116,11 @@ class DjangoApi {
return this.postRequest(url, {});
}
sendCatalogSalesToTryton() {
const url = this.base + "/don_confiao/api/enviar_catalog_sales_a_tryton";
return this.postRequest(url, {});
}
getCatalogSales() {
const url = this.base + "/don_confiao/api/catalog_sales/";
return this.getRequest(url);
@@ -119,6 +130,30 @@ class DjangoApi {
const url = this.base + "/api/users/me/";
return this.getRequest(url);
}
getCatalogueImages() {
const url = this.base + "/don_confiao/api/catalogue_images/";
return this.getRequest(url);
}
createCatalogueImage(data) {
const url = this.base + "/don_confiao/api/catalogue_images/";
return http.post(url, data, {
headers: { 'Content-Type': undefined },
}).then((r) => r.data);
}
updateCatalogueImage(id, data) {
const url = this.base + `/don_confiao/api/catalogue_images/${id}/`;
return http.put(url, data, {
headers: { 'Content-Type': undefined },
}).then((r) => r.data);
}
deleteCatalogueImage(id) {
const url = this.base + `/don_confiao/api/catalogue_images/${id}/`;
return http.delete(url).then((r) => r.data);
}
}
export default DjangoApi;

View File

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