145 Commits

Author SHA1 Message Date
02697f09e1 #58 feat: mostrar categorías creadas y actualizadas al sincronizar productos 2026-08-22 16:40:48 -05:00
e81dbb8154 Merge pull request 'feat/49-provenance' (#54) from feat/49-provenance into main
Reviewed-on: #54
2026-08-16 22:21:12 -05:00
4641439454 #49 fix: popup de detalle de producto en el mapa abre correctamente al clicar en el popup agrupado 2026-08-16 21:27:49 -05:00
e286424919 #49 feat: detalle de producto en popup de leaflet en el mapa de provenance en lugar de modal web 2026-08-16 14:22:00 -05:00
6966c4180a #49 feat: panel lateral izquierdo de productos junto al mapa de provenance 2026-08-16 12:42:52 -05:00
798a9bba15 #49 feat: boton para volver al zoom general en el mapa de provenance con icono fit-to-page 2026-08-16 12:36:00 -05:00
f24e7ab2ec #49 feat: agrupacion de marcadores del mismo punto con contador y popup de productos en el mapa de provenance 2026-08-16 12:24:49 -05:00
6a833a1ea4 #49 feat: recuadro informativo de productos en el mapa de provenance sin desplazar posiciones 2026-08-16 12:19:13 -05:00
a251ae59eb #49 feat: separacion de marcadores de provenance escalada por zoom en el mapa 2026-08-16 04:19:17 -05:00
18026617ce #49 fix: marcador de producto sin imagen visible en el mapa de provenance 2026-08-16 04:10:39 -05:00
6200fa7432 #49 feat: desplegable en seccion de provenance con leyenda de colores y titulo sin historia 2026-08-16 03:21:56 -05:00
ad0bbb579d #49 feat: grafico unificado de provenance con filtro por niveles y re-estabilizacion 2026-08-16 03:10:57 -05:00
5c91e66429 #49 feat: graficos de provenance en columnas por nivel y aristas deduplicadas 2026-08-16 02:21:41 -05:00
2967ea4f4d #49 feat: reemplazar cytoscape por vis-network con semantica de certeza en graficos de provenance 2026-08-16 01:41:27 -05:00
58b42d956b #49 fix: labels en nodos de gráficos y municipio con departamento en proveedores 2026-08-15 18:33:30 -05:00
79100ef40e #49 test: cubrir páginas admin de provenance y NavBar 2026-08-15 18:07:06 -05:00
d16b10d2ee #49 docs: documentar provenance (gráficos, admin CRUD, patrones de testing) 2026-08-15 17:48:26 -05:00
9917d4866f #49 feat: diálogo para vincular productos con proveedores en Gestión de Productos 2026-08-15 17:45:34 -05:00
760b3031b2 #49 feat: CRUD admin de organizaciones, proveedores y geografía 2026-08-15 17:40:34 -05:00
4ecfb64118 #49 feat: gráficos de provenance (proveedores-organizaciones y territorios) en el resumen público 2026-08-15 17:27:53 -05:00
efa666dcb3 #49 feat: componente genérico CytoscapeChart con evento select 2026-08-15 17:16:53 -05:00
8335ad2313 #49 feat: builders de grafos de provenance (proveedores-organizaciones y territorios) 2026-08-15 17:14:42 -05:00
21376151f0 #49 feat: servicio genérico de layout por columnas y adaptador cytoscape 2026-08-15 17:13:37 -05:00
1200951b51 #49 feat: servicios API CRUD de organizations, suppliers, countries, departments, municipalities y getProduct 2026-08-15 17:12:33 -05:00
6b477aacbc Merge branch 'main' of ssh://gitea.onecluster.org:6666/OneTeam/don_confiao_frontend 2026-08-15 16:06:46 -05:00
87c01b11a7 feat: mostrar detalle y nombre en ventanas de sincronización Tryton
- Fallidos muestran ID + Nombre/Código + Detalle (causa del fallo)
- Actualizados muestran columna Detalle con los cambios aplicados
- Creados/Sin cambios/Verificados muestran ID + Nombre; ventas ID + Código
- Formateadores toleran backend anterior (IDs sueltos)
2026-08-15 16:05:22 -05:00
ce28e11e68 Merge pull request 'feat/consulta-publica-pedido' (#53) from feat/consulta-publica-pedido into main
Reviewed-on: #53
2026-08-10 01:10:56 -05:00
1989c608e5 feat: diferenciar resumen de pedido/compra y ajustar textos del buscador 2026-08-10 01:07:26 -05:00
e9296af0dc fix: usar ruta /don_confiao/resumen_publico sin api/ para consulta pública 2026-08-10 00:52:59 -05:00
bbba9e707d fix: ruta /pedido con código opcional para consulta pública 2026-08-10 00:42:52 -05:00
5649abdf0a docs: documentar gotchas de lint, testing y build en AGENTS.md 2026-08-10 00:28:11 -05:00
ad33e5bd0f chore: versionar config de ESLint con ignore de archivos pre-existentes 2026-08-10 00:24:31 -05:00
d11eb33ec8 Merge branch 'main' into feat/consulta-publica-pedido 2026-08-09 23:53:33 -05:00
1af4d2052d feat: consulta pública de pedidos por código 2026-08-09 23:48:55 -05:00
2b00b2556a feat: reemplazar tarjeta En Desarrollo por Software Libre con enlaces a repositorios 2026-08-09 20:24:46 -05:00
24fa4800a9 #51 feat: logo del sitio editable desde el panel de administración 2026-08-08 14:19:56 -05:00
5aaaac1e45 Merge branch 'main' of ssh://gitea.onecluster.org:6666/OneTeam/don_confiao_frontend 2026-08-08 13:51:34 -05:00
5b54f0e20d #50 feat: mostrar dirección y mapa de la tienda en la página de inicio 2026-08-08 13:50:21 -05:00
7a7939e309 Merge pull request 'Ajustes vista de catalogo' (#49) from feat/clean-catalog-mobile into main
Reviewed-on: #49
2026-07-11 20:08:30 -05:00
mono
523d0b9d08 #45 feat: show 'Resumen del pedido' and hide planilla alert for catalog orders 2026-07-11 19:52:34 -05:00
mono
0806731a1f #45 feat: rename checkout to 'Finalizar Pedido' and add contact info in checkout modal 2026-07-11 19:47:04 -05:00
mono
beb5e153f3 #45 feat: update login prompt message with contact info and icons 2026-07-11 19:39:36 -05:00
mono
3e19b3d272 #45 feat: hide total price when quantity is 0 2026-07-11 19:05:07 -05:00
mono
b21d3a0ca6 #45 fix: cart toggle button not working on mobile 2026-07-11 18:59:46 -05:00
mono
3029e6d4b9 #45 feat: clean catalog view on mobile devices 2026-07-11 18:56:45 -05:00
7a75f93f67 Merge pull request 'feat: mejora selector de productos con autocomplete y estética del modal en gestión de imágenes de catálogo' (#48) from fix/Buscador_de_imagenes into main
Reviewed-on: #48
2026-07-11 13:55:24 -05:00
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
d5e30c92b0 feat: agregar página de ventas por catálogo
- Agregar CatalogSalesManagement.vue con tabla de ventas
- Filtros por texto (ID/cliente) y rango de fechas
- Filas expandibles con detalle de productos y datos de envío
- Agregar ruta protegida /admin/catalog-sales
- Agregar endpoint getCatalogSales() en servicios API
- Agregar menú 'Ver Ventas por Catálogo' en NavBar
2026-05-29 01:06:44 -05:00
6aecbd37d2 feat: agregar página de gestión de productos activos/inactivos
- Agregar ProductsManagement.vue con tabla de productos
- Filtros: Activos, Inactivos, Todos
- Búsqueda por nombre en tiempo real
- Acciones por lote: activar/desactivar múltiples productos
- Botones condicionales según filtro activo
- Agregar ruta protegida /admin/products
- Actualizar API con métodos getProducts(active) y updateProduct()
- Agregar método patchRequest en django-api
- Agregar menú 'Gestión de Productos' en NavBar
2026-05-29 00:53:38 -05:00
cb79ad6d45 chore: Sintaxis 2026-05-28 23:29:36 -05:00
38c1d8c17c feat: optimize product images to e-commerce standard (600x600 → 300px display)
INDUSTRY STANDARD IMPLEMENTATION:
- Change placeholder images from 300x200 (3:2) to 600x600 (1:1 square)
- Set max-height to 300px with aspect-ratio 1:1 for consistent display
- Follow e-commerce best practices (Amazon, Shopify, Mercado Libre)

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

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

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

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

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

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

RESULT: Cards are now 25-30% smaller maintaining proportions across all devices
2026-05-28 23:10:27 -05:00
6970867f7b feat: transform catalog to modern grid layout with compact product cards
- Transform Card.vue from horizontal to vertical compact design
- Reduce card height with fixed image max-height (160-240px responsive)
- Center all text content (product name, prices, actions)
- Style product name with semibold font-weight, 2-line ellipsis
- Redesign price labels: subtle gray uppercase for labels, bold primary for values
- Style quantity controls: tonal icon buttons with smooth animations
- Redesign input field: solo-filled variant, compact density, subtle shadow
- Transform catalog.vue list layout to responsive grid (v-row/v-col)
- Implement responsive columns: 1 col mobile, 2 cols tablet, 3 cols desktop
- Clean up obsolete .catalog-item styles, use native Vuetify grid spacing
- Add hover effects: translateY(-6px), scale(1.08) on image, border highlight
- Add footer background (#fafafa) for visual separation
- Optimize breakpoints: 6 responsive sizes (374px, 559px, 959px, 1280px, 1920px)
- Result: Professional, compact, balanced e-commerce catalog design
2026-05-28 23:05:57 -05:00
196a5e2068 fix: resolve mobile layout issues and redesign catalog header
- Add 'app' prop to NavBar for proper Vuetify layout integration
- Fix mobile z-index: page-header now compensates for NavBar height (64px)
- Fix cart visibility: position cart above footer instead of hidden underneath
- Redesign header: white background with subtle shadow instead of blue gradient
- Expand catalog layout: increase catalog width (md=10 lg=9) for better content space
- Optimize mobile search: expand search field to full width, hide title on mobile
- Adjust mobile padding: increase to 100px to account for footer + collapsed cart
2026-05-28 22:57:21 -05:00
619590adcc fix: optimize catalog mobile cart behavior and z-index hierarchy
- Fix cart overlay blocking header in mobile (z-index: header 900 < cart 1000)
- Add cart-is-collapsed class with translateY(calc(100% - 60px)) for bottom sheet behavior
- Ensure cart header remains visible and clickable when collapsed in mobile
- Add deep Vuetify styles for search field integration (:deep(.v-field))
- Preserve desktop sticky sidebar behavior (position: sticky, overflow-y: auto)
- Make entire cart header clickable in mobile (@click on v-card-title)
- Add visual feedback with chevron icons (mdi-chevron-up/down)
- Clean CSS organization with section comments
2026-05-28 22:44:13 -05:00
da45c4c1f7 feat: add search bar and restyle catalog header matching Nueva Compra
- Replace plain title with gradient page-header (icon, title, subtitle)
- Add search field with mdi-magnify icon and real-time name filtering
- Integrate search into the header as a single sticky unit
- Add filteredItems computed for client-side product search
- Reset to page 1 on search query change
- Show distinct message when search yields no results
- Adapt pagination to work with filtered results
2026-05-28 22:19:10 -05:00
690c8ff288 feat: redesign hero banner with glassmorphism and animated glow bubbles
- Replace gradient background with light pastel bubbles on #f8fafc
- Add 4 animated glow bubbles (blue, green, yellow, red) with radial gradients
- Implement glassmorphism card for hero content with backdrop-filter
- Add floating corner animations and center pulse animation
- Update logo to colorful variant
- Remove white text classes, use dark slate for readability
2026-05-28 21:40:29 -05:00
df291df451 fix: correct catalog purchase field name and swap api method mappings
- Rename saleline_set to catalogsaleline_set in catalog purchase payload
- Fix swapped createPurchase/createCatalogPurchase method calls in Api class
- Format http.js (indentation, quotes, trailing commas)
2026-05-28 18:57:51 -05:00
9ea01eed39 fix: resolve white screen crashes and restructure layout hierarchy
- Move NavBar from App.vue to layouts/default.vue to fix nested v-app/v-main
- Replace VSkeletonLoader with v-progress-circular to avoid genStructure crash
- Initialize payment_methods as [] and add fallback in v-select
- Remove duplicate fetchClients call from mounted()
- Add authStore.user check in admin route guard
- Replace window.location.href with router.push for SPA navigation
- Add !important to page-header gradient styles
2026-05-28 18:14:37 -05:00
e2604a1837 feat: redesign purchase page with improved UI/UX and responsive layout 2026-05-28 17:31:26 -05:00
d86919e930 feat: improve home page with hero banner, logo, and catalog CTA 2026-05-28 17:24:45 -05:00
7ecf1eb142 feat: add 'Ver Catálogo' option to main navigation menu 2026-05-28 17:18:18 -05:00
e816ae3e7d feat: add inline catalog checkout with modals
Replace redirect to /comprar with a 2-step modal flow (cart confirmation
+ personal data) on the catalog page. Add createCatalogPurchase API
endpoint for catalog sales.
2026-05-28 17:13:22 -05:00
2b52c63133 feat: add tooltip with full product name on hover 2026-05-15 13:34:21 -05:00
15f1f0f826 fix: prevent product name from expanding column and pushing controls out of row 2026-05-15 13:32:00 -05:00
a6d3a86e33 fix: remove name truncation, allow full product name to grow downward 2026-05-15 13:28:44 -05:00
9fd6f8306d fix: constrain product name length to prevent layout breakage 2026-05-15 13:25:18 -05:00
f958735bd2 fix: vertically center card content with image on desktop 2026-05-15 13:22:06 -05:00
f3f55fe81e style: fix lint formatting in django-api.js (quotes, indentation) 2026-05-15 13:12:00 -05:00
99d3881d61 feat: improve catalog responsive design, cart layout and card component
- Redesign Cart component with improved alignments, gaps, and responsive padding
- Refactor Card component with mobile-first layout and consistent spacing
- Update catalog layout to 60/40 split for products/cart on desktop (lg)
- Add PaginationControls component (previously untracked)
- Clean up obsolete CSS styles from catalog page
2026-05-15 13:10:54 -05:00
87f12f76ea Merge pull request 'Agregando proteccion a rutas' (#38) from add-auth-protection-routes into main
Reviewed-on: #38
2026-03-15 23:54:32 -05:00
mono
faa5cf1a46 refactor: remove redundant admin checks from pages (now handled by router) 2026-03-15 23:54:34 -05:00
mono
c1fccc4d53 feat: add admin route protection via router beforeEach 2026-03-15 23:46:08 -05:00
mono
eb4811ab8b feat: add auth protection to routes using meta fields 2026-03-15 23:32:36 -05:00
cd0f1b65dd Merge pull request 'docker-deploy-config' (#37) from docker-deploy-config into main
Reviewed-on: #37
2026-03-15 23:01:56 -05:00
mono
6e3d9b8a49 chore: remove env files from repository tracking 2026-03-15 22:59:33 -05:00
mono
bd977d8609 chore: add deploy env files to gitignore 2026-03-15 22:56:45 -05:00
mono
fa0bf07ad2 fix: add ARG and ENV to Dockerfile for build args, fix typo in .env.staging 2026-03-15 22:53:42 -05:00
mono
95a9a5cb1f feat: add docker deploy configuration for staging and production 2026-03-15 21:08:05 -05:00
65f8b545c6 Merge pull request 'Adicionar método de pago a instrucciones de planilla de una compra #35' (#36) from add_payment_method_summary_purchase_#35 into main
Reviewed-on: #36
2026-03-14 23:55:10 -05:00
mono
3e84642583 #35 feat: add payment method to purchase summary template 2026-03-14 23:54:26 -05:00
4d77b50987 Merge pull request 'Arreglando vistas de importación de clientes, productos y envio de ventas a tryton #24' (#33) from fix_tryton_products_custom_views_#24 into main
Reviewed-on: #33
2026-03-14 23:25:32 -05:00
mono
4cbe18708e #29 feat: improve customers sync UI with loading state and results 2026-03-14 23:22:32 -05:00
mono
794e885b9d #29 feat: improve sales sync UI with loading state and results 2026-03-14 23:19:14 -05:00
mono
59122c04d3 #29 feat: improve products sync UI with loading state and results 2026-03-14 23:16:22 -05:00
d3620d1c33 Merge pull request 'Ajustando menú por rol de usuario #29' (#32) from adjust_menu_by_rol_#29 into main
Reviewed-on: #32
2026-03-14 23:02:00 -05:00
mono
786d0551bb #29 refactor: replace admin code with role-based auth using Pinia 2026-03-14 22:55:24 -05:00
mono
2c9ea4b871 #29 feat: hide admin menu for non-admin users 2026-03-14 22:41:00 -05:00
mono
974c84fdb2 #29 fix: fetch user data on route change after login 2026-03-14 22:36:57 -05:00
mono
373ebb0ae4 docs: emphasize commit authorization requirement in AGENTS.md 2026-03-14 22:33:21 -05:00
mono
63bcfa67fe #29 fix: use role field for user badge 2026-03-14 22:30:50 -05:00
mono
c9d019c918 #29 fix: correct user endpoint to /api/users/me/ 2026-03-14 22:30:10 -05:00
mono
0c31d21212 #29 feat: add user profile menu in navbar 2026-03-14 22:24:38 -05:00
mono
71294af7fa #29 docs: add project analysis to AGENTS.md 2026-03-14 22:24:34 -05:00
18507bc337 fix: Add missing import for useCartStore in catalog 2026-03-14 17:04:54 -05:00
78923ddb6e feat: Add cart store for catalog quantity sync
- Create Pinia cart store to manage cart state globally
- Sync quantity changes between catalog cards and cart sidebar
- Improve product card design with borders and hover effects
- Fix cart sync when updating quantity via minus button or input field
2026-03-14 16:59:45 -05:00
1d37ce42dc feat: Improve product card design and sync cart quantity
- Add borders, hover effects, and box shadows to product cards
- Improve typography and layout styling
- Fix cart sync when updating quantity via minus button or input field
- Add updateQuantity prop to handle manual quantity changes
2026-03-14 16:28:10 -05:00
04509006a4 fix: Update dependencies 2026-03-14 15:36:43 -05:00
2ba8004240 feat: Add quantity controls to cart with +/- buttons 2026-03-07 17:57:31 -05:00
339ac6727f feat: Add shopping cart to catalog page with sidebar 2026-03-07 17:33:57 -05:00
c95279d96b Merge pull request 'Implementando autenticación usando jwt #28' (#31) from implement_jwt_authentication_#28 into main
Reviewed-on: #31
2026-03-07 17:30:23 -05:00
mono
7f73251c5e #28 style: fix lint issues in auth components 2026-03-07 17:03:30 -05:00
mono
3112c82bb2 #28 fix: update auth state on route change 2026-03-07 16:48:24 -05:00
mono
6088623415 docs: add git commit conventions to AGENTS.md 2026-03-07 16:22:04 -05:00
mono
7731b2fbd6 #28 feat(auth): add login/logout buttons in NavBar 2026-03-07 16:12:17 -05:00
mono
d4421aece4 chore: add .env to gitignore and add .env.example 2026-03-07 16:05:26 -05:00
mono
1c8638b8d4 docs: add AGENTS.md with project conventions for AI agents 2026-03-07 16:03:21 -05:00
mono
72d81bdf47 #28 fix: add component imports in pages and isAuthenticated method 2026-03-07 16:03:17 -05:00
9e78646d20 feat: Add catalog page idea 2026-02-21 17:10:30 -05:00
0ba348cc64 #28 fix(login): handle bad credentials. 2026-02-21 13:38:00 -05:00
4e79ecd56b #28 feat(api): add logout. 2026-02-14 18:55:26 -05:00
4a67f85dcb #28 refactor(api): using axios instead fetch. 2026-02-14 18:20:07 -05:00
173cf3771b #28 dependency: add axios 2026-02-14 17:56:37 -05:00
173ddfd05f feat(Login): add example jwt token 2026-02-14 17:49:49 -05:00
c593dd0273 Merge pull request '#23 Comentado botones para adicionar clientes.' (#25) from delete_add_customer_buttons_#23 into main
Reviewed-on: #25
2025-08-30 16:10:33 -05:00
c8460c8e81 #23 fix: comment add customer button. 2025-08-30 16:08:31 -05:00
Rodia
3fdfc3c883 Merge pull request 'feat: Add page send sales to tryton close #20' (#21) from enviar_ventas_tryton#20 into main
Reviewed-on: #21
2025-08-16 15:15:31 -05:00
rodia
460e213e0e feat: Add page send sales to tryton close #20 2025-08-16 17:12:46 -03:00
rodia
e192c3778a feat: Admin code for sychronize Parties, Products 2025-08-16 14:02:55 -03:00
Rodia
5c594ea563 Merge pull request 'AsyncProductsFromTryton' (#18) from AsyncProductsFromTryton into main
Reviewed-on: #18
2025-08-16 09:42:45 -05:00
f22fcb6f16 feat: Add Update Parties From Tryton 2025-08-09 19:44:30 -03:00
1b84c5419f feat: Sincronizar Productos desde Tryton 2025-08-09 16:35:07 -03:00
7fe137ce4f Se agrega al menu la opcion de sincronizar productos desde tryton 2025-07-26 19:57:12 -03:00
e2ccfbcd5f Merge pull request 'config(Deploy): use environment to config api server.' (#17) from config_environment_on_production into main
Reviewed-on: #17
2025-04-04 18:54:03 -05:00
459a028891 config(Deploy): use environment to config api server. 2025-04-04 18:28:10 -05:00
109 changed files with 15289 additions and 1728 deletions

3
.env.example Normal file
View File

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

40
.eslintrc.js Normal file
View File

@@ -0,0 +1,40 @@
/**
* .eslint.js
*
* ESLint configuration file.
*/
module.exports = {
root: true,
env: {
node: true,
},
extends: [
'vuetify',
'./.eslintrc-auto-import.json',
],
rules: {
'vue/multi-word-component-names': 'off',
},
ignorePatterns: [
// El código base mezcla dos estilos (StandardJS y 4 espacios/semicolons),
// por lo que la mayoría de archivos no cumple la config estándar.
// Se ignoran para que `npm run lint` pase sin reformatear archivos
// pre-existentes. Los archivos nuevos de esta tarea se mantienen
// lint-eados. Revisar cuando se unifique el estilo del proyecto.
//
// Los patrones matchean archivos y no directorios: si se ignorara un
// directorio (`src/**`), el walker de ESLint no descendería y las
// negaciones anidadas nunca se evaluarían.
'src/**/*.vue',
'src/**/*.js',
'!src/components/PublicOrderSummary.vue',
'!src/components/order/*.vue',
'!src/components/order/**/*.vue',
'!src/components/provenance/*.vue',
'!src/components/provenance/**/*.vue',
'!src/components/graph/*.vue',
'!src/pages/pedido/*.vue',
'!src/pages/pedido/**/*.vue',
],
}

11
.gitignore vendored
View File

@@ -5,6 +5,7 @@ node_modules
# local env files # local env files
.env.local .env.local
.env.*.local .env.*.local
.env
# Log files # Log files
npm-debug.log* npm-debug.log*
@@ -23,5 +24,13 @@ pnpm-debug.log*
/.browserslistrc /.browserslistrc
/.editorconfig /.editorconfig
/.eslintrc-auto-import.json /.eslintrc-auto-import.json
/.eslintrc.js
/.vite/ /.vite/
# Deploy environment files
deploy/.env.staging
deploy/.env.production
# Generated type declarations
/auto-imports.d.ts
/components.d.ts
/typed-router.d.ts

183
AGENTS.md Normal file
View File

@@ -0,0 +1,183 @@
# Don Confiao - Frontend
## Tech Stack
- **Framework:** Vue 3 (Composition API)
- **UI Library:** Vuetify 3
- **Routing:** Vue Router 4 (auto-routes con `unplugin-vue-router`)
- **State:** Pinia
- **HTTP:** Axios
- **Build:** Vite
- **Linting:** ESLint
## Project Structure
```
src/
├── assets/ # Imágenes, iconos estáticos
├── components/ # Componentes Vue reutilizables
│ ├── order/ # Componentes del resumen de pedido público
│ ├── provenance/ # Provenance (público + admin): gráficos, sección y CRUD admin
│ │ └── admin/ # Organizaciones, Proveedores, Geografía, SupplierLinkDialog
│ └── graph/ # VisChart.vue (wrapper genérico de vis-network)
├── layouts/ # Layouts de página
├── pages/ # Vistas (auto-routed desde文件名)
│ └── admin/ # Páginas admin (products, organizations, suppliers, geography, ...)
├── plugins/ # Configuración de Vuetify, etc.
├── router/ # Configuración de rutas
├── services/ # API services (auth.js, etc.)
│ ├── api.js # Clase wrapper que делегат methods
│ ├── api-implementation.js # Factory que selecciona implementación
│ ├── auth.js # Manejo de auth (login, tokens JWT)
│ ├── django-api.js # Implementación de API para Django
│ └── http.js # Axios instance con interceptors
├── stores/ # Pinia stores
└── styles/ # SCSS settings
```
## Important Conventions
### Auto-imports
- Componentes en `src/components/` se auto-importan por nombre
- Los archivos en `src/pages/*.vue` se routing automáticamente via `unplugin-vue-router`
- Alias `@` = `src/`
### Pages (CRITICAL)
**Siempre importar componentes en los archivos de página:**
```vue
<template>
<MiComponente />
</template>
<script setup>
import MiComponente from '@/components/MiComponente.vue';
</script>
```
### Componentes
- Usar Composition API (`<script setup>` o `export default { }`)
- Naming: PascalCase (ej: `LoginDialog.vue`, `CartGrid.vue`)
- Componentes de página van en `pages/`, componentes reutilizables en `components/`
### Servicios API
- Ubicación: `src/services/`
- Usar Axios para HTTP requests
- JWT tokens en localStorage (`access_token`, `refresh_token`)
- La API se inyecta globalmente via `app.provide('api', api)` y se usa con `inject('api')`
### Routing
- Rutas automáticas basadas en archivos en `src/pages/` (no se registran rutas a mano, excepto en casos especiales)
- `router/index.js` usa `setupLayouts(routes)` + guard `beforeEach`:
- Meta `requiresAuth` → redirige a `/autenticarse` si no hay token
- Meta `requiresAdmin` (o rutas en `ADMIN_ROUTES`) → redirige si el usuario no es admin
- **Rutas públicas** (ej: `/pedido/:code?`) NO deben llevar `requiresAuth`
## Environment Variables
- `VITE_DJANGO_BASE_URL` - URL del backend Django
- `VITE_API_IMPLEMENTATION` - Selecciona la implementación de API (default: django)
## Commands
```bash
npm run dev # Desarrollo (puerto 3000)
npm run preview # Preview build
npm run lint # ESLint --fix (¡OJO: reformatea archivos, ver sección Lint!)
npm test # Vitest (unit tests)
npm run test:watch
npx vite build --outDir /tmp/opencode/dist-check # Verificar build sin tocar dist/
```
## Lint y Estilos (IMPORTANTE)
### El repo mezcla DOS estilos JS (~50/50)
No hay un estilo mayoritario. El código histórico está partido:
- **StandardJS** (2 espacios, sin semicolons, comillas simples): `main.js`, `stores/*`, `plugins/*`, `router/index.js`, `services/http.js` y muchos `.vue`
- **4 espacios + semicolons + comillas dobles**: la mayoría de `services/` y otros `.vue`
### Config de ESLint
- `.eslintrc.js` está **versionado** y extiende `vuetify` (estilo **StandardJS**)
- Tiene `ignorePatterns` masivo: `src/**` excepto los archivos nuevos de la tarea
(`!src/components/order/**`, `!src/components/PublicOrderSummary.vue`,
`!src/components/provenance/**`, `!src/components/graph/*.vue`,
`!src/pages/pedido/**`)
- **Los archivos nuevos deben seguir StandardJS** para quedar lint-eados:
- 2 espacios (indent), sin semicolons, comillas simples
- `function () {}` con espacio, `const f = (x) => x` (arrow-parens en args únicos NO)
- Sin trailing commas; `{ clave: valor }` con espacios internos
- Eventos personalizados en kebab-case, `v-slot:nombre` (no `#nombre`)
- Atributos en orden alfabético dentro de su categoría (`vue/attributes-order`):
directivas (`v-if`, `v-model`) primero, luego props/attrs, luego `@eventos`
### PELIGRO: `npm run lint` usa `--fix`
- Reformatea automáticamente TODO archivo no ignorado que no cumpla estilo
- Puede modificar decenas de archivos de golpe. Antes de usarlo revisar qué está ignorado
- Para **evaluar** sin modificar: `npx eslint . --ignore-path .gitignore` (sin `--fix`)
- Para desglosar por archivo/regla: `npx eslint . --ignore-path .gitignore --format json | node -e "..."`
## Testing (Vitest)
- Correr con `npm test` (`vitest run`)
- Infra en `tests/setup.js` (polyfills: `navigator.clipboard`, `ResizeObserver`, `IntersectionObserver`, `matchMedia`) + `vitest.config.mjs`
- **`vitest.config.mjs` requiere `server.deps.inline: ['vuetify']`** para montar componentes Vuetify en jsdom
- Los tests **no usan globals**: importar `describe/it/expect/vi` explícitamente desde `vitest`
- Tests de páginas con router: esperar a que el router actualice `route.params` con un helper (`waitForRouteParam`)
- Los `.d.ts` generados (`auto-imports.d.ts`, `components.d.ts`, `typed-router.d.ts`) están en `.gitignore`
- **Diálogos Vuetify se teleportan a `document.body`**: en tests, el contenido NO está en `wrapper.text()`. Assertar sobre `document.body.textContent` (ver `tests/unit/components/provenance/ProvenanceDetailModal.spec.js`). Helpers para interactuar con inputs/buttons de diálogos en `tests/unit/components/provenance/admin/helpers.js` (`setBodyInput`, `clickBody`)
- **Selects/autocompletados en tests**: escribir en su `<input>` NO cambia el `v-model`. Emitir `update:modelValue` sobre el componente (`findAllComponents({ name: 'VSelect' })` / `{ name: 'VAutocomplete' }`). OJO: `VAutocomplete` también matchea como `VSelect`, y `v-data-table` añade un `VSelect` (items-per-page); filtrar por `props('items')` cuando haya varios
- **Listas largas** (municipios, proveedores): `v-autocomplete` con búsqueda, pero la selección debe conservarse aunque el filtro no la incluya → `items` computado que devuelve los filtrados + los seleccionados que no estén (patrón en `SuppliersManagement.vue`, `GeographyManagement.vue`, `SupplierLinkDialog.vue`)
## Common Issues
1. **Página en blanco:** Verificar que los componentes en `src/pages/*.vue` tengan import explícito
2. **`npm run build` falla con `EACCES`:** `dist/` tiene archivos root (docker, gitignored) y no se puede borrar. Verificar el build con `npx vite build --outDir /tmp/opencode/dist-check`
3. **Build falla con "Illegal '/' in tags" / "Invalid end tag":** tags de `CurrencyText` malformados pre-existentes (ej: `<CurrencyText <:value="..."/CurrencyText >` o `</CurrencyText>` duplicado). Buscar `CurrencyText` mal cerrado en `ReconciliationJar.vue` / `ReconciliationJarView.vue`
4. **Después de mergear `main`:** correr `npm install` — las deps nuevas (ej: `leaflet` en `StoreLocation.vue`) quedan en `package.json` pero no instaladas → el build falla con "Rollup failed to resolve import"
5. **Errores de lint:** ver sección Lint y Estilos (el repo no cumple un único estilo; no "arreglar" el lint de archivos pre-existentes)
## Git Commits
**Antes de hacer commit:**
1. **SIEMPRE pedir permiso al usuario antes de hacer commit**
2. Mostrar resumen de los cambios que se incluirán
**Formato de mensajes:**
- Usar prefijo `#<numero>` para referenciar el issue (ej: `#28 feat: add login` donde #28 es el número del issue en GitHub/GitLab)
- Prefijos válidos: `feat`, `fix`, `chore`, `docs`, `refactor`, `style`
## Análisis del Proyecto
### Flujo de Autenticación
1. **Login:** `AuthService.login(credentials)` → obtiene JWT tokens → guarda en localStorage
2. **Token:** Se envía en headers via interceptor en `http.js` (`Authorization: Bearer <token>`)
3. **Refresh:** El interceptor renueva automáticamente el token si expira (401)
4. **Logout:** `AuthService.logout()` → limpia localStorage
### Estructura de API
- `api.js`: Interfaz genérica con métodos como `getCustomers()`, `getProducts()`, etc.
- `api-implementation.js`: Factory que selecciona implementación (actualmente solo Django)
- `django-api.js`: Implementación concreta con endpoints de Django
### Componentes Principales
- **NavBar.vue**: Barra de navegación con menú de usuario
- **LoginDialog.vue**: Diálogo de inicio de sesión
- **Purchase.vue / AdminPurchase.vue**: Componentes de compra
- **Cart.vue**: Carrito de compras
- **SummaryPurchase.vue**: Resumen de compra
### Endpoints Django Comunes
- `/api/token/` - Autenticación (login/refresh)
- `/users/me/` - Usuario actual
- `/don_confiao/api/customers/` - Clientes
- `/don_confiao/api/products/` - Productos
- `/don_confiao/api/sales/` - Ventas
- `/don_confiao/resumen_publico/<code>` - Resumen público de pedido por código (AllowAny, SIN `api/`)
## Consulta Pública de Pedidos
- Ruta `/pedido/:code?` (pública, sin `requiresAuth`)
- `getPublicOrderSummary(code)` en `services/api.js` / `django-api.js`
- Componentes modulares en `src/components/order/`: `OrderAccessInfo.vue` (código + link), `OrderCustomer.vue`, `OrderLines.vue`, `OrderPayment.vue`, `OrderTotal.vue`
- Orquestador: `PublicOrderSummary.vue`; `OrderAccessInfo.vue` se comparte con `SummaryPurchase.vue`
## Provenance (Origen e historia de los productos)
- Los gráficos se renderizan en el resumen público (`ProvenanceSection` dentro de `PublicOrderSummary.vue`) a partir de `product_provenance` embebido en el resumen (`GET /don_confiao/resumen_publico/<code>`). El backend NO genera los gráficos.
- Payload: `[{ product: {id, name, catalogue_images[]}, suppliers: [{ supplier: {...}, organization|null, municipality|null, department|null, country|null }] }]`.
- **Genérico reutilizable:**
- `src/components/graph/VisChart.vue`: wrapper de vis-network (props `nodes`, `edges`, `height`, `options`; import `import { DataSet, Network } from 'vis-network/standalone'`; emite `select` con el nodo). **OJO**: nodo con `image: null` → TypeError de vis; omitir la clave `image` si no hay foto
- **Específico público** (`src/components/provenance/`): builder puro en `provenance-graph.js` (`buildProvenanceGraph(provenance, kinds)`, `hasAnySupplier`) y el adaptador vis `provenance-vis.js` (`toVisNodes`, `toVisEdges`, `chartOptions`). **Semántica de certeza**: arista con `certain: true` es continua (inequívoca) y `certain: false` es discontinua (dudosa); con varios proveedores por producto se inserta un nodo `junction:<productId>` (disyunción) con arista sólida hasta él y discontinua hacia cada proveedor; la duda se corta donde los proveedores coinciden (misma organización/municipio/departamento). Las aristas se deduplican por par `(from, to)` y si un mismo par repite con distinta certeza gana la duda. `buildProvenanceGraph` acepta los niveles a graficar (`product`, `supplier`, `organization`, `municipality`, `department`, `country`); los niveles omitidos se saltan conectando el nivel previo con el siguiente. `ProvenanceGraph.vue` unifica los charts en uno con checkboxes de filtro (por defecto solo productos y proveedores), leyenda con el color de cada nivel (`KIND_COLORS` en `provenance-vis.js`), columnas por nivel (x fijo por tipo; la física ordena la y) y espaciado vertical mínimo (`minVerticalSpacing` en `VisChart`); muestra "próximamente estará disponible" cuando no hay relaciones. `ProvenanceSection.vue` muestra el título ("Origen de los productos") con un desplegable (clic en el título o botón chevron) que oculta el gráfico por defecto, y un segundo desplegable para el mapa ("Mapa de origen de los productos") — patrón reutilizable para futuros bloques. `ProvenanceMap.vue` muestra un recuadro informativo (lista) con **todos** los productos del payload —incluidos los sin proveedor o cuyo municipio no tiene coordenadas, marcados "Sin geolocalización"— como panel lateral izquierdo junto al mapa (en columna en pantallas < 900px) y, si hay al menos un municipio con coordenadas, el mapa leaflet al lado: un marcador por producto en el municipio de origen (usa `municipality.latitude/longitude` del payload, sin desplazar posiciones aunque coincidan; los productos del mismo punto se agrupan en un único marcador con contador que al hacer clic despliega un popup con la lista de productos internos para abrir cada uno) y un ícono de persona en la posición de la tienda (settings store, endpoint público `getStoreSettings`); `fitBounds` abarca todos los marcadores, al hacer hover sobre un producto dibuja una línea discontinua hasta la tienda y al hacer hover sobre la tienda dibuja las de todos los productos; un botón flotante en el título del panel (`data-test="map-reset-zoom"`) re-ejecuta `fitBounds` para volver al zoom general; clic en un marcador individual abre un **popup de leaflet** anclado al ícono con el detalle (imagen + producto + proveedor + organización + territorio); clic en un producto del popup agrupado reemplaza su contenido por el detalle de ese producto. Clic en un producto del recuadro: si tiene ubicación hace `flyTo` al punto y abre el popup de detalle en el marcador; si no, abre `ProvenanceRelationModal.vue` (único caso donde se usa el modal web, pues no hay ícono en el mapa) que muestra el proveedor/organización disponibles y la nota "Aún sin geolocalización registrada." o "Aún no se ha vinculado un proveedor a este producto.". `ProvenanceDetailModal.vue`
- **Admin CRUD** (`src/components/provenance/admin/`): `OrganizationsManagement.vue`, `SuppliersManagement.vue`, `GeographyManagement.vue` (tabs países/departamentos/municipios), `SupplierLinkDialog.vue` (vincula productos↔proveedores, abierto desde `ProductsManagement.vue`). Páginas en `src/pages/admin/{organizations,suppliers,geography}.vue`; rutas en `ADMIN_ROUTES` (`router/index.js`); ítems en `NavBar.vue`
- **Endpoints provenance**: `/don_confiao/api/organizations/`, `/suppliers/`, `/countries/`, `/departments/`, `/municipalities/` (CRUD); vincular productos con `PATCH /don_confiao/api/products/<id>/` body `{"suppliers": [ids]}`; detalle de producto (con `suppliers`) via `GET /don_confiao/api/products/<id>/`
- Los tests mockean `vis-network/standalone` (`vi.mock('vis-network/standalone', ...)`) o el propio `VisChart.vue`, y la API con `global.provide: { api }`

34
deploy/.dockerignore Normal file
View File

@@ -0,0 +1,34 @@
# Dependencies
node_modules
# Build output (will be generated inside container)
dist
# Git
.git
.gitignore
# IDE
.idea
.vscode
# Logs
*.log
npm-debug.log*
# Environment files (use deploy/.env.staging or .production instead)
.env
.env.local
.env.*.local
# Development
.vite
*.Dockerfile
docker-compose*.yml
deploy/Dockerfile
deploy/build.sh
deploy/.env*
# Misc
*.md
LICENSE

3
deploy/.env.example Normal file
View File

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

28
deploy/Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
# Stage 1: Build with Node.js
FROM node:20-alpine AS builder
ARG VITE_DJANGO_BASE_URL
ARG VITE_API_IMPLEMENTATION
ARG VITE_CONTACT_PHONE
ENV VITE_DJANGO_BASE_URL=$VITE_DJANGO_BASE_URL
ENV VITE_API_IMPLEMENTATION=$VITE_API_IMPLEMENTATION
ENV VITE_CONTACT_PHONE=$VITE_CONTACT_PHONE
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Serve with Nginx
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

82
deploy/build.sh Executable file
View File

@@ -0,0 +1,82 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
GITEA_REGISTRY="gitea.onecluster.org"
GITEA_USER="mono"
usage() {
echo "Usage: $0 <staging|production> [commit_sha]"
echo ""
echo "Examples:"
echo " $0 staging # Build and push staging image"
echo " $0 production # Build and push production image"
echo " $0 staging abc1234 # Build with specific commit SHA"
exit 1
}
if [ $# -lt 1 ]; then
usage
fi
ENV_TYPE="$1"
COMMIT_SHA="${2:-$(git -C "$PROJECT_ROOT" rev-parse --short HEAD 2>/dev/null || echo "unknown")}"
case "$ENV_TYPE" in
staging)
ENV_FILE="$SCRIPT_DIR/.env.staging"
IMAGE_NAME="don_confiao_frontend_staging"
TAG="latest"
;;
production)
ENV_FILE="$SCRIPT_DIR/.env.production"
IMAGE_NAME="don_confiao_frontend"
TAG="latest"
;;
*)
echo "Error: Invalid environment type '$ENV_TYPE'"
usage
;;
esac
if [ ! -f "$ENV_FILE" ]; then
echo "Error: Environment file not found: $ENV_FILE"
echo "Please create it based on: $SCRIPT_DIR/.env.example"
exit 1
fi
echo "=== Building $ENV_TYPE image ==="
echo "Image tag: $TAG"
echo "Commit SHA: $COMMIT_SHA"
echo "Environment file: $ENV_FILE"
set -a
source "$ENV_FILE"
set +a
BUILD_ARGS="--build-arg VITE_DJANGO_BASE_URL=$VITE_DJANGO_BASE_URL"
BUILD_ARGS="$BUILD_ARGS --build-arg VITE_API_IMPLEMENTATION=$VITE_API_IMPLEMENTATION"
BUILD_ARGS="$BUILD_ARGS --build-arg VITE_CONTACT_PHONE=$VITE_CONTACT_PHONE"
FULL_IMAGE_NAME="$GITEA_REGISTRY/$GITEA_USER/$IMAGE_NAME"
echo "Building image..."
docker build \
$BUILD_ARGS \
-t "$FULL_IMAGE_NAME:$TAG" \
-t "$FULL_IMAGE_NAME:$COMMIT_SHA" \
-f "$SCRIPT_DIR/Dockerfile" \
"$PROJECT_ROOT"
echo ""
echo "=== Pushing image to registry ==="
docker push "$FULL_IMAGE_NAME:$TAG"
docker push "$FULL_IMAGE_NAME:$COMMIT_SHA"
echo ""
echo "=== Done ==="
echo "Image: $FULL_IMAGE_NAME:$TAG"
echo "Commit: $FULL_IMAGE_NAME:$COMMIT_SHA"

29
deploy/nginx.conf Normal file
View File

@@ -0,0 +1,29 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript application/json;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Vue Router: redirect all non-file requests to index.html
location / {
try_files $uri $uri/ /index.html;
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
}

View File

@@ -7,4 +7,7 @@ services:
- ./:/app/ - ./:/app/
ports: ports:
- "7001:3000" - "7001:3000"
environment:
- VITE_DJANGO_BASE_URL=http://localhost:7000
- VITE_API_IMPLEMENTATION=django

3319
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,18 +5,25 @@
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint . --fix --ignore-path .gitignore" "lint": "eslint . --fix --ignore-path .gitignore",
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@mdi/font": "7.4.47", "@mdi/font": "7.4.47",
"axios": "^1.13.5",
"core-js": "^3.37.1", "core-js": "^3.37.1",
"leaflet": "^1.9.4",
"roboto-fontface": "*", "roboto-fontface": "*",
"vee-validate": "^4.14.6", "vee-validate": "^4.14.6",
"vis-network": "^10.1.1",
"vue": "^3.4.31", "vue": "^3.4.31",
"vuetify": "^3.6.11" "vuetify": "^3.6.11"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.0.5", "@vitejs/plugin-vue": "^5.0.5",
"@vue/eslint-config-typescript": "^13.0.0",
"@vue/test-utils": "^2.4.11",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-standard": "^17.1.0", "eslint-config-standard": "^17.1.0",
"eslint-config-vuetify": "^1.0.0", "eslint-config-vuetify": "^1.0.0",
@@ -25,8 +32,10 @@
"eslint-plugin-node": "^11.1.0", "eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^6.4.0", "eslint-plugin-promise": "^6.4.0",
"eslint-plugin-vue": "^9.27.0", "eslint-plugin-vue": "^9.27.0",
"jsdom": "^26.1.0",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"sass": "1.77.6", "sass": "1.77.6",
"typescript": "^5.9.3",
"unplugin-auto-import": "^0.17.6", "unplugin-auto-import": "^0.17.6",
"unplugin-fonts": "^1.1.1", "unplugin-fonts": "^1.1.1",
"unplugin-vue-components": "^0.27.2", "unplugin-vue-components": "^0.27.2",
@@ -34,6 +43,7 @@
"vite": "^5.3.3", "vite": "^5.3.3",
"vite-plugin-vue-layouts": "^0.11.0", "vite-plugin-vue-layouts": "^0.11.0",
"vite-plugin-vuetify": "^2.0.3", "vite-plugin-vuetify": "^2.0.3",
"vitest": "^3.2.7",
"vue-router": "^4.4.0" "vue-router": "^4.4.0"
} }
} }

View File

@@ -1,19 +1,11 @@
<template> <template>
<v-app> <v-app>
<NavBar /> <router-view />
<v-main>
<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>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" version="1.0"><path d="M256 196.25c0 40.122-21.534 60.038-64.603 59.747H67.495C22.498 255.997 0 236.663 0 197.994V60.184C0 20.06 22.016 0 66.049 0h124.384C234.144 0 256 20.206 256 60.62v135.63" style="text-align:start;line-height:125%;-inkscape-font-specification:Kimberley" font-size="108.872" font-weight="400" fill="#1b2019" font-family="Kimberley"/><path d="M19.006 169.515c5.949-3.124 17.076 1.966 22.115 10.12 4.518 7.31 27.406 14.114 35.516 10.558 5.412-2.372 6.779-5.378 7.202-15.844.798-19.685-3.636-35.098-17.99-62.536-7.205-13.77-14.753-29-16.775-33.835C47.052 73.14 41.601 64.6 36.961 59c-9.876-11.91-10.183-15.164-2.078-22.074C47.02 26.581 67.52 43.327 63.095 59.97c-2.178 8.191 2.003 20.14 15.85 45.295 16.431 29.85 33.084 41.94 43.03 31.243 2.57-2.763 5.759-18.178 8.698-42.04 4.421-35.889 4.399-38.037-.47-45.252-8.418-12.475-.48-25.42 12.84-20.943 13.43 4.513 22.192 22.778 13.775 28.714-3.735 2.633-4.942 10.29-6.636 42.097-2.173 40.785-.53 51.7 8.609 57.187 8.121 4.876 10.946 3.885 28.067-9.889 18.167-14.612 25.636-24.069 26.115-33.067.446-8.37 5.992-12.847 14.832-11.972 5.635.56 8.15 2.41 11.3 8.312 6.431 12.053 4.589 19.71-5.212 21.667-7.987 1.592-21.708 19.27-42.815 55.16-22.288 37.897-27.173 41.845-52.152 42.147-10.268.123-24.634-3.295-58.453-13.914-24.663-7.746-48.043-14.468-51.957-14.943-14.142-1.708-21.187-24.12-9.51-30.257z" fill="#ededed"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View File

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

View File

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

View File

@@ -0,0 +1,570 @@
<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
>
<!-- Código -->
<template #item.code="{ item }">
<span v-if="item.code" class="d-flex align-center">
<code class="mr-1">{{ item.code }}</code>
<v-btn
icon="mdi-content-copy"
size="x-small"
variant="text"
:title="`Copiar código ${item.code}`"
@click="copyCode(item.code)"
></v-btn>
</span>
<span v-else>-</span>
</template>
<!-- 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
>
<!-- Código -->
<template #item.code="{ item }">
<span v-if="item.code" class="d-flex align-center">
<code class="mr-1">{{ item.code }}</code>
<v-btn
icon="mdi-content-copy"
size="x-small"
variant="text"
:title="`Copiar código ${item.code}`"
@click="copyCode(item.code)"
></v-btn>
</span>
<span v-else>-</span>
</template>
<!-- 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: 'Código', key: 'code', 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: 'Código', key: 'code', 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 = '';
}
async function copyCode(code) {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(code)
}
snackbar.value = { show: true, message: 'Código copiado', color: 'success' }
} catch {
snackbar.value = { show: true, message: 'No se pudo copiar el código', color: 'error' }
}
}
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,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,51 +0,0 @@
<template>
<v-dialog v-model="dialog" persistent>
<v-card>
<v-card-title>
Ingrese el código
</v-card-title>
<v-card-text>
<v-form id="code-form" @submit.prevent="verifyCode">
<v-text-field v-model="code" label="Código" type="password" autocomplete="off" />
</v-form>
</v-card-text>
<v-card-actions>
<v-btn type="submit" form="code-form" color="green">Aceptar</v-btn>
<v-btn :to="{ path: '/' }" color="red">Cancelar</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
import { inject } from 'vue';
export default {
data() {
return {
api: inject('api'),
dialog: true,
code: '',
};
},
methods: {
verifyCode() {
this.api.isValidAdminCode(this.code)
.then(data => {
if (data['validCode']) {
this.$emit('code-verified', true);
this.dialog = false;
} else {
alert('Código incorrecto');
this.$emit('code-verified', false);
}
})
.catch(error => {
alert('Error al validar el código');
this.$emit('code-verified', false);
console.error(error);
});
}
},
}
</script>

267
src/components/Login.vue Normal file
View File

@@ -0,0 +1,267 @@
<template>
<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">
<SiteLogo 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-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-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 setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import AuthService from '@/services/auth'
import SiteLogo from '@/components/SiteLogo.vue'
const router = useRouter()
const username = ref('')
const password = ref('')
const error = ref('')
const isSubmitting = ref(false)
const loginForm = ref(null)
function requiredRule(value) {
return !!value || 'Este campo es obligatorio'
}
async function onSubmit() {
error.value = ''
isSubmitting.value = true
try {
const form = loginForm.value
if (form) {
const { valid } = await form.validate()
if (!valid) return
}
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>
<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

@@ -0,0 +1,75 @@
<template>
<v-dialog v-model="show" max-width="400">
<v-card>
<v-card-title class="headline d-flex align-center">
Iniciar sesión
<v-spacer />
<v-btn icon size="small" variant="text" @click="show = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text>
<v-form ref="form" @submit.prevent="onSubmit">
<v-text-field
v-model="username"
label="Usuario"
:rules="[required]"
required
/>
<v-text-field
v-model="password"
label="Contraseña"
type="password"
:rules="[required]"
required
/>
<v-alert v-if="error" type="error" class="mt-2">{{ error }}</v-alert>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn text @click="show = false">Cancelar</v-btn>
<v-btn color="primary" @click="onSubmit">Entrar</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
import AuthService from '@/services/auth';
export default {
name: 'LoginDialog',
data: () => ({
show: false,
username: '',
password: '',
error: '',
}),
methods: {
required(v) {
return !!v || 'Campo obligatorio';
},
async onSubmit() {
this.error = '';
const form = this.$refs.form;
if (!(await form.validate())) return;
try {
await AuthService.login({
username: this.username,
password: this.password,
});
this.show = false;
this.$emit('login-success');
} catch (e) {
this.error = e.message ?? 'Error al iniciar sesión';
}
},
open() {
this.show = true;
},
},
};
</script>

32
src/components/Logout.vue Normal file
View File

@@ -0,0 +1,32 @@
<template>
<v-container class="d-flex flex-column align-center justify-center" style="height: 100vh;">
<v-progress-circular indeterminate color="primary" />
<p class="mt-4">Cerrando sesión…</p>
</v-container>
</template>
<script>
import AuthService from '@/services/auth';
export default {
name: 'DonConfiao',
mounted() {
this.logout();
},
methods: {
logout() {
AuthService.logout();
this.$router.push({
path: '/autenticarse'
});
},
},
};
</script>
<style scoped>
p {
font-size: 1.1rem;
color: #555;
}
</style>

View File

@@ -1,13 +1,50 @@
<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>
<template v-if="$vuetify.display.mdAndUp"> <v-btn
<v-btn icon="mdi-magnify" variant="text"></v-btn> v-if="!isAuthenticated"
<v-btn icon="mdi-filter" variant="text"></v-btn> prepend-icon="mdi-login"
</template> variant="text"
<v-btn icon="mdi-dots-vertical" variant="text"></v-btn> @click="navigate('/autenticarse')"
>
Login
</v-btn>
<v-btn
v-else
variant="text"
>
<v-menu activator="parent">
<v-list>
<v-list-item>
<v-list-item-title class="font-weight-bold">{{ user?.username }}</v-list-item-title>
<v-list-item-subtitle>{{ user?.email }}</v-list-item-subtitle>
</v-list-item>
<v-divider></v-divider>
<v-list-item v-if="user?.first_name || user?.last_name">
<v-list-item-title>{{ user?.first_name }} {{ user?.last_name }}</v-list-item-title>
</v-list-item>
<v-list-item>
<v-chip
:color="user?.role === 'administrator' ? 'error' : 'primary'"
size="small"
>
{{ user?.role === 'administrator' ? 'Administrador' : 'Usuario' }}
</v-chip>
</v-list-item>
<v-divider></v-divider>
<v-list-item @click="logout">
<v-list-item-title>
<v-icon start>mdi-logout</v-icon>
Cerrar sesión
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-icon start>mdi-account</v-icon>
{{ user?.username }}
</v-btn>
</v-app-bar> </v-app-bar>
<v-navigation-drawer v-model="drawer" <v-navigation-drawer v-model="drawer"
:location="$vuetify.display.mobile ? 'bottom' : undefined" :location="$vuetify.display.mobile ? 'bottom' : undefined"
@@ -17,22 +54,25 @@
nav nav
> >
<v-list-item <v-list-item
v-for="item in menuItems" v-for="item in filteredMenuItems"
:key="item.title" :key="item.title"
:title="item.title" :title="item.title"
:prepend-icon="item.icon" :prepend-icon="item.icon"
@click="navigate(item.route)" @click="navigate(item.route)"
></v-list-item> ></v-list-item>
<v-list-item prepend-icon="mdi-cog" title="Administracion" @click="toggleAdminMenu()"></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-list-item v-if="isAuthenticated && isAdmin && showAdminMenu">
<v-list v-if="showAdminMenu"> <v-list>
<v-list-item <template v-for="(item, index) in menuAdminItems" :key="index">
v-for="item in menuAdminItems" <v-divider v-if="item.divider"></v-divider>
:key="item.title" <v-list-subheader v-else-if="item.header">{{ item.header }}</v-list-subheader>
:title="item.title" <v-list-item
:prepend-icon="item.icon" v-else
@click="navigateAdmin(item.route)" :title="item.title"
></v-list-item> :prepend-icon="item.icon"
@click="navigateAdmin(item.route)"
></v-list-item>
</template>
</v-list> </v-list>
</v-list-item> </v-list-item>
</v-list> </v-list>
@@ -40,29 +80,88 @@
</template> </template>
<script> <script>
import AuthService from '@/services/auth';
import { useAuthStore } from '@/stores/auth';
import { inject } from 'vue';
export default { export default {
name: 'NavBar', name: 'NavBar',
setup() {
const authStore = useAuthStore();
return { authStore };
},
data: () => ({ data: () => ({
drawer: false, drawer: false,
group: null, group: null,
showAdminMenu: false, showAdminMenu: false,
menuItems: [ isAuthenticated: false,
{ title: 'Inicio', route: '/', icon: 'mdi-home'}, user: null,
{ title: 'Comprar', route:'/comprar', icon: 'mdi-cart'}, api: inject('api'),
], menuItems: [
{ title: 'Inicio', route: '/', icon: 'mdi-home'},
{ title: 'Comprar', route:'/comprar', icon: 'mdi-cart'},
{ title: 'Ver Catálogo', route: '/catalog', icon: 'mdi-store'},
{ title: 'Consultar mi pedido o compra', route: '/pedido', icon: 'mdi-magnify-scan'},
],
menuAdminItems: [ menuAdminItems: [
{ title: 'Cuadrar tarro', route: '/cuadrar_tarro', icon: 'mdi-calculator'}, { title: 'Cuadrar tarro', route: '/cuadrar_tarro', icon: 'mdi-calculator'},
{ title: 'Cuadres de tarro', route: '/cuadres_de_tarro', icon: 'mdi-chart-bar'}, { title: 'Cuadres de tarro', route: '/cuadres_de_tarro', icon: 'mdi-chart-bar'},
{ title: 'CSV Tryton', route: '/ventas_para_tryton', icon: 'mdi-file-table'}, { title: 'CSV Tryton', route: '/ventas_para_tryton', icon: 'mdi-file-table'},
{ title: 'Compra adm', route: '/compra_admin', icon: 'mdi-cart'}, { title: 'Compra adm', route: '/compra_admin', icon: 'mdi-cart'},
{ title: 'Gestión de Productos', route: '/admin/products', icon: 'mdi-package-variant'},
{ title: 'Imágenes de Catálogo', route: '/admin/catalogue-images', icon: 'mdi-image-multiple'},
{ title: 'Datos de la Tienda', route: '/admin/store-settings', icon: 'mdi-map-marker'},
{ title: 'Ver Ventas por Catálogo', route: '/admin/catalog-sales', icon: 'mdi-cart-arrow-down'},
{ title: 'Organizaciones', route: '/admin/organizations', icon: 'mdi-domain'},
{ title: 'Proveedores', route: '/admin/suppliers', icon: 'mdi-truck'},
{ title: 'Geografía', route: '/admin/geography', icon: 'mdi-earth'},
{ 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'}
], ],
}), }),
watch: { computed: {
group () { isAdmin() {
this.drawer = false 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();
if (this.isAuthenticated) {
this.fetchUser();
}
}, },
watch: {
group () {
this.drawer = false
},
$route() {
this.checkAuth();
if (this.isAuthenticated && !this.user) {
this.fetchUser();
}
},
},
methods: { methods: {
checkAuth() {
this.isAuthenticated = AuthService.isAuthenticated();
},
async fetchUser() {
try {
this.user = await this.api.getCurrentUser();
this.authStore.setUser(this.user);
} catch (error) {
console.error('Error fetching user:', error);
}
},
navigate(route) { navigate(route) {
this.$router.push(route); this.$router.push(route);
}, },
@@ -73,6 +172,14 @@
toggleAdminMenu() { toggleAdminMenu() {
this.showAdminMenu = !this.showAdminMenu; this.showAdminMenu = !this.showAdminMenu;
}, },
logout() {
AuthService.logout();
this.isAuthenticated = false;
this.user = null;
this.authStore.clearUser();
this.$router.push('/');
},
} }
} }
</script> </script>

View File

@@ -0,0 +1,291 @@
<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>
<!-- Slot para columna de proveedores -->
<template #item.actions="{ item }">
<v-btn
@click="openSupplierLink(item)"
color="primary"
size="small"
variant="tonal"
prepend-icon="mdi-truck"
>
Proveedores
</v-btn>
</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>
<!-- Diálogo de proveedores del producto -->
<SupplierLinkDialog
:visible="linkDialog"
:product="linkProduct"
@update:visible="linkDialog = $event"
/>
<!-- 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";
import SupplierLinkDialog from "@/components/provenance/admin/SupplierLinkDialog.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("");
const linkDialog = ref(false);
const linkProduct = ref(null);
// 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 },
{ title: "Proveedores", key: "actions", sortable: false },
];
// 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 };
}
function openSupplierLink(product) {
linkProduct.value = product;
linkDialog.value = true;
}
// 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

@@ -0,0 +1,80 @@
<template>
<div>
<v-container v-if="loading" class="text-center py-8">
<v-progress-circular color="primary" indeterminate />
<p class="text-body-1 text-grey mt-4">Cargando pedido...</p>
</v-container>
<v-alert v-else-if="error" class="my-4" type="error">
{{ error }}
</v-alert>
<v-card v-else-if="purchase" class="rounded-lg">
<v-card-item>
<template #prepend>
<v-icon color="primary" size="36">mdi-receipt-text-outline</v-icon>
</template>
<v-card-title class="font-weight-bold text-h6">
{{ purchase.type === 'catalog' ? 'Resumen del pedido' : 'Resumen de la compra' }}
</v-card-title>
</v-card-item>
<v-divider />
<v-card-text>
<OrderAccessInfo :code="purchase.code" />
<v-divider class="my-3" />
<v-list>
<v-list-item>
<template #prepend>
<v-icon>mdi-calendar</v-icon>
</template>
<v-list-item-title>Fecha</v-list-item-title>
<v-list-item-subtitle>{{ purchase.date }}</v-list-item-subtitle>
</v-list-item>
<OrderCustomer :customer="purchase.customer" />
<OrderPayment
v-if="purchase.type === 'sale'"
:payment-method="purchase.payment_method"
/>
</v-list>
<v-divider class="my-3" />
<OrderLines :lines="purchase.lines" />
<v-divider class="my-3" />
<OrderTotal :total="calculateTotal(purchase.lines)" />
<ProvenanceSection
v-if="purchase.product_provenance?.length"
:provenance="purchase.product_provenance"
/>
</v-card-text>
</v-card>
</div>
</template>
<script setup>
import OrderAccessInfo from '@/components/order/OrderAccessInfo.vue'
import OrderCustomer from '@/components/order/OrderCustomer.vue'
import OrderLines from '@/components/order/OrderLines.vue'
import OrderPayment from '@/components/order/OrderPayment.vue'
import OrderTotal from '@/components/order/OrderTotal.vue'
import ProvenanceSection from '@/components/provenance/ProvenanceSection.vue'
defineProps({
purchase: {
type: Object,
default: null,
},
loading: {
type: Boolean,
default: false,
},
error: {
type: String,
default: '',
},
})
function calculateTotal (lines = []) {
return lines.reduce((total, line) => {
return total + Number(line.unit_price || 0) * Number(line.quantity || 0)
}, 0)
}
</script>

View File

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

View File

@@ -52,7 +52,7 @@
:key="payment_method" :key="payment_method"
:value="payment_method" :value="payment_method"
> >
{{ payment_method }}&nbsp; <CurrencyText :value="totalByMethod(payment_method)"</CurrencyText> {{ payment_method }}&nbsp; <CurrencyText :value="totalByMethod(payment_method)" />
</v-tab> </v-tab>
</v-tabs> </v-tabs>

View File

@@ -41,7 +41,7 @@
v-for="(elements, paymentMethod) in purchases" v-for="(elements, paymentMethod) in purchases"
:key="paymentMethod" :key="paymentMethod"
> >
{{ paymentMethod }}&nbsp; <CurrencyText :value="elements.total"</CurrencyText> {{ paymentMethod }}&nbsp; <CurrencyText :value="elements.total" />
</v-tab> </v-tab>
</v-tabs> </v-tabs>
<v-tabs-window v-model="tab"> <v-tabs-window v-model="tab">
@@ -63,7 +63,7 @@
<td><v-btn @click="openSummaryModal(purchase.id)">{{ purchase.id }}</v-btn></td> <td><v-btn @click="openSummaryModal(purchase.id)">{{ purchase.id }}</v-btn></td>
<td>{{ purchase.date }}</td> <td>{{ purchase.date }}</td>
<td>{{ purchase.customer }}</td> <td>{{ purchase.customer }}</td>
<td><CurrencyText :value="purchase.total"</CurrencyText></td> <td><CurrencyText :value="purchase.total" /></td>
</tr> </tr>
</tbody> </tbody>

View File

@@ -0,0 +1,39 @@
<template>
<v-img
v-if="logoUrl && !broken"
:src="logoUrl"
:alt="alt"
:max-width="maxWidth"
@error="broken = true"
/>
</template>
<script setup>
import { computed, inject, onMounted, ref } from 'vue';
import { useSettingsStore } from '@/stores/settings';
const props = defineProps({
maxWidth: {
type: [Number, String],
default: 180,
},
alt: {
type: String,
default: 'Don Confiao',
},
});
const settingsStore = useSettingsStore();
const api = inject('api');
const broken = ref(false);
const logoUrl = computed(() => settingsStore.logo);
onMounted(async () => {
try {
await settingsStore.fetchSettings(api);
} catch (e) {
broken.value = true;
}
});
</script>

View File

@@ -0,0 +1,167 @@
<template>
<v-container v-if="settings && settings.address">
<v-sheet
class="store-location-section rounded-xl pa-6 pa-md-8"
elevation="2"
>
<div class="d-flex flex-column align-center text-center mb-4">
<div class="d-flex align-center mb-1">
<v-icon color="primary" size="36" class="mr-2">mdi-map-marker</v-icon>
<h2 class="text-h4 font-weight-bold mb-0">Visítanos</h2>
</div>
<p class="text-body-1 text-medium-emphasis mb-0">
Encuentra nuestra tienda física y adquiere nuestros productos
</p>
</div>
<div class="text-center mb-4">
<div class="d-flex align-center justify-center">
<v-icon color="red" class="mr-2">mdi-map-marker</v-icon>
<span class="text-h6 font-weight-medium">
{{ settings.address }}
</span>
</div>
<v-btn
v-if="hasCoordinates"
:href="directionsLink"
target="_blank"
rel="noopener noreferrer"
color="primary"
variant="tonal"
size="small"
prepend-icon="mdi-directions"
class="mt-2"
>
Cómo llegar
</v-btn>
<v-btn
v-else
:href="searchLink"
target="_blank"
rel="noopener noreferrer"
color="primary"
variant="tonal"
size="small"
prepend-icon="mdi-map-search"
class="mt-2"
>
Ver en el mapa
</v-btn>
</div>
<div v-if="hasCoordinates" ref="mapEl" class="map-wrapper"></div>
<v-alert v-else type="info" variant="tonal" class="ma-0">
La ubicación en el mapa aún no está configurada.
</v-alert>
</v-sheet>
</v-container>
</template>
<script setup>
import { ref, computed, inject, onMounted, onBeforeUnmount, nextTick } from 'vue';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { useSettingsStore } from '@/stores/settings';
const api = inject('api');
const settingsStore = useSettingsStore();
const settings = computed(() => settingsStore.settings);
const mapEl = ref(null);
let map = null;
let marker = null;
const hasCoordinates = computed(
() =>
settings.value &&
settings.value.latitude != null &&
settings.value.longitude != null
);
const redPinIcon = L.divIcon({
className: 'store-map-pin',
html: '<i class="mdi mdi-map-marker" style="font-size:48px;line-height:1;color:#f44336;"></i>',
iconSize: [48, 48],
iconAnchor: [24, 48],
});
const directionsLink = computed(() => {
const lat = settings.value.latitude;
const lng = settings.value.longitude;
return `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}#map=16/${lat}/${lng}`;
});
const searchLink = computed(
() =>
`https://www.openstreetmap.org/search?query=${encodeURIComponent(
settings.value.address
)}`
);
function initMap() {
if (!hasCoordinates.value || !mapEl.value || map) return;
const lat = settings.value.latitude;
const lng = settings.value.longitude;
map = L.map(mapEl.value, {
scrollWheelZoom: false,
}).setView([lat, lng], 16);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(map);
marker = L.marker([lat, lng], { icon: redPinIcon }).addTo(map);
marker.bindPopup(settings.value.address);
}
function handleResize() {
if (map) {
map.invalidateSize();
}
}
onMounted(async () => {
try {
await settingsStore.fetchSettings(api);
await nextTick();
initMap();
} catch (error) {
console.error('Error al cargar la información de la tienda:', error);
}
window.addEventListener('resize', handleResize);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize);
if (map) {
map.remove();
map = null;
marker = null;
}
});
</script>
<style scoped>
.store-location-section {
max-width: 1200px;
margin: 0 auto;
}
.map-wrapper {
position: relative;
width: 100%;
height: 400px;
border-radius: 12px;
overflow: hidden;
z-index: 0;
}
.map-wrapper :deep(.leaflet-control-attribution) {
font-size: 10px;
}
</style>

View File

@@ -0,0 +1,235 @@
<template>
<v-container fluid>
<v-row align="center">
<v-col cols="12" md="6">
<h1 class="text-h4">Datos de la Tienda</h1>
</v-col>
<v-col cols="12" md="6" class="text-md-right">
<v-btn
color="primary"
variant="elevated"
prepend-icon="mdi-content-save"
:loading="submitting"
:disabled="submitting"
@click="submitForm"
>
Guardar Cambios
</v-btn>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-card>
<v-card-text>
<v-form ref="formRef">
<v-text-field
v-model="form.address"
label="Dirección de la tienda"
:rules="[(v) => !!v || 'La dirección es obligatoria']"
variant="outlined"
prepend-inner-icon="mdi-map-marker"
class="mb-3"
/>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model.number="form.latitude"
label="Latitud (opcional)"
variant="outlined"
prepend-inner-icon="mdi-crosshairs-gps"
type="number"
step="any"
class="mb-3"
hint="Ej: 4.6097"
persistent-hint
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model.number="form.longitude"
label="Longitud (opcional)"
variant="outlined"
prepend-inner-icon="mdi-crosshairs-gps"
type="number"
step="any"
class="mb-3"
hint="Ej: -74.0817"
persistent-hint
/>
</v-col>
</v-row>
<v-divider class="my-4" />
<div>
<h3 class="text-h6 mb-2">Logo del sitio</h3>
<v-img
v-if="currentLogo"
:src="currentLogo"
alt="Logo actual"
max-width="180"
class="mb-3"
/>
<v-file-input
v-model="logoFile"
label="Seleccionar nueva imagen del logo"
accept="image/*"
prepend-icon="mdi-image"
variant="outlined"
class="mb-3"
clearable
/>
<v-btn
v-if="currentLogo"
variant="tonal"
color="error"
prepend-icon="mdi-image-off"
:loading="removingLogo"
:disabled="removingLogo || submitting"
@click="removeLogo"
>
Quitar logo actual
</v-btn>
</div>
</v-form>
</v-card-text>
</v-card>
</v-col>
</v-row>
<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 } from "vue";
import { useSettingsStore } from "@/stores/settings";
const api = inject("api");
const settingsStore = useSettingsStore();
const formRef = ref(null);
const submitting = ref(false);
const removingLogo = ref(false);
const logoFile = ref(null);
const currentLogo = ref(null);
const form = ref({
address: "",
latitude: null,
longitude: null,
});
const snackbar = ref({ show: false, message: "", color: "success" });
async function loadSettings() {
try {
const data = await api.getStoreSettings();
settingsStore.setSettings(data);
form.value = {
address: data.address || "",
latitude: data.latitude ?? null,
longitude: data.longitude ?? null,
};
currentLogo.value = data.logo || null;
} catch (error) {
console.error("Error al cargar la información de la tienda:", error);
showSnackbar("Error al cargar la información de la tienda", "error");
}
}
async function submitForm() {
const formComponent = formRef.value;
if (formComponent) {
const { valid } = await formComponent.validate();
if (!valid) return;
}
submitting.value = true;
try {
if (logoFile.value) {
const payload = new FormData();
payload.append("address", form.value.address);
if (form.value.latitude !== "" && form.value.latitude != null) {
payload.append("latitude", toNumberOrNull(form.value.latitude));
}
if (form.value.longitude !== "" && form.value.longitude != null) {
payload.append("longitude", toNumberOrNull(form.value.longitude));
}
payload.append("logo", logoFile.value);
await api.updateStoreSettings(payload);
} else {
await api.updateStoreSettings({
address: form.value.address,
latitude: toNumberOrNull(form.value.latitude),
longitude: toNumberOrNull(form.value.longitude),
});
}
logoFile.value = null;
await refreshLogo();
showSnackbar("Información de la tienda actualizada", "success");
} catch (error) {
console.error("Error al guardar la información de la tienda:", error);
showSnackbar("Error al guardar la información de la tienda", "error");
} finally {
submitting.value = false;
}
}
async function removeLogo() {
removingLogo.value = true;
try {
await api.updateStoreSettings({ logo: null });
logoFile.value = null;
await refreshLogo();
showSnackbar("Logo eliminado", "success");
} catch (error) {
console.error("Error al quitar el logo:", error);
showSnackbar("Error al quitar el logo", "error");
} finally {
removingLogo.value = false;
}
}
async function refreshLogo() {
const data = await api.getStoreSettings();
settingsStore.setSettings(data);
currentLogo.value = data.logo || null;
}
function toNumberOrNull(value) {
if (value === null || value === undefined || value === "") return null;
const number = Number(value);
return Number.isNaN(number) ? null : number;
}
function showSnackbar(message, color) {
snackbar.value = { show: true, message, color };
}
onMounted(loadSettings);
</script>
<style scoped>
.text-md-right {
text-align: right;
}
@media (max-width: 960px) {
.text-md-right {
text-align: left;
}
}
</style>

View File

@@ -8,8 +8,9 @@
</v-container> </v-container>
<v-container v-show="id"> <v-container v-show="id">
<v-toolbar> <v-toolbar>
<v-toolbar-title> Resumen de la compra {{ id }}</v-toolbar-title> <v-toolbar-title> {{ type === 'catalog' ? 'Resumen del pedido' : 'Resumen de la compra' }} {{ id }}</v-toolbar-title>
</v-toolbar> </v-toolbar>
<OrderAccessInfo v-if="purchase.code" :code="purchase.code" />
<v-list> <v-list>
<v-list-item> <v-list-item>
<v-list-item-title>Fecha:</v-list-item-title> <v-list-item-title>Fecha:</v-list-item-title>
@@ -39,7 +40,7 @@
{{ currencyFormat(calculateSubtotal(item.unit_price, item.quantity)) }} {{ currencyFormat(calculateSubtotal(item.unit_price, item.quantity)) }}
</template> </template>
</v-data-table-virtual> </v-data-table-virtual>
<v-alert type="info" class="my-4"> <v-alert v-if="type !== 'catalog'" type="info" class="my-4">
Recuerda adicionar a la planilla física lo siguiente Recuerda adicionar a la planilla física lo siguiente
<v-data-table <v-data-table
:headers="headersTemplate" :headers="headersTemplate"
@@ -52,6 +53,7 @@
<td>{{ item.id }}</td> <td>{{ item.id }}</td>
<td>{{ item.date }}</td> <td>{{ item.date }}</td>
<td><span v-if="item.customer">{{ item.customer.name }}</span></td> <td><span v-if="item.customer">{{ item.customer.name }}</span></td>
<td><span v-if="item.payment_method">{{ item.payment_method }}</span></td>
<td><span v-if="item.lines">{{ currencyFormat(calculateTotal(item.lines)) }}</span></td> <td><span v-if="item.lines">{{ currencyFormat(calculateTotal(item.lines)) }}</span></td>
</tr> </tr>
</template> </template>
@@ -71,7 +73,8 @@
name: 'SummaryPurchase', name: 'SummaryPurchase',
props: { props: {
msg: String, msg: String,
id: Number id: Number,
type: String
}, },
data () { data () {
return { return {
@@ -87,6 +90,7 @@
{title: 'Compra', value: 'id'}, {title: 'Compra', value: 'id'},
{title: 'Fecha', value: 'date'}, {title: 'Fecha', value: 'date'},
{title: 'Nombre', value: 'customer.name'}, {title: 'Nombre', value: 'customer.name'},
{title: 'Método de pago', value: 'payment_method'},
{title: 'Valor', value: ''}, {title: 'Valor', value: ''},
], ],
}; };
@@ -100,7 +104,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

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

View File

@@ -0,0 +1,469 @@
<template>
<v-card class="product-card" elevation="2" rounded="lg">
<!-- Imagen del Producto -->
<div class="product-image-container">
<v-img
:src="product.img"
:alt="product.name"
class="product-img"
cover
max-height="300"
aspect-ratio="1"
>
<template v-slot:placeholder>
<div class="d-flex align-center justify-center fill-height">
<v-progress-circular
indeterminate
color="primary"
size="32"
></v-progress-circular>
</div>
</template>
</v-img>
</div>
<!-- Contenido de la Tarjeta -->
<v-card-text class="product-content pa-3 text-center">
<!-- Título del Producto -->
<v-tooltip location="top" :text="product.name">
<template v-slot:activator="{ props }">
<h3
class="product-name text-subtitle-1 font-weight-medium mb-2"
v-bind="props"
>
{{ product.name }}
</h3>
</template>
</v-tooltip>
<!-- Sección de Precios -->
<div class="prices-section mb-2">
<!-- Precio Unitario -->
<div class="price-row mb-1">
<span class="price-label text-caption">Precio unitario</span>
<div class="price-value text-body-1 font-weight-bold text-primary">
{{ currency(product.price) }}
</div>
</div>
<!-- Precio Total -->
<div v-if="product.quantity > 0" class="price-row">
<span class="price-label text-caption">Precio total</span>
<v-chip
color="success"
variant="flat"
size="small"
class="price-total-chip font-weight-bold mt-1"
>
{{ currency(product.price * product.quantity) }}
</v-chip>
</div>
</div>
</v-card-text>
<!-- Footer con Controles de Cantidad -->
<v-card-actions class="product-actions pa-2 pb-3 justify-center">
<template v-if="disabled">
<v-btn
color="primary"
variant="tonal"
prepend-icon="mdi-cart-plus"
class="login-add-btn"
@click="$emit('request-login')"
>
Agregar
</v-btn>
</template>
<template v-else>
<div class="quantity-controls">
<v-btn
icon
size="small"
variant="tonal"
color="error"
class="qty-btn"
@click="decrease(product)"
:disabled="product.quantity === 0"
>
<v-icon size="20">mdi-minus</v-icon>
</v-btn>
<v-text-field
v-model.number="product.quantity"
type="number"
min="0"
class="quantity-input mx-1"
variant="solo-filled"
density="compact"
hide-details
single-line
flat
aria-label="Cantidad"
@input="handleQuantityChange"
/>
<v-btn
icon
size="small"
variant="tonal"
color="success"
class="qty-btn"
@click="handleIncrease"
>
<v-icon size="20">mdi-plus</v-icon>
</v-btn>
</div>
</template>
</v-card-actions>
</v-card>
</template>
<script>
export default {
emits: ['add-to-cart', 'request-login'],
props: {
product: {
type: Object,
required: true,
},
increase: {
type: Function,
required: true,
},
decrease: {
type: Function,
required: true,
},
currency: {
type: Function,
required: true,
},
updateQuantity: {
type: Function,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
},
methods: {
handleIncrease() {
this.increase(this.product);
this.$emit("add-to-cart", this.product);
},
handleQuantityChange(value) {
this.updateQuantity(this.product);
if (this.product.quantity > 0) {
this.$emit("add-to-cart", this.product);
}
},
},
};
</script>
<style scoped>
/* ============================================
CARD CONTAINER
============================================ */
.product-card {
height: 100%;
display: flex;
flex-direction: column;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid rgba(0, 0, 0, 0.08);
background: #ffffff;
overflow: hidden;
}
.product-card:hover {
transform: translateY(-6px);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.12) !important;
border-color: rgba(33, 150, 243, 0.3);
}
/* ============================================
IMAGEN DEL PRODUCTO
============================================ */
.product-image-container {
position: relative;
width: 100%;
overflow: hidden;
background: linear-gradient(135deg, #fafafa 0%, #ffffff 100%);
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
.product-img {
width: 100%;
height: 100%;
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.product-card:hover .product-img {
transform: scale(1.08);
}
/* ============================================
CONTENIDO DE LA TARJETA
============================================ */
.product-content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
}
.product-name {
font-size: 0.95rem;
font-weight: 500;
color: #1a1a1a;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
min-height: 2.5rem;
letter-spacing: 0.01em;
}
/* ============================================
SECCIÓN DE PRECIOS
============================================ */
.prices-section {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.price-row {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.price-label {
text-transform: uppercase;
letter-spacing: 0.8px;
font-size: 0.65rem;
color: #9e9e9e;
font-weight: 500;
}
.price-value {
color: #1565c0;
letter-spacing: 0.02em;
font-size: 1.1rem;
}
.price-total-chip {
letter-spacing: 0.03em;
font-size: 0.9rem;
padding: 0 12px;
height: 26px;
box-shadow: 0 2px 8px rgba(76, 175, 80, 0.25);
}
/* ============================================
CONTROLES DE CANTIDAD
============================================ */
.product-actions {
border-top: 1px solid rgba(0, 0, 0, 0.06);
background: #fafafa;
}
.quantity-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.quantity-input {
max-width: 65px;
min-width: 65px;
}
.quantity-input :deep(.v-field) {
background-color: #ffffff !important;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
.quantity-input :deep(.v-field__input) {
text-align: center;
font-weight: 700;
font-size: 0.95rem;
color: #1a1a1a;
padding: 4px 0;
min-height: 32px;
}
.quantity-input :deep(.v-field__field) {
padding: 0;
}
.qty-btn {
flex-shrink: 0;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.qty-btn:hover {
transform: scale(1.1);
}
.qty-btn:active {
transform: scale(0.95);
}
/* ============================================
RESPONSIVE BREAKPOINTS
============================================ */
/* Botón de agregar (no autenticado) */
.login-add-btn {
width: 100%;
font-weight: 600;
letter-spacing: 0.5px;
}
/* Móvil pequeño (< 375px) */
@media (max-width: 374px) {
.product-content {
padding: 10px;
}
.product-name {
font-size: 0.9rem;
min-height: 2.4rem;
}
.price-value {
font-size: 1rem;
}
.quantity-input {
max-width: 60px;
min-width: 60px;
}
}
/* Móvil estándar (375px - 559px) */
@media (min-width: 375px) and (max-width: 559px) {
.product-name {
font-size: 0.85rem;
}
.price-value {
font-size: 0.9rem;
}
}
/* Tablet (560px - 959px) */
@media (min-width: 560px) and (max-width: 959px) {
.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;
}
.price-total-chip {
font-size: 0.8rem;
height: 24px;
}
.quantity-input {
max-width: 58px;
min-width: 58px;
}
}
/* Desktop (≥ 960px) */
@media (min-width: 960px) {
.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-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-name {
font-size: 1.05rem;
min-height: 2.5rem;
}
.price-value {
font-size: 1.1rem;
}
}
</style>

View File

@@ -0,0 +1,602 @@
<template>
<v-card :class="{
'cart-mobile-expanded': isMobile && !isCollapsed,
'cart-collapsed-mobile': isMobile && isCollapsed,
'cart-desktop-collapsed': !isMobile && isCollapsed
}">
<v-card-title
class="d-flex align-center cart-title"
:class="{ 'cart-header-mobile': isMobile, 'cart-header-desktop': !isMobile }"
@click="isMobile && $emit('toggle-collapse')">
<!-- Icono del carrito - SIEMPRE VISIBLE -->
<v-icon class="mr-2">mdi-cart</v-icon>
<!-- Texto "Carrito" - SIEMPRE VISIBLE -->
<span class="cart-title-text">Carrito</span>
<!-- Cantidad de productos - SIEMPRE VISIBLE -->
<v-chip
v-if="cartCount > 0"
color="primary"
class="ml-2"
size="small"
>
{{ cartCount }}
</v-chip>
<v-chip
v-else
color="grey"
class="ml-2"
size="small"
variant="outlined"
>
0
</v-chip>
<!-- Total visible cuando está colapsado (mobile o desktop) -->
<span
v-if="isCollapsed && cartItems.length > 0"
class="ml-auto text-subtitle-1 font-weight-bold mr-2"
>
{{ currency(cartTotal) }}
</span>
<v-spacer v-else></v-spacer>
<!-- Botón toggle SIEMPRE visible (mobile y desktop) -->
<v-btn
icon
size="small"
variant="text"
@click.stop="$emit('toggle-collapse')"
:title="isCollapsed ? 'Expandir carrito' : 'Contraer carrito'"
>
<v-icon>{{ isCollapsed ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
</v-btn>
</v-card-title>
<div v-show="!isCollapsed">
<v-divider></v-divider>
<v-card-text v-if="cartItems.length === 0" class="text-center grey--text">
El carrito está vacío
</v-card-text>
<v-list v-else density="compact" :max-height="listMaxHeight" class="overflow-y-auto cart-list">
<v-list-item v-for="(item, index) in cartItems" :key="item.id" class="cart-list-item">
<template v-slot:prepend>
<div class="prepend-wrapper">
<!-- Mostrar imagen solo si NO es extra small -->
<v-avatar v-if="!isExtraSmall" size="40" rounded>
<v-img :src="item.img" cover></v-img>
</v-avatar>
<!-- Mostrar número cuando es extra small -->
<div v-else class="item-number">
{{ index + 1 }}
</div>
</div>
</template>
<!-- Contenido reorganizado -->
<div class="cart-item-content">
<!-- Línea 1: Nombre del producto -->
<div class="product-name">{{ item.name }}</div>
<!-- Línea 2: Controles + Precio unitario -->
<div class="controls-row">
<div class="quantity-controls">
<v-btn small text class="qty-btn" @click="decreaseQuantity(item.id)">
<v-icon small>mdi-minus</v-icon>
</v-btn>
<v-text-field
:model-value="item.quantity"
@update:model-value="updateQuantity(item.id, $event)"
type="number"
min="1"
density="compact"
variant="outlined"
hide-details
class="qty-input"
/>
<v-btn small text class="qty-btn" @click="increaseQuantity(item.id)">
<v-icon small>mdi-plus</v-icon>
</v-btn>
</div>
<span class="unit-price">x {{ currency(item.price) }}</span>
</div>
</div>
<!-- Append: Total + Delete -->
<template v-slot:append>
<div class="item-actions">
<strong class="total-price">{{ currency(item.price * item.quantity) }}</strong>
<v-btn
icon="mdi-delete"
size="small"
variant="text"
color="error"
class="delete-btn"
@click="$emit('remove', item.id)"
></v-btn>
</div>
</template>
</v-list-item>
</v-list>
<v-divider v-if="cartItems.length > 0"></v-divider>
<v-card-text v-if="cartItems.length > 0" class="cart-total-section">
<div class="d-flex justify-space-between align-center">
<strong>Total:</strong>
<strong class="text-h6">{{ currency(cartTotal) }}</strong>
</div>
</v-card-text>
<v-card-actions v-if="cartItems.length > 0" class="cart-checkout-section">
<v-btn color="primary" block @click="$emit('checkout')">
Finalizar Pedido
</v-btn>
</v-card-actions>
</div>
</v-card>
</template>
<script>
export default {
name: 'Cart',
props: {
cartItems: {
type: Array,
default: () => []
},
currency: {
type: Function,
required: true
},
isCollapsed: {
type: Boolean,
default: false
},
isMobile: {
type: Boolean,
default: false
},
windowWidth: {
type: Number,
default: 0
}
},
emits: ['remove', 'checkout', 'update-quantity', 'toggle-collapse'],
computed: {
cartCount() {
return this.cartItems.reduce((sum, item) => sum + item.quantity, 0);
},
cartTotal() {
return this.cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0);
},
listMaxHeight() {
// En desktop, permitir más altura para scroll
if (!this.isMobile) {
return '500px';
}
// En mobile, altura limitada
return '300px';
},
isExtraSmall() {
// Detectar si la resolución es menor a 560px
return this.windowWidth < 560;
}
},
methods: {
updateQuantity(itemId, newQuantity) {
const qty = parseInt(newQuantity) || 1;
this.$emit('update-quantity', { itemId, quantity: qty });
},
increaseQuantity(itemId) {
const item = this.cartItems.find(i => i.id === itemId);
if (item) {
this.$emit('update-quantity', { itemId, quantity: item.quantity + 1 });
}
},
decreaseQuantity(itemId) {
const item = this.cartItems.find(i => i.id === itemId);
if (item && item.quantity > 1) {
this.$emit('update-quantity', { itemId, quantity: item.quantity - 1 });
} else if (item) {
this.$emit('remove', itemId);
}
}
}
}
</script>
<style scoped>
/* === ESTILOS BASE PARA CART ITEMS (Mobile First) === */
/* Contenedor principal del item */
.cart-item-content {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
min-width: 0; /* Permite que el flex funcione con overflow */
}
/* Nombre del producto */
.product-name {
font-size: 0.875rem;
font-weight: 500;
line-height: 1.3;
color: rgba(0, 0, 0, 0.87);
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2; /* Máximo 2 líneas */
-webkit-box-orient: vertical;
word-break: break-word;
}
/* Fila de controles + precio unitario */
.controls-row {
display: flex;
align-items: center;
gap: 8px;
}
/* Controles de cantidad */
.quantity-controls {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
/* Input de cantidad */
.qty-input {
width: 55px;
min-width: 55px;
}
.qty-input input {
text-align: center;
font-weight: 500;
}
/* Botones de cantidad */
.qty-btn {
min-width: 24px !important;
width: 24px !important;
height: 24px !important;
border-radius: 12px !important;
padding: 0 !important;
flex-shrink: 0;
}
.qty-btn .v-icon {
font-size: 14px !important;
}
/* Precio unitario */
.unit-price {
font-size: 0.8rem;
color: rgba(0, 0, 0, 0.6);
font-weight: 400;
white-space: nowrap;
flex-shrink: 0;
}
/* Contenedor de acciones (total + delete) */
.item-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: flex-start;
gap: 4px;
flex-shrink: 0;
}
/* Precio total */
.total-price {
font-size: 0.9rem;
font-weight: 600;
color: #2e7d32;
white-space: nowrap;
line-height: 1.2;
}
/* Botón de eliminar */
.delete-btn {
flex-shrink: 0;
}
/* Footer: Total y checkout */
.cart-total-section {
padding: 12px 16px !important;
}
.cart-checkout-section {
padding: 4px 16px 12px !important;
}
/* Ajustes al list-item */
.cart-list-item {
padding: 10px 8px !important;
min-height: auto !important;
align-items: center !important;
}
/* Wrapper del prepend para controlar gap con content */
.prepend-wrapper {
display: flex;
align-items: center;
margin-right: 10px;
}
/* Estilos para mobile expandido */
.cart-mobile-expanded {
max-height: 70vh;
overflow-y: auto;
border-radius: 16px 16px 0 0 !important;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.15) !important;
}
/* Estilos para mobile colapsado */
.cart-collapsed-mobile {
height: 60px;
overflow: hidden;
border-radius: 16px 16px 0 0 !important;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1) !important;
}
/* Estilos para desktop colapsado */
.cart-desktop-collapsed {
height: 60px;
overflow: hidden;
border-radius: 12px !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1) !important;
transition: all 0.3s ease-in-out;
}
/* Header del carrito */
.cart-title {
padding: 8px 12px !important;
min-height: 48px;
}
.cart-title-text {
font-size: 1rem;
font-weight: 600;
white-space: nowrap;
}
/* Header interactivo en mobile */
.cart-header-mobile {
cursor: pointer;
user-select: none;
}
/* Header interactivo en desktop */
.cart-header-desktop {
cursor: pointer;
user-select: none;
background-color: rgba(0, 0, 0, 0.02);
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
.cart-header-desktop:hover {
background-color: rgba(0, 0, 0, 0.04);
}
/* Lista de items con scroll */
.cart-list {
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
}
.cart-list::-webkit-scrollbar {
width: 6px;
}
.cart-list::-webkit-scrollbar-track {
background: transparent;
}
.cart-list::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
.cart-list::-webkit-scrollbar-thumb:hover {
background-color: rgba(0, 0, 0, 0.3);
}
/* Transiciones suaves */
.v-card {
transition: max-height 0.35s ease-in-out, box-shadow 0.3s ease-in-out, border-radius 0.3s ease-in-out;
}
/* === MEDIA QUERIES RESPONSIVE === */
/* Resolución 560-959px (Mobile/Tablet con imagen) */
@media (min-width: 560px) and (max-width: 959px) {
.cart-title {
padding: 10px 16px !important;
min-height: 52px;
}
.cart-title-text {
font-size: 1.05rem;
}
.cart-list-item {
padding: 12px !important;
}
.prepend-wrapper {
margin-right: 12px;
}
.cart-item-content {
gap: 8px;
}
.product-name {
font-size: 0.9rem;
}
.controls-row {
gap: 12px;
}
.quantity-controls {
gap: 6px;
}
.qty-input {
width: 65px !important;
min-width: 65px !important;
}
.qty-btn {
min-width: 28px !important;
width: 28px !important;
height: 28px !important;
border-radius: 14px !important;
}
.qty-btn .v-icon {
font-size: 16px !important;
}
.unit-price {
font-size: 0.85rem;
}
.total-price {
font-size: 0.95rem;
}
.cart-total-section {
padding: 12px 16px !important;
}
.cart-checkout-section {
padding: 4px 16px 12px !important;
}
}
/* Resolución ≥960px (Desktop) */
@media (min-width: 960px) {
.v-card {
border-radius: 12px !important;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08) !important;
}
.cart-title {
padding: 12px 16px !important;
min-height: 56px;
}
.cart-title-text {
font-size: 1.1rem;
}
.cart-list-item {
padding: 12px 16px !important;
}
.prepend-wrapper {
margin-right: 14px;
}
.cart-item-content {
gap: 10px;
}
.product-name {
font-size: 0.95rem;
font-weight: 600;
}
.controls-row {
gap: 14px;
}
.quantity-controls {
gap: 6px;
}
.qty-input {
width: 70px !important;
min-width: 70px !important;
}
.qty-btn {
min-width: 32px !important;
width: 32px !important;
height: 32px !important;
border-radius: 16px !important;
}
.qty-btn .v-icon {
font-size: 18px !important;
}
.unit-price {
font-size: 0.9rem;
}
.total-price {
font-size: 1rem;
}
/* Item-actions en fila en desktop */
.item-actions {
flex-direction: row;
align-items: center;
gap: 8px;
}
.cart-desktop-collapsed {
width: 100%;
}
.cart-total-section {
padding: 14px 16px !important;
}
.cart-checkout-section {
padding: 4px 16px 14px !important;
}
}
/* Ajustes para la lista de items en mobile */
@media (max-width: 680px) {
.v-list {
max-height: calc(70vh - 200px) !important;
}
}
/* Ajustes para resoluciones extra pequeñas (<560px) */
@media (max-width: 559px) {
/* Número del item cuando no hay imagen */
.item-number {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
min-width: 32px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 50%;
font-weight: 700;
font-size: 0.875rem;
color: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
}
.quantity-controls {
gap: 3px !important;
}
}
</style>

View File

@@ -0,0 +1,269 @@
<template>
<v-row class="pagination-container my-3" align="center">
<!-- Fila 1: Navegador de páginas (centrado, ancho completo) -->
<v-col cols="12" class="d-flex justify-center mb-2">
<v-pagination
:model-value="currentPage"
@update:model-value="$emit('page-change', $event)"
:length="totalPages"
:total-visible="computedTotalVisible"
:show-first-last-page="showFirstLastButtons"
rounded="circle"
color="primary"
:size="paginationSize"
></v-pagination>
</v-col>
<!-- Fila 2: Info + Selector (en línea) - Solo Desktop -->
<v-col
cols="12"
class="d-none d-md-flex justify-center align-center pagination-info-row"
>
<!-- Información de resultados -->
<span class="pagination-info-text">
Mostrando {{ paginationInfo.start }}-{{ paginationInfo.end }}
de {{ paginationInfo.total }} productos
</span>
<!-- Separador visual -->
<v-divider vertical class="mx-4" style="height: 24px;"></v-divider>
<!-- Selector de items por página -->
<span class="mr-2 text-body-1 font-weight-medium">
Productos por página:
</span>
<v-select
:model-value="itemsPerPage"
@update:model-value="$emit('items-per-page-change', $event)"
:items="itemsPerPageOptions"
density="comfortable"
variant="outlined"
hide-details
class="items-per-page-selector"
></v-select>
</v-col>
<!-- Fila 2 Mobile: Info de resultados - Solo Mobile -->
<v-col cols="12" class="d-flex d-md-none justify-center">
<span class="pagination-info-text">
Mostrando {{ paginationInfo.start }}-{{ paginationInfo.end }}
de {{ paginationInfo.total }} productos
</span>
</v-col>
<!-- Fila 3 Mobile: Selector - Solo Mobile -->
<v-col
cols="12"
class="d-flex d-md-none justify-center align-center"
>
<span class="mr-2 text-body-1 font-weight-medium">
Productos por página:
</span>
<v-select
:model-value="itemsPerPage"
@update:model-value="$emit('items-per-page-change', $event)"
:items="itemsPerPageOptions"
density="comfortable"
variant="outlined"
hide-details
class="items-per-page-selector"
></v-select>
</v-col>
</v-row>
</template>
<script>
import { computed, ref, onMounted, onUnmounted } from 'vue';
export default {
name: 'PaginationControls',
props: {
currentPage: {
type: Number,
required: true
},
totalPages: {
type: Number,
required: true
},
itemsPerPage: {
type: Number,
required: true
},
itemsPerPageOptions: {
type: Array,
required: true
},
paginationInfo: {
type: Object,
required: true
},
position: {
type: String,
default: 'top',
validator: (value) => ['top', 'bottom'].includes(value)
},
totalVisiblePages: {
type: Number,
default: null
}
},
emits: ['page-change', 'items-per-page-change'],
setup(props) {
const windowWidth = ref(window.innerWidth);
// Actualizar ancho de ventana en resize
const updateWidth = () => {
windowWidth.value = window.innerWidth;
};
onMounted(() => {
window.addEventListener('resize', updateWidth);
});
onUnmounted(() => {
window.removeEventListener('resize', updateWidth);
});
const isMobile = computed(() => windowWidth.value < 680);
// Computed para tamaño de paginación: más pequeño en tablet para ahorrar espacio
const paginationSize = computed(() => {
const width = windowWidth.value;
// En pantallas pequeñas/medianas, usar tamaño default para ahorrar espacio
if (width < 960) {
return 'default';
}
// En desktop, usar tamaño large
return 'large';
});
// Computed para mostrar botones first/last: solo en pantallas >= 680px
const showFirstLastButtons = computed(() => {
const width = windowWidth.value;
// Mostrar first/last solo en tablet y desktop (>= 680px)
// En mobile (<680px), solo prev/next para ahorrar espacio
return width >= 680;
});
// Computed property para total-visible: prioriza prop recibida, sino calcula localmente
const computedTotalVisible = computed(() => {
// Si se recibe totalVisiblePages desde el padre, usarlo (SINCRONIZACIÓN)
if (props.totalVisiblePages !== null) {
return props.totalVisiblePages;
}
// Fallback: cálculo local (por compatibilidad)
const width = windowWidth.value;
const totalPages = props.totalPages;
// Si hay pocas páginas, mostrarlas todas
if (totalPages <= 7) {
return totalPages;
}
// Breakpoints responsivos
if (width < 400) {
return 3;
} else if (width < 680) {
return 5;
} else if (width < 960) {
return 7;
} else if (width < 1280) {
return 9;
} else {
return 11;
}
});
return {
isMobile,
computedTotalVisible,
paginationSize,
showFirstLastButtons
};
}
};
</script>
<style scoped>
.pagination-container {
padding: 12px 0;
gap: 8px;
}
.pagination-info-text {
font-size: 1.05rem;
color: #666;
font-weight: 500;
}
/* Nueva clase para la fila de info en desktop */
.pagination-info-row {
gap: 16px;
}
.items-per-page-selector {
max-width: 100px;
}
/* Mobile */
@media (max-width: 680px) {
.pagination-container {
gap: 12px;
}
.items-per-page-selector {
max-width: 80px;
}
.text-body-1 {
font-size: 0.9rem;
}
}
/* Ajustes visuales */
.v-pagination {
margin: 0 auto;
min-width: 400px; /* Garantizar espacio mínimo para iconos + páginas */
}
/* En móviles muy pequeños, reducir min-width */
@media (max-width: 480px) {
.v-pagination {
min-width: 320px;
}
}
/* En pantallas medianas problemáticas (680-960px), asegurar espacio suficiente */
@media (min-width: 680px) and (max-width: 960px) {
.v-pagination {
min-width: 450px; /* Más espacio para evitar que desaparezcan los iconos */
}
}
/* Separador vertical */
.v-divider--vertical {
opacity: 0.5;
}
/* Aumentar tamaño del icono del dropdown en v-select */
.items-per-page-selector :deep(.v-icon) {
font-size: 1.5rem !important;
}
/* Aumentar tamaño del texto dentro del select */
.items-per-page-selector :deep(.v-field__input) {
font-size: 1rem !important;
font-weight: 500;
}
/* Aumentar tamaño de los items del menú dropdown */
.items-per-page-selector :deep(.v-list-item-title) {
font-size: 1rem !important;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,155 @@
<template>
<div
ref="containerRef"
class="vis-chart"
:style="{ height: `${height}px` }"
/>
</template>
<script setup>
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { DataSet, Network } from 'vis-network/standalone'
const props = defineProps({
nodes: {
type: Array,
default: () => [],
},
edges: {
type: Array,
default: () => [],
},
height: {
type: Number,
default: 400,
},
options: {
type: Object,
default: () => ({}),
},
minVerticalSpacing: {
type: Number,
default: null,
},
})
const emit = defineEmits(['select'])
const containerRef = ref(null)
let network = null
let nodesDataSet = null
const BASE_OPTIONS = {
autoResize: true,
interaction: {
hover: true,
tooltipDelay: 150,
},
physics: {
enabled: true,
stabilization: { enabled: true, iterations: 800 },
barnesHut: {
gravitationalConstant: -5000,
springLength: 140,
springConstant: 0.05,
},
},
nodes: {
shape: 'dot',
size: 24,
borderWidth: 2,
font: {
face: 'sans-serif',
size: 13,
color: '#263238',
strokeWidth: 4,
strokeColor: '#ffffff',
},
},
edges: {
smooth: { enabled: true, type: 'dynamic' },
arrows: { to: { enabled: true, scaleFactor: 0.6 } },
color: { color: '#b0bec5', highlight: '#546e7a', hover: '#90a4ae' },
width: 2,
},
layout: { improvedLayout: true },
}
function mergeOptions () {
return {
...BASE_OPTIONS,
...props.options,
nodes: { ...BASE_OPTIONS.nodes, ...(props.options.nodes || {}) },
edges: { ...BASE_OPTIONS.edges, ...(props.options.edges || {}) },
physics: { ...BASE_OPTIONS.physics, ...(props.options.physics || {}) },
interaction: { ...BASE_OPTIONS.interaction, ...(props.options.interaction || {}) },
}
}
function render (nodes, edges) {
nodesDataSet = new DataSet(nodes)
network.setData({ nodes: nodesDataSet, edges: new DataSet(edges) })
}
function applyMinVerticalSpacing (minSpacing) {
if (!minSpacing || minSpacing <= 0) return
const columns = new Map()
for (const [id, position] of Object.entries(network.getPositions())) {
const x = Math.round(position.x)
if (!columns.has(x)) columns.set(x, [])
columns.get(x).push({ id, x, y: position.y })
}
for (const column of columns.values()) {
column.sort((a, b) => a.y - b.y)
let previousY = null
for (const item of column) {
if (previousY !== null && item.y - previousY < minSpacing) {
network.moveNode(item.id, item.x, previousY + minSpacing)
}
previousY = network.getPositions()[item.id].y
}
}
}
function setupNetwork () {
if (network) network.destroy()
network = new Network(containerRef.value, { nodes: [], edges: [] }, mergeOptions())
render(props.nodes, props.edges)
network.on('click', params => {
const id = params.nodes?.[0]
if (id === undefined) return
const node = nodesDataSet.get(id)
if (node) emit('select', node)
})
network.once('stabilizationIterationsDone', () => {
network.setOptions({ physics: { enabled: false } })
applyMinVerticalSpacing(props.minVerticalSpacing)
network.fit()
})
}
onMounted(setupNetwork)
watch(
() => [props.nodes, props.edges],
() => {
if (!containerRef.value) return
setupNetwork()
},
{ deep: true }
)
onBeforeUnmount(() => {
if (network) network.destroy()
network = null
})
</script>
<style scoped>
.vis-chart {
width: 100%;
min-height: 200px;
}
</style>

View File

@@ -0,0 +1,79 @@
<template>
<div>
<v-list density="compact">
<v-list-item>
<template #prepend>
<v-icon>mdi-tag</v-icon>
</template>
<v-list-item-title>Código</v-list-item-title>
<v-list-item-subtitle>
<code class="order-code">{{ code }}</code>
</v-list-item-subtitle>
<template #append>
<v-btn
data-test="copy-code"
icon="mdi-content-copy"
size="small"
title="Copiar código"
variant="text"
@click="copyText(code)"
/>
</template>
</v-list-item>
<v-list-item>
<template #prepend>
<v-icon>mdi-link-variant</v-icon>
</template>
<v-list-item-title>Link de consulta</v-list-item-title>
<v-list-item-subtitle>
<code class="order-link">{{ publicLink }}</code>
</v-list-item-subtitle>
<template #append>
<v-btn
data-test="copy-link"
icon="mdi-content-copy"
size="small"
title="Copiar link"
variant="text"
@click="copyText(publicLink)"
/>
</template>
</v-list-item>
</v-list>
<v-snackbar v-model="snackbar" color="success" location="top" :timeout="2000">
Copiado
</v-snackbar>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
const props = defineProps({
code: {
type: String,
required: true,
},
})
const snackbar = ref(false)
const publicLink = computed(() => {
return `${window.location.origin}/pedido/${props.code}`
})
async function copyText (text) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
}
snackbar.value = true
}
</script>
<style scoped>
.order-code,
.order-link {
word-break: break-all;
}
</style>

View File

@@ -0,0 +1,18 @@
<template>
<v-list-item v-if="customer">
<template #prepend>
<v-icon>mdi-account</v-icon>
</template>
<v-list-item-title>Cliente</v-list-item-title>
<v-list-item-subtitle>{{ customer.name }}</v-list-item-subtitle>
</v-list-item>
</template>
<script setup>
defineProps({
customer: {
type: Object,
default: null,
},
})
</script>

View File

@@ -0,0 +1,39 @@
<template>
<v-table density="compact">
<thead>
<tr>
<th class="text-left">Producto</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, index) in lines" :key="index">
<td>{{ line.product?.name }}</td>
<td class="text-right">
<CurrencyText :value="Number(line.unit_price)" />
</td>
<td class="text-right">{{ line.quantity }}</td>
<td class="text-right">
<CurrencyText :value="subtotal(line)" />
</td>
</tr>
</tbody>
</v-table>
</template>
<script setup>
import CurrencyText from '@/components/CurrencyText.vue'
defineProps({
lines: {
type: Array,
default: () => [],
},
})
function subtotal (line) {
return Number(line.unit_price || 0) * Number(line.quantity || 0)
}
</script>

View File

@@ -0,0 +1,18 @@
<template>
<v-list-item v-if="paymentMethod">
<template #prepend>
<v-icon>mdi-credit-card</v-icon>
</template>
<v-list-item-title>Pagado en</v-list-item-title>
<v-list-item-subtitle>{{ paymentMethod }}</v-list-item-subtitle>
</v-list-item>
</template>
<script setup>
defineProps({
paymentMethod: {
type: String,
default: '',
},
})
</script>

View File

@@ -0,0 +1,19 @@
<template>
<div class="d-flex justify-space-between align-center">
<span class="font-weight-bold">Total</span>
<span class="font-weight-bold">
<CurrencyText :value="total" />
</span>
</div>
</template>
<script setup>
import CurrencyText from '@/components/CurrencyText.vue'
defineProps({
total: {
type: Number,
required: true,
},
})
</script>

View File

@@ -0,0 +1,108 @@
<template>
<v-dialog
max-width="520"
:model-value="visible"
@update:model-value="onUpdateVisible"
>
<v-card v-if="selected">
<v-card-title class="d-flex align-center">
<v-icon class="mr-2" :color="kindColor" :icon="kindIcon" />
<span class="font-weight-bold">{{ kindLabel }}</span>
</v-card-title>
<v-divider />
<v-card-text>
<img
v-if="selected.image"
alt="Imagen del producto"
class="provenance-image rounded-lg border mb-3"
:src="selected.image"
>
<div class="text-h6 mb-2">{{ selected.label }}</div>
<v-list v-if="hasDetails" density="compact">
<v-list-item v-if="description">
<v-list-item-title>Descripción</v-list-item-title>
<v-list-item-subtitle>{{ description }}</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="website">
<v-list-item-title>Sitio web</v-list-item-title>
<v-list-item-subtitle>
<a :href="website" rel="noopener" target="_blank">{{ website }}</a>
</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="contactEmail">
<v-list-item-title>Correo de contacto</v-list-item-title>
<v-list-item-subtitle>{{ contactEmail }}</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="contactPhone">
<v-list-item-title>Teléfono de contacto</v-list-item-title>
<v-list-item-subtitle>{{ contactPhone }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="close">Cerrar</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
selected: {
type: Object,
default: null,
},
})
const emit = defineEmits(['update:visible'])
const KIND_INFO = {
product: { label: 'Producto', icon: 'mdi-package-variant', color: 'teal' },
supplier: { label: 'Proveedor', icon: 'mdi-truck', color: 'blue' },
organization: { label: 'Organización', icon: 'mdi-domain', color: 'orange' },
municipality: { label: 'Municipio', icon: 'mdi-map-marker', color: 'green' },
department: { label: 'Departamento', icon: 'mdi-map', color: 'indigo' },
country: { label: 'País', icon: 'mdi-earth', color: 'purple' },
}
const kindInfo = computed(() => {
return KIND_INFO[props.selected?.kind] || KIND_INFO.product
})
const kindLabel = computed(() => kindInfo.value.label)
const kindIcon = computed(() => kindInfo.value.icon)
const kindColor = computed(() => kindInfo.value.color)
const entity = computed(() => props.selected?.entity || {})
const description = computed(() => entity.value.description || '')
const website = computed(() => entity.value.website || '')
const contactEmail = computed(() => entity.value.contact_email || '')
const contactPhone = computed(() => entity.value.contact_phone || '')
const hasDetails = computed(() => {
return description.value || website.value || contactEmail.value || contactPhone.value
})
function onUpdateVisible (value) {
emit('update:visible', value)
}
function close () {
emit('update:visible', false)
}
</script>
<style scoped>
.provenance-image {
display: block;
max-height: 220px;
max-width: 100%;
object-fit: contain;
}
</style>

View File

@@ -0,0 +1,114 @@
<template>
<div>
<v-alert
v-if="!hasSuppliers"
class="my-4"
type="info"
variant="tonal"
>
La información de origen de los productos de este pedido estará disponible
próximamente.
</v-alert>
<template v-else>
<div class="d-flex flex-wrap align-center ga-4 mb-2">
<span class="text-body-2 font-weight-medium">Elementos a graficar:</span>
<v-checkbox
v-for="option in options"
:key="option.value"
v-model="selectedKinds"
density="compact"
:value="option.value"
>
<template #label>
<span class="d-flex align-center">
<span
class="legend-dot"
:style="{ backgroundColor: option.color }"
/>
{{ option.label }}
</span>
</template>
</v-checkbox>
</div>
<v-alert
v-if="visNodes.length === 0"
class="my-2"
type="warning"
variant="tonal"
>
Selecciona al menos un elemento para graficar.
</v-alert>
<VisChart
v-if="visNodes.length > 0"
data-test="provenance-chart"
:edges="visEdges"
:height="height"
:min-vertical-spacing="minVerticalSpacing"
:nodes="visNodes"
:options="chartOptions"
@select="onSelect"
/>
</template>
<ProvenanceDetailModal
:selected="selected"
:visible="modalVisible"
@update:visible="modalVisible = $event"
/>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import VisChart from '@/components/graph/VisChart.vue'
import ProvenanceDetailModal from './ProvenanceDetailModal.vue'
import { buildProvenanceGraph, hasAnySupplier } from './provenance-graph'
import { chartOptions, KIND_COLORS, toVisEdges, toVisNodes } from './provenance-vis'
const props = defineProps({
provenance: {
type: Array,
default: () => [],
},
})
const options = [
{ value: 'product', label: 'Productos', color: KIND_COLORS.product },
{ value: 'supplier', label: 'Proveedores', color: KIND_COLORS.supplier },
{ value: 'organization', label: 'Organizaciones', color: KIND_COLORS.organization },
{ value: 'municipality', label: 'Municipios', color: KIND_COLORS.municipality },
{ value: 'department', label: 'Departamentos', color: KIND_COLORS.department },
{ value: 'country', label: 'País', color: KIND_COLORS.country },
]
const selectedKinds = ref(['product', 'supplier'])
const hasSuppliers = computed(() => hasAnySupplier(props.provenance))
const graph = computed(() => buildProvenanceGraph(props.provenance, selectedKinds.value))
const visNodes = computed(() => toVisNodes(graph.value.nodes))
const visEdges = computed(() => toVisEdges(graph.value.edges))
const height = computed(() => Math.max(280, Math.min(700, graph.value.nodes.length * 64)))
const minVerticalSpacing = 2 * 24 + 22
const selected = ref(null)
const modalVisible = ref(false)
function onSelect (node) {
if (!node || node.kind === 'junction') return
selected.value = node
modalVisible.value = true
}
</script>
<style scoped>
.legend-dot {
width: 14px;
height: 14px;
border-radius: 50%;
margin-right: 6px;
border: 2px solid rgba(0, 0, 0, 0.15);
}
</style>

View File

@@ -0,0 +1,569 @@
<template>
<div>
<template v-if="productRows.length">
<div class="map-layout">
<v-card class="product-panel" data-test="map-product-box" variant="tonal">
<v-card-title class="d-flex align-center justify-space-between py-2">
<span class="text-subtitle-1 font-weight-bold">
Productos ({{ productRows.length }})
</span>
<v-btn
v-if="hasMarkers"
data-test="map-reset-zoom"
density="comfortable"
icon="mdi-fit-to-page"
size="small"
variant="flat"
@click="resetZoom"
/>
</v-card-title>
<v-divider />
<v-list density="compact">
<v-list-item
v-for="row in productRows"
:key="row.product.id"
data-test="map-product-row"
:prepend-avatar="row.image"
role="button"
:subtitle="row.locationLabel"
:title="row.product.name"
@click="onRowClick(row)"
>
<template #append>
<v-icon v-if="row.position" color="primary" size="small">
mdi-map-marker
</v-icon>
<v-icon v-else color="medium-emphasis" size="small">
mdi-map-marker-off
</v-icon>
</template>
</v-list-item>
</v-list>
</v-card>
<div class="map-area">
<template v-if="hasMarkers">
<div ref="mapEl" class="map-wrapper" />
<v-alert
v-if="!storePosition"
class="mt-2"
type="warning"
variant="tonal"
>
La ubicación de la tienda no está configurada, por lo que no se mostrará su recorrido.
</v-alert>
<p class="text-caption text-medium-emphasis mt-2 mb-0">
Pasa el cursor sobre un producto para ver su recorrido hasta la tienda. Haz clic en un marcador para ver el detalle.
</p>
</template>
<v-alert
v-else
class="my-2"
type="info"
variant="tonal"
>
Las coordenadas geográficas de los municipios de origen aún no están disponibles.
</v-alert>
</div>
</div>
</template>
<ProvenanceRelationModal
:product="selectedProduct"
:relation="selectedRelation"
:visible="modalVisible"
@update:visible="modalVisible = $event"
/>
</div>
</template>
<script setup>
import { computed, inject, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import { useSettingsStore } from '@/stores/settings'
import ProvenanceRelationModal from './ProvenanceRelationModal.vue'
const props = defineProps({
provenance: {
type: Array,
default: () => [],
},
})
const api = inject('api')
const settingsStore = useSettingsStore()
const mapEl = ref(null)
const selectedProduct = ref(null)
const selectedRelation = ref(null)
const modalVisible = ref(false)
let map = null
let storeMarker = null
let productMarkers = []
let activeLines = []
const settings = computed(() => settingsStore.settings)
const storePosition = computed(() => {
const current = settings.value
if (!current || current.latitude == null || current.longitude == null) return null
return [Number(current.latitude), Number(current.longitude)]
})
const markers = computed(() => {
const result = []
for (const entry of props.provenance || []) {
if (!entry.product) continue
for (const rel of entry.suppliers || []) {
const municipality = rel.municipality
if (municipality && municipality.latitude != null && municipality.longitude != null) {
result.push({
product: entry.product,
relation: rel,
position: [Number(municipality.latitude), Number(municipality.longitude)],
})
}
}
}
return result
})
const productRows = computed(() => {
const rows = []
for (const entry of props.provenance || []) {
if (!entry.product) continue
const suppliers = entry.suppliers || []
const geoRelations = suppliers.filter(rel =>
rel.municipality && rel.municipality.latitude != null && rel.municipality.longitude != null
)
const municipalities = [...new Map(
geoRelations.map(rel => [rel.municipality.id, rel.municipality])
).values()]
rows.push({
product: entry.product,
relation: suppliers[0] || null,
image: entry.product.catalogue_images?.[0] || null,
position: geoRelations.length
? [Number(geoRelations[0].municipality.latitude), Number(geoRelations[0].municipality.longitude)]
: null,
locationLabel: municipalities.length
? municipalities.map(m => m.name).join(', ')
: 'Sin geolocalización',
})
}
return rows
})
const hasMarkers = computed(() => markers.value.length > 0)
const markerGroups = computed(() => {
const groups = new Map()
for (const markerData of markers.value) {
const key = markerData.position.join(',')
if (!groups.has(key)) groups.set(key, [])
groups.get(key).push(markerData)
}
return [...groups.values()].map(items => ({ position: items[0].position, items }))
})
const personIcon = L.divIcon({
className: 'provenance-store-pin',
html: '<i class="mdi mdi-account-circle" style="font-size:46px;line-height:1;color:#f44336;"></i>',
iconSize: [46, 46],
iconAnchor: [23, 46],
})
function productIcon (markerData) {
const image = markerData.product.catalogue_images?.[0]
const html = image
? `<img class="provenance-product-img" src="${image}" alt="${markerData.product.name}">`
: '<span class="provenance-product-fallback"><i class="mdi mdi-package-variant"></i></span>'
return L.divIcon({
className: 'provenance-product-pin',
html,
iconSize: [38, 38],
iconAnchor: [19, 38],
})
}
function groupIcon (group) {
return L.divIcon({
className: 'provenance-group-pin',
html: `<span class="provenance-group-count">${group.items.length}</span><i class="mdi mdi-package-variant"></i>`,
iconSize: [38, 38],
iconAnchor: [19, 38],
})
}
function buildGroupPopup (items, onItemClick) {
const container = document.createElement('div')
container.className = 'provenance-popup-list'
items.forEach((item, index) => {
const row = document.createElement('div')
row.className = 'provenance-popup-item'
row.setAttribute('role', 'button')
row.setAttribute('data-test', `map-popup-item-${index}`)
const image = item.product.catalogue_images?.[0]
if (image) {
const img = document.createElement('img')
img.src = image
img.className = 'provenance-popup-img'
row.appendChild(img)
}
const name = document.createElement('span')
name.className = 'provenance-popup-name'
name.textContent = item.product.name
row.appendChild(name)
row.addEventListener('click', event => {
event.stopPropagation()
onItemClick(item)
})
container.appendChild(row)
})
return container
}
function appendEntityRow (container, label, icon, color, entity) {
if (!entity) return
const row = document.createElement('div')
row.className = 'provenance-detail-row'
const iconEl = document.createElement('i')
iconEl.className = `mdi ${icon}`
iconEl.style.color = color
row.appendChild(iconEl)
const body = document.createElement('div')
const labelEl = document.createElement('div')
labelEl.className = 'provenance-detail-label'
labelEl.textContent = label
const nameEl = document.createElement('div')
nameEl.className = 'provenance-detail-name'
nameEl.textContent = entity.name
body.appendChild(labelEl)
body.appendChild(nameEl)
row.appendChild(body)
container.appendChild(row)
}
function buildDetailPopup (markerData) {
const container = document.createElement('div')
container.className = 'provenance-detail-popup'
const header = document.createElement('div')
header.className = 'provenance-detail-header'
const image = markerData.product.catalogue_images?.[0]
if (image) {
const img = document.createElement('img')
img.src = image
img.className = 'provenance-detail-img'
header.appendChild(img)
}
const title = document.createElement('div')
title.className = 'provenance-detail-title'
title.textContent = markerData.product.name
header.appendChild(title)
container.appendChild(header)
const rel = markerData.relation || {}
appendEntityRow(container, 'Proveedor', 'mdi-truck', '#1976d2', rel.supplier)
appendEntityRow(container, 'Organización', 'mdi-domain', '#fb8c00', rel.organization)
appendEntityRow(container, 'Municipio', 'mdi-map-marker', '#4caf50', rel.municipality)
appendEntityRow(container, 'Departamento', 'mdi-map', '#3f51b5', rel.department)
appendEntityRow(container, 'País', 'mdi-earth', '#9c27b0', rel.country)
return container
}
function openProductDetail (marker, markerData) {
marker.setPopupContent(buildDetailPopup(markerData))
marker.openPopup()
}
function showLine (from, to) {
const line = L.polyline([from, to], {
color: '#26a69a',
weight: 2,
dashArray: '6 6',
}).addTo(map)
activeLines.push(line)
}
function clearLines () {
activeLines.forEach(line => line.remove())
activeLines = []
}
function addStoreMarker () {
if (!storePosition.value) return
storeMarker = L.marker(storePosition.value, { icon: personIcon }).addTo(map)
storeMarker.bindTooltip('Tienda')
storeMarker.on('mouseover', () => {
markers.value.forEach(markerData => showLine(markerData.position, storePosition.value))
})
storeMarker.on('mouseout', clearLines)
}
function addProductMarkers () {
markerGroups.value.forEach(group => {
if (group.items.length === 1) {
const item = group.items[0]
const marker = L.marker(item.position, { icon: productIcon(item) }).addTo(map)
marker.bindTooltip(item.product.name)
marker.bindPopup(buildDetailPopup(item))
marker.on('mouseover', () => {
if (storePosition.value) showLine(item.position, storePosition.value)
})
marker.on('mouseout', clearLines)
productMarkers.push({ marker, items: [item] })
return
}
const marker = L.marker(group.position, { icon: groupIcon(group) }).addTo(map)
marker.bindTooltip(`${group.items.length} productos en este punto`)
marker.bindPopup(buildGroupPopup(group.items, item => openProductDetail(marker, item)))
marker.on('popupclose', () => {
marker.bindPopup(buildGroupPopup(group.items, item => openProductDetail(marker, item)))
})
marker.on('mouseover', () => {
if (storePosition.value) group.items.forEach(item => showLine(item.position, storePosition.value))
})
marker.on('mouseout', clearLines)
productMarkers.push({ marker, items: group.items })
})
}
function fitBounds () {
const bounds = L.latLngBounds()
markers.value.forEach(markerData => bounds.extend(markerData.position))
if (storePosition.value) bounds.extend(storePosition.value)
if (bounds.isValid()) map.fitBounds(bounds, { padding: [60, 60] })
}
function onRowClick (row) {
if (row.position && map) {
map.flyTo(row.position, Math.max(map.getZoom(), 12), { duration: 0.6 })
const entry = productMarkers.find(entry =>
entry.items.some(item => item.product.id === row.product.id && item.relation === row.relation)
)
if (entry) {
const item = entry.items.find(i => i.relation === row.relation) || entry.items[0]
openProductDetail(entry.marker, item)
return
}
}
selectedProduct.value = row.product
selectedRelation.value = row.relation
modalVisible.value = true
}
function resetZoom () {
if (map) fitBounds()
}
function initMap () {
if (!mapEl.value || map) return
const center = storePosition.value || markers.value[0].position
map = L.map(mapEl.value, { scrollWheelZoom: false }).setView(center, 6)
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(map)
addStoreMarker()
addProductMarkers()
fitBounds()
}
function handleResize () {
if (map) map.invalidateSize()
}
onMounted(async () => {
window.addEventListener('resize', handleResize)
try {
await settingsStore.fetchSettings(api)
await nextTick()
if (mapEl.value) initMap()
} catch (error) {
console.error('Error al cargar la configuración de la tienda:', error)
}
})
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
if (map) {
map.remove()
map = null
storeMarker = null
productMarkers = []
}
})
</script>
<style scoped>
.map-layout {
display: flex;
gap: 12px;
align-items: stretch;
}
.product-panel {
flex: 0 0 300px;
max-height: 480px;
overflow-y: auto;
}
.map-area {
flex: 1;
min-width: 0;
}
.map-wrapper {
position: relative;
width: 100%;
height: 420px;
border-radius: 12px;
overflow: hidden;
z-index: 0;
}
@media (max-width: 900px) {
.map-layout {
flex-direction: column;
}
.product-panel {
flex-basis: auto;
max-height: none;
}
}
.map-wrapper :deep(.provenance-product-pin) {
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
}
.map-wrapper :deep(.provenance-product-img) {
width: 38px;
height: 38px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #ffffff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
}
.map-wrapper :deep(.provenance-product-fallback) {
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 50%;
background: #26a69a;
color: #ffffff;
border: 2px solid #ffffff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
font-size: 24px;
}
.map-wrapper :deep(.provenance-store-pin) {
border: none;
background: transparent;
}
.map-wrapper :deep(.provenance-group-pin) {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #26a69a;
border: 2px solid #ffffff;
border-radius: 50%;
color: #ffffff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
cursor: pointer;
}
.map-wrapper :deep(.provenance-group-pin .provenance-group-count) {
font-weight: 700;
font-size: 14px;
line-height: 1;
}
.map-wrapper :deep(.provenance-group-pin .mdi) {
font-size: 12px;
line-height: 1;
margin-top: 2px;
}
.map-wrapper :deep(.provenance-popup-item) {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
cursor: pointer;
}
.map-wrapper :deep(.provenance-popup-item:hover) {
text-decoration: underline;
}
.map-wrapper :deep(.provenance-popup-img) {
width: 28px;
height: 28px;
border-radius: 4px;
object-fit: cover;
}
.map-wrapper :deep(.provenance-popup-name) {
font-weight: 500;
}
.map-wrapper :deep(.provenance-detail-popup) {
min-width: 200px;
max-width: 260px;
}
.map-wrapper :deep(.provenance-detail-header) {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.map-wrapper :deep(.provenance-detail-img) {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.map-wrapper :deep(.provenance-detail-title) {
font-weight: 700;
}
.map-wrapper :deep(.provenance-detail-row) {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 4px 0;
}
.map-wrapper :deep(.provenance-detail-row .mdi) {
font-size: 18px;
margin-top: 2px;
}
.map-wrapper :deep(.provenance-detail-label) {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
opacity: 0.7;
}
.map-wrapper :deep(.provenance-detail-name) {
font-weight: 500;
}
.map-wrapper :deep(.leaflet-control-attribution) {
font-size: 10px;
}
</style>

View File

@@ -0,0 +1,145 @@
<template>
<v-dialog
max-width="560"
:model-value="visible"
@update:model-value="onUpdateVisible"
>
<v-card v-if="relation || product">
<v-card-title class="d-flex align-center">
<v-icon class="mr-2" color="teal" icon="mdi-package-variant" />
<span class="font-weight-bold">{{ productName }}</span>
</v-card-title>
<v-divider />
<v-card-text>
<img
v-if="productImage"
alt="Imagen del producto"
class="provenance-image rounded-lg border mb-3"
:src="productImage"
>
<div
v-if="!hasLocation"
class="d-flex align-start mb-3"
>
<v-icon class="mr-3" color="grey" icon="mdi-map-marker-off" />
<div>
<div class="text-subtitle-2 font-weight-bold">
Geolocalización
</div>
<div class="text-body-1">
{{ locationNote }}
</div>
</div>
</div>
<div
v-for="row in rows"
:key="row.kind"
class="d-flex align-start mb-3"
>
<v-icon class="mr-3" :color="row.color" :icon="row.icon" />
<div>
<div class="text-subtitle-2 font-weight-bold">
{{ row.label }}
</div>
<div class="text-body-1">
{{ row.name }}
</div>
<div
v-for="detail in row.details"
:key="detail.label"
class="text-body-2 text-medium-emphasis"
>
{{ detail.label }}: {{ detail.value }}
</div>
</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="close">Cerrar</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
relation: {
type: Object,
default: null,
},
product: {
type: Object,
default: null,
},
})
const emit = defineEmits(['update:visible'])
const ENTITY_INFO = {
supplier: { label: 'Proveedor', icon: 'mdi-truck', color: 'blue' },
organization: { label: 'Organización', icon: 'mdi-domain', color: 'orange' },
municipality: { label: 'Municipio', icon: 'mdi-map-marker', color: 'green' },
department: { label: 'Departamento', icon: 'mdi-map', color: 'indigo' },
country: { label: 'País', icon: 'mdi-earth', color: 'purple' },
}
const productName = computed(() => props.product?.name || 'Producto')
const productImage = computed(() => props.product?.catalogue_images?.[0] || null)
const hasLocation = computed(() => !!(props.relation?.municipality || props.relation?.department))
const locationNote = computed(() => {
if (props.relation) return 'Aún sin geolocalización registrada.'
return 'Aún no se ha vinculado un proveedor a este producto.'
})
function entityDetails (kind, entity) {
const details = []
if (kind === 'supplier' || kind === 'organization') {
if (entity.description) details.push({ label: 'Descripción', value: entity.description })
if (entity.website) details.push({ label: 'Sitio web', value: entity.website })
if (entity.contact_email) details.push({ label: 'Correo', value: entity.contact_email })
if (entity.contact_phone) details.push({ label: 'Teléfono', value: entity.contact_phone })
}
return details
}
const rows = computed(() => {
const rel = props.relation || {}
return ['supplier', 'organization', 'municipality', 'department', 'country']
.filter(kind => rel[kind])
.map(kind => {
const info = ENTITY_INFO[kind]
return {
kind,
label: info.label,
icon: info.icon,
color: info.color,
name: rel[kind].name,
details: entityDetails(kind, rel[kind]),
}
})
})
function onUpdateVisible (value) {
emit('update:visible', value)
}
function close () {
emit('update:visible', false)
}
</script>
<style scoped>
.provenance-image {
display: block;
max-height: 220px;
max-width: 100%;
object-fit: contain;
}
</style>

View File

@@ -0,0 +1,77 @@
<template>
<div v-if="provenance && provenance.length > 0">
<v-divider class="my-4" />
<div
class="d-flex align-center justify-space-between cursor-pointer"
data-test="provenance-toggle"
role="button"
@click="expanded = !expanded"
>
<h3 class="text-h6 font-weight-bold">
Origen de los productos
</h3>
<v-btn
data-test="provenance-toggle-button"
density="comfortable"
:icon="expanded ? 'mdi-chevron-up' : 'mdi-chevron-down'"
variant="text"
@click.stop="expanded = !expanded"
/>
</div>
<v-expand-transition>
<div v-if="expanded">
<p class="text-body-2 text-medium-emphasis mb-4">
Conoce quiénes producen los productos que compras y de qué territorios provienen.
</p>
<ProvenanceGraph :provenance="provenance" />
</div>
</v-expand-transition>
<v-divider class="my-4" />
<div
class="d-flex align-center justify-space-between cursor-pointer"
data-test="map-toggle"
role="button"
@click="mapExpanded = !mapExpanded"
>
<h3 class="text-h6 font-weight-bold">
Mapa de origen de los productos
</h3>
<v-btn
data-test="map-toggle-button"
density="comfortable"
:icon="mapExpanded ? 'mdi-chevron-up' : 'mdi-chevron-down'"
variant="text"
@click.stop="mapExpanded = !mapExpanded"
/>
</div>
<v-expand-transition>
<div v-if="mapExpanded">
<p class="text-body-2 text-medium-emphasis mb-4">
Recorrido de cada producto desde el municipio donde se produce hasta nuestra tienda.
</p>
<ProvenanceMap :provenance="provenance" />
</div>
</v-expand-transition>
</div>
</template>
<script setup>
import { ref } from 'vue'
import ProvenanceGraph from './ProvenanceGraph.vue'
import ProvenanceMap from './ProvenanceMap.vue'
defineProps({
provenance: {
type: Array,
default: null,
},
})
const expanded = ref(false)
const mapExpanded = ref(false)
</script>

View File

@@ -0,0 +1,687 @@
<template>
<v-container fluid>
<h1 class="text-h4 mb-4">Gestión de Geografía</h1>
<v-tabs v-model="activeTab" color="primary">
<v-tab
data-testid="geo-tab-countries"
value="countries"
>
Países
</v-tab>
<v-tab
data-testid="geo-tab-departments"
value="departments"
>
Departamentos
</v-tab>
<v-tab
data-testid="geo-tab-municipalities"
value="municipalities"
>
Municipios
</v-tab>
</v-tabs>
<!-- Países -->
<v-row v-if="activeTab === 'countries'" class="mt-2">
<v-col class="text-right" cols="12">
<v-btn
color="primary"
data-testid="geo-country-create"
prepend-icon="mdi-plus"
@click="openCreateCountry"
>
Nuevo país
</v-btn>
</v-col>
<v-col cols="12">
<v-card>
<v-data-table
density="compact"
:headers="countryHeaders"
item-value="id"
:items="countries"
items-per-page="25"
:items-per-page-options="[10, 25, 50, 100]"
:loading="loading"
>
<template #item.actions="{ item }">
<v-btn
:data-testid="'geo-country-edit-' + item.id"
icon="mdi-pencil"
size="small"
variant="text"
@click="openEditCountry(item)"
/>
<v-btn
color="error"
:data-testid="'geo-country-delete-' + item.id"
icon="mdi-delete"
size="small"
variant="text"
@click="openDeleteCountry(item)"
/>
</template>
<template #loading>
<v-skeleton-loader type="table-row@10" />
</template>
<template #no-data>
<v-alert class="my-4" type="info" variant="tonal">
No hay países para mostrar
</v-alert>
</template>
</v-data-table>
</v-card>
</v-col>
</v-row>
<!-- Departamentos -->
<v-row v-if="activeTab === 'departments'" class="mt-2">
<v-col class="text-right" cols="12">
<v-btn
color="primary"
data-testid="geo-department-create"
prepend-icon="mdi-plus"
@click="openCreateDepartment"
>
Nuevo departamento
</v-btn>
</v-col>
<v-col cols="12">
<v-card>
<v-data-table
density="compact"
:headers="departmentHeaders"
item-value="id"
:items="departments"
items-per-page="25"
:items-per-page-options="[10, 25, 50, 100]"
:loading="loading"
>
<template #item.country="{ item }">
{{ item.country_detail?.name }}
</template>
<template #item.actions="{ item }">
<v-btn
:data-testid="'geo-department-edit-' + item.id"
icon="mdi-pencil"
size="small"
variant="text"
@click="openEditDepartment(item)"
/>
<v-btn
color="error"
:data-testid="'geo-department-delete-' + item.id"
icon="mdi-delete"
size="small"
variant="text"
@click="openDeleteDepartment(item)"
/>
</template>
<template #loading>
<v-skeleton-loader type="table-row@10" />
</template>
<template #no-data>
<v-alert class="my-4" type="info" variant="tonal">
No hay departamentos para mostrar
</v-alert>
</template>
</v-data-table>
</v-card>
</v-col>
</v-row>
<!-- Municipios -->
<v-row v-if="activeTab === 'municipalities'" class="mt-2">
<v-col class="text-right" cols="12">
<v-btn
color="primary"
data-testid="geo-municipality-create"
prepend-icon="mdi-plus"
@click="openCreateMunicipality"
>
Nuevo municipio
</v-btn>
</v-col>
<v-col cols="12">
<v-card>
<v-data-table
density="compact"
:headers="municipalityHeaders"
item-value="id"
:items="municipalities"
items-per-page="25"
:items-per-page-options="[10, 25, 50, 100]"
:loading="loading"
>
<template #item.department="{ item }">
{{ item.department_detail?.name }}
</template>
<template #item.country="{ item }">
{{ item.country_detail?.name }}
</template>
<template #item.actions="{ item }">
<v-btn
:data-testid="'geo-municipality-edit-' + item.id"
icon="mdi-pencil"
size="small"
variant="text"
@click="openEditMunicipality(item)"
/>
<v-btn
color="error"
:data-testid="'geo-municipality-delete-' + item.id"
icon="mdi-delete"
size="small"
variant="text"
@click="openDeleteMunicipality(item)"
/>
</template>
<template #loading>
<v-skeleton-loader type="table-row@10" />
</template>
<template #no-data>
<v-alert class="my-4" type="info" variant="tonal">
No hay municipios para mostrar
</v-alert>
</template>
</v-data-table>
</v-card>
</v-col>
</v-row>
<!-- Diálogo país -->
<v-dialog v-model="countryDialog.show" max-width="480">
<v-card>
<v-card-title>
{{ countryDialog.isEdit ? 'Editar país' : 'Nuevo país' }}
</v-card-title>
<v-card-text>
<v-form @submit.prevent="saveCountry">
<v-text-field
v-model="countryForm.name"
data-testid="geo-country-form-name"
label="Nombre"
required
/>
<v-text-field
v-model="countryForm.code"
data-testid="geo-country-form-code"
label="Código (ISO)"
maxlength="3"
required
/>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="countryDialog.show = false">Cancelar</v-btn>
<v-btn
color="primary"
data-testid="geo-country-save"
:disabled="saving"
variant="elevated"
@click="saveCountry"
>
Guardar
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Diálogo departamento -->
<v-dialog v-model="departmentDialog.show" max-width="480">
<v-card>
<v-card-title>
{{ departmentDialog.isEdit ? 'Editar departamento' : 'Nuevo departamento' }}
</v-card-title>
<v-card-text>
<v-form @submit.prevent="saveDepartment">
<v-text-field
v-model="departmentForm.name"
data-testid="geo-department-form-name"
label="Nombre"
required
/>
<v-select
v-model="departmentForm.country"
data-testid="geo-department-form-country"
item-title="name"
item-value="id"
:items="countries"
label="País"
required
/>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="departmentDialog.show = false">Cancelar</v-btn>
<v-btn
color="primary"
data-testid="geo-department-save"
:disabled="saving"
variant="elevated"
@click="saveDepartment"
>
Guardar
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Diálogo municipio -->
<v-dialog v-model="municipalityDialog.show" max-width="480">
<v-card>
<v-card-title>
{{ municipalityDialog.isEdit ? 'Editar municipio' : 'Nuevo municipio' }}
</v-card-title>
<v-card-text>
<v-form @submit.prevent="saveMunicipality">
<v-text-field
v-model="municipalityForm.name"
data-testid="geo-municipality-form-name"
label="Nombre"
required
/>
<v-autocomplete
v-model="municipalityForm.department"
data-testid="geo-municipality-form-department"
item-title="name"
item-value="id"
:items="departmentItems"
label="Departamento"
required
:search-input="departmentSearch"
/>
<v-select
v-model="municipalityForm.country"
data-testid="geo-municipality-form-country"
item-title="name"
item-value="id"
:items="countries"
label="País"
required
/>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="municipalityDialog.show = false">Cancelar</v-btn>
<v-btn
color="primary"
data-testid="geo-municipality-save"
:disabled="saving"
variant="elevated"
@click="saveMunicipality"
>
Guardar
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="deleteCountryDialog" max-width="420">
<v-card>
<v-card-title>Eliminar país</v-card-title>
<v-card-text>
¿Está seguro de eliminar "{{ deleteCountryTarget?.name }}"?
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="deleteCountryDialog = false">Cancelar</v-btn>
<v-btn
color="error"
:disabled="saving"
variant="elevated"
@click="confirmDeleteCountry"
>
Eliminar
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="deleteDepartmentDialog" max-width="420">
<v-card>
<v-card-title>Eliminar departamento</v-card-title>
<v-card-text>
¿Está seguro de eliminar "{{ deleteDepartmentTarget?.name }}"?
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="deleteDepartmentDialog = false">Cancelar</v-btn>
<v-btn
color="error"
:disabled="saving"
variant="elevated"
@click="confirmDeleteDepartment"
>
Eliminar
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="deleteMunicipalityDialog" max-width="420">
<v-card>
<v-card-title>Eliminar municipio</v-card-title>
<v-card-text>
¿Está seguro de eliminar "{{ deleteMunicipalityTarget?.name }}"?
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="deleteMunicipalityDialog = false">Cancelar</v-btn>
<v-btn
color="error"
:disabled="saving"
variant="elevated"
@click="confirmDeleteMunicipality"
>
Eliminar
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar
v-model="snackbar.show"
:color="snackbar.color"
location="top"
:timeout="3000"
>
{{ snackbar.message }}
<template #actions>
<v-btn variant="text" @click="snackbar.show = false">Cerrar</v-btn>
</template>
</v-snackbar>
</v-container>
</template>
<script setup>
import { computed, inject, onMounted, ref, watch } from 'vue'
const api = inject('api')
const activeTab = ref('countries')
const countries = ref([])
const departments = ref([])
const municipalities = ref([])
const loading = ref(false)
const saving = ref(false)
const departmentSearch = ref('')
const snackbar = ref({ show: false, message: '', color: 'success' })
const countryHeaders = [
{ title: 'ID', key: 'id' },
{ title: 'Nombre', key: 'name' },
{ title: 'Código', key: 'code' },
{ title: 'Acciones', key: 'actions', sortable: false },
]
const departmentHeaders = [
{ title: 'ID', key: 'id' },
{ title: 'Nombre', key: 'name' },
{ title: 'País', key: 'country' },
{ title: 'Acciones', key: 'actions', sortable: false },
]
const municipalityHeaders = [
{ title: 'ID', key: 'id' },
{ title: 'Nombre', key: 'name' },
{ title: 'Departamento', key: 'department' },
{ title: 'País', key: 'country' },
{ title: 'Acciones', key: 'actions', sortable: false },
]
const countryDialog = ref({ show: false, isEdit: false, item: null })
const countryForm = ref({ name: '', code: '' })
const departmentDialog = ref({ show: false, isEdit: false, item: null })
const departmentForm = ref({ name: '', country: null })
const municipalityDialog = ref({ show: false, isEdit: false, item: null })
const municipalityForm = ref({ name: '', department: null, country: null })
const departmentItems = computed(() => {
const query = departmentSearch.value.trim().toLowerCase()
let filtered = departments.value
if (query) {
filtered = departments.value.filter(department => {
return (department.name || '').toLowerCase().includes(query)
})
}
const selectedId = Number(municipalityForm.value.department)
const hasSelected = filtered.some(department => department.id === selectedId)
if (selectedId && !hasSelected) {
const selected = departments.value.find(department => department.id === selectedId)
if (selected) filtered = [selected, ...filtered]
}
return filtered
})
async function loadCountries () {
loading.value = true
try {
countries.value = await api.getCountries()
} catch (error) {
console.error('Error al cargar países:', error)
showSnackbar('Error al cargar países', 'error')
} finally {
loading.value = false
}
}
async function loadDepartments () {
loading.value = true
try {
departments.value = await api.getDepartments()
} catch (error) {
console.error('Error al cargar departamentos:', error)
showSnackbar('Error al cargar departamentos', 'error')
} finally {
loading.value = false
}
}
async function loadMunicipalities () {
loading.value = true
try {
municipalities.value = await api.getMunicipalities()
} catch (error) {
console.error('Error al cargar municipios:', error)
showSnackbar('Error al cargar municipios', 'error')
} finally {
loading.value = false
}
}
function openCreateCountry () {
countryForm.value = { name: '', code: '' }
countryDialog.value = { show: true, isEdit: false, item: null }
}
function openEditCountry (item) {
countryForm.value = { name: item.name, code: item.code }
countryDialog.value = { show: true, isEdit: true, item }
}
async function saveCountry () {
saving.value = true
try {
if (countryDialog.value.isEdit) {
await api.updateCountry(countryDialog.value.item.id, countryForm.value)
showSnackbar('País actualizado', 'success')
} else {
await api.createCountry(countryForm.value)
showSnackbar('País creado', 'success')
}
countryDialog.value.show = false
await loadCountries()
} catch (error) {
console.error('Error al guardar país:', error)
showSnackbar('Error al guardar país', 'error')
} finally {
saving.value = false
}
}
function openDeleteCountry (item) {
deleteCountryTarget.value = item
deleteCountryDialog.value = true
}
const deleteCountryDialog = ref(false)
const deleteCountryTarget = ref(null)
async function confirmDeleteCountry () {
saving.value = true
try {
await api.deleteCountry(deleteCountryTarget.value.id)
deleteCountryDialog.value = false
showSnackbar('País eliminado', 'success')
await loadCountries()
} catch (error) {
console.error('Error al eliminar país:', error)
showSnackbar('Error al eliminar país', 'error')
} finally {
saving.value = false
}
}
function openCreateDepartment () {
departmentForm.value = { name: '', country: null }
departmentDialog.value = { show: true, isEdit: false, item: null }
}
function openEditDepartment (item) {
departmentForm.value = { name: item.name, country: item.country }
departmentDialog.value = { show: true, isEdit: true, item }
}
async function saveDepartment () {
saving.value = true
try {
if (departmentDialog.value.isEdit) {
await api.updateDepartment(departmentDialog.value.item.id, departmentForm.value)
showSnackbar('Departamento actualizado', 'success')
} else {
await api.createDepartment(departmentForm.value)
showSnackbar('Departamento creado', 'success')
}
departmentDialog.value.show = false
await loadDepartments()
} catch (error) {
console.error('Error al guardar departamento:', error)
showSnackbar('Error al guardar departamento', 'error')
} finally {
saving.value = false
}
}
const deleteDepartmentDialog = ref(false)
const deleteDepartmentTarget = ref(null)
function openDeleteDepartment (item) {
deleteDepartmentTarget.value = item
deleteDepartmentDialog.value = true
}
async function confirmDeleteDepartment () {
saving.value = true
try {
await api.deleteDepartment(deleteDepartmentTarget.value.id)
deleteDepartmentDialog.value = false
showSnackbar('Departamento eliminado', 'success')
await loadDepartments()
} catch (error) {
console.error('Error al eliminar departamento:', error)
showSnackbar('Error al eliminar departamento', 'error')
} finally {
saving.value = false
}
}
function openCreateMunicipality () {
municipalityForm.value = { name: '', department: null, country: null }
municipalityDialog.value = { show: true, isEdit: false, item: null }
}
function openEditMunicipality (item) {
municipalityForm.value = {
name: item.name,
department: item.department,
country: item.country,
}
municipalityDialog.value = { show: true, isEdit: true, item }
}
watch(
() => municipalityForm.value.department,
departmentId => {
if (!departmentId) return
const department = departments.value.find(item => item.id === departmentId)
if (department && department.country) {
municipalityForm.value.country = department.country
}
}
)
async function saveMunicipality () {
saving.value = true
try {
if (municipalityDialog.value.isEdit) {
await api.updateMunicipality(municipalityDialog.value.item.id, municipalityForm.value)
showSnackbar('Municipio actualizado', 'success')
} else {
await api.createMunicipality(municipalityForm.value)
showSnackbar('Municipio creado', 'success')
}
municipalityDialog.value.show = false
await loadMunicipalities()
} catch (error) {
console.error('Error al guardar municipio:', error)
showSnackbar('Error al guardar municipio', 'error')
} finally {
saving.value = false
}
}
const deleteMunicipalityDialog = ref(false)
const deleteMunicipalityTarget = ref(null)
function openDeleteMunicipality (item) {
deleteMunicipalityTarget.value = item
deleteMunicipalityDialog.value = true
}
async function confirmDeleteMunicipality () {
saving.value = true
try {
await api.deleteMunicipality(deleteMunicipalityTarget.value.id)
deleteMunicipalityDialog.value = false
showSnackbar('Municipio eliminado', 'success')
await loadMunicipalities()
} catch (error) {
console.error('Error al eliminar municipio:', error)
showSnackbar('Error al eliminar municipio', 'error')
} finally {
saving.value = false
}
}
function showSnackbar (message, color) {
snackbar.value = { show: true, message, color }
}
watch(activeTab, tab => {
if (tab === 'departments') loadDepartments()
if (tab === 'municipalities') {
loadMunicipalities()
loadDepartments()
}
})
onMounted(loadCountries)
</script>

View File

@@ -0,0 +1,299 @@
<template>
<v-container fluid>
<v-row align="center">
<v-col cols="12" md="6">
<h1 class="text-h4">Gestión de Organizaciones</h1>
</v-col>
<v-col class="text-md-right" cols="12" md="6">
<v-btn
color="primary"
data-testid="org-create"
prepend-icon="mdi-plus"
@click="openCreate"
>
Nueva organización
</v-btn>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="searchQuery"
clearable
data-testid="org-search"
density="compact"
hide-details
label="Buscar por nombre"
prepend-inner-icon="mdi-magnify"
variant="outlined"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-card>
<v-data-table
density="compact"
:headers="headers"
item-value="id"
:items="filteredOrganizations"
items-per-page="25"
:items-per-page-options="[10, 25, 50, 100]"
:loading="loading"
>
<template #item.website="{ item }">
<a
v-if="item.website"
:href="item.website"
rel="noopener"
target="_blank"
>
{{ item.website }}
</a>
</template>
<template #item.actions="{ item }">
<v-btn
:data-testid="'org-edit-' + item.id"
icon="mdi-pencil"
size="small"
variant="text"
@click="openEdit(item)"
/>
<v-btn
color="error"
:data-testid="'org-delete-' + item.id"
icon="mdi-delete"
size="small"
variant="text"
@click="openDelete(item)"
/>
</template>
<template #loading>
<v-skeleton-loader type="table-row@10" />
</template>
<template #no-data>
<v-alert class="my-4" type="info" variant="tonal">
No hay organizaciones para mostrar
</v-alert>
</template>
</v-data-table>
</v-card>
</v-col>
</v-row>
<v-dialog v-model="dialog.show" max-width="520">
<v-card>
<v-card-title>
{{ dialog.isEdit ? 'Editar organización' : 'Nueva organización' }}
</v-card-title>
<v-card-text>
<v-form @submit.prevent="save">
<v-text-field
v-model="formData.name"
data-testid="org-form-name"
label="Nombre"
required
/>
<v-textarea
v-model="formData.description"
data-testid="org-form-description"
label="Descripción"
rows="3"
/>
<v-text-field
v-model="formData.website"
data-testid="org-form-website"
label="Sitio web"
/>
<v-text-field
v-model="formData.contact_email"
data-testid="org-form-email"
label="Correo de contacto"
/>
<v-text-field
v-model="formData.contact_phone"
data-testid="org-form-phone"
label="Teléfono de contacto"
/>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="dialog.show = false">Cancelar</v-btn>
<v-btn
color="primary"
data-testid="org-save"
:disabled="saving"
variant="elevated"
@click="save"
>
Guardar
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="deleteDialog.show" max-width="420">
<v-card>
<v-card-title>Eliminar organización</v-card-title>
<v-card-text>
¿Está seguro de eliminar "{{ deleteDialog.item?.name }}"?
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="deleteDialog.show = false">Cancelar</v-btn>
<v-btn
color="error"
data-testid="org-confirm-delete"
:disabled="saving"
variant="elevated"
@click="confirmDelete"
>
Eliminar
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar
v-model="snackbar.show"
:color="snackbar.color"
location="top"
:timeout="3000"
>
{{ snackbar.message }}
<template #actions>
<v-btn variant="text" @click="snackbar.show = false">Cerrar</v-btn>
</template>
</v-snackbar>
</v-container>
</template>
<script setup>
import { computed, inject, onMounted, ref } from 'vue'
const api = inject('api')
const organizations = ref([])
const loading = ref(false)
const saving = ref(false)
const searchQuery = ref('')
const snackbar = ref({ show: false, message: '', color: 'success' })
const dialog = ref({ show: false, isEdit: false, item: null })
const deleteDialog = ref({ show: false, item: null })
const headers = [
{ title: 'ID', key: 'id' },
{ title: 'Nombre', key: 'name' },
{ title: 'Descripción', key: 'description' },
{ title: 'Sitio web', key: 'website' },
{ title: 'Correo', key: 'contact_email' },
{ title: 'Teléfono', key: 'contact_phone' },
{ title: 'Acciones', key: 'actions', sortable: false },
]
const emptyForm = () => ({
name: '',
description: '',
website: '',
contact_email: '',
contact_phone: '',
})
const formData = ref(emptyForm())
const filteredOrganizations = computed(() => {
const query = searchQuery.value.trim().toLowerCase()
if (!query) return organizations.value
return organizations.value.filter(organization => {
return (organization.name || '').toLowerCase().includes(query)
})
})
async function load () {
loading.value = true
try {
organizations.value = await api.getOrganizations()
} catch (error) {
console.error('Error al cargar organizaciones:', error)
showSnackbar('Error al cargar organizaciones', 'error')
} finally {
loading.value = false
}
}
function openCreate () {
formData.value = emptyForm()
dialog.value = { show: true, isEdit: false, item: null }
}
function openEdit (item) {
formData.value = {
name: item.name,
description: item.description,
website: item.website,
contact_email: item.contact_email,
contact_phone: item.contact_phone,
}
dialog.value = { show: true, isEdit: true, item }
}
async function save () {
saving.value = true
try {
if (dialog.value.isEdit) {
await api.updateOrganization(dialog.value.item.id, formData.value)
showSnackbar('Organización actualizada', 'success')
} else {
await api.createOrganization(formData.value)
showSnackbar('Organización creada', 'success')
}
dialog.value.show = false
await load()
} catch (error) {
console.error('Error al guardar organización:', error)
showSnackbar('Error al guardar organización', 'error')
} finally {
saving.value = false
}
}
function openDelete (item) {
deleteDialog.value = { show: true, item }
}
async function confirmDelete () {
saving.value = true
try {
await api.deleteOrganization(deleteDialog.value.item.id)
deleteDialog.value.show = false
showSnackbar('Organización eliminada', 'success')
await load()
} catch (error) {
console.error('Error al eliminar organización:', error)
showSnackbar('Error al eliminar organización', 'error')
} finally {
saving.value = false
}
}
function showSnackbar (message, color) {
snackbar.value = { show: true, message, color }
}
onMounted(load)
</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,126 @@
<template>
<v-dialog
max-width="560"
:model-value="visible"
@update:model-value="onUpdateVisible"
>
<v-card>
<v-card-title>Proveedores del producto</v-card-title>
<v-card-text>
<p v-if="product" class="text-subtitle-1 mb-3">
{{ product.name }}
</p>
<v-autocomplete
v-model="selectedIds"
chips
closable-chips
item-title="name"
item-value="id"
:items="supplierItems"
label="Proveedores"
multiple
:search-input="searchQuery"
/>
<v-chip
v-if="selectedIds.length > 0"
class="mt-3"
color="primary"
variant="tonal"
>
Seleccionados ({{ selectedIds.length }})
</v-chip>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="close">Cancelar</v-btn>
<v-btn
color="primary"
data-testid="supplier-link-save"
:disabled="saving"
variant="elevated"
@click="save"
>
Guardar
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { computed, inject, ref, watch } from 'vue'
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
product: {
type: Object,
default: null,
},
})
const emit = defineEmits(['update:visible'])
const api = inject('api')
const suppliers = ref([])
const selectedIds = ref([])
const searchQuery = ref('')
const saving = ref(false)
const supplierItems = computed(() => {
const query = searchQuery.value.trim().toLowerCase()
let filtered = suppliers.value
if (query) {
filtered = suppliers.value.filter(supplier => {
return (supplier.name || '').toLowerCase().includes(query)
})
}
const selected = selectedIds.value.map(Number)
for (const supplier of suppliers.value) {
const isSelected = selected.includes(supplier.id)
const alreadyIncluded = filtered.some(item => item.id === supplier.id)
if (isSelected && !alreadyIncluded) filtered = [supplier, ...filtered]
}
return filtered
})
async function load () {
suppliers.value = await api.getSuppliers()
if (props.product) {
const detail = await api.getProduct(props.product.id)
selectedIds.value = detail.suppliers || []
} else {
selectedIds.value = []
}
}
watch(
() => props.visible,
async isVisible => {
if (isVisible) await load()
},
{ immediate: true }
)
async function save () {
saving.value = true
try {
await api.updateProduct(props.product.id, { suppliers: selectedIds.value })
close()
} catch (error) {
console.error('Error al guardar proveedores del producto:', error)
} finally {
saving.value = false
}
}
function onUpdateVisible (value) {
emit('update:visible', value)
}
function close () {
emit('update:visible', false)
}
</script>

View File

@@ -0,0 +1,347 @@
<template>
<v-container fluid>
<v-row align="center">
<v-col cols="12" md="6">
<h1 class="text-h4">Gestión de Proveedores</h1>
</v-col>
<v-col class="text-md-right" cols="12" md="6">
<v-btn
color="primary"
data-testid="supplier-create"
prepend-icon="mdi-plus"
@click="openCreate"
>
Nuevo proveedor
</v-btn>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="searchQuery"
clearable
data-testid="supplier-search"
density="compact"
hide-details
label="Buscar por nombre"
prepend-inner-icon="mdi-magnify"
variant="outlined"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-card>
<v-data-table
density="compact"
:headers="headers"
item-value="id"
:items="filteredSuppliers"
items-per-page="25"
:items-per-page-options="[10, 25, 50, 100]"
:loading="loading"
>
<template #item.organization="{ item }">
{{ item.organization_detail?.name }}
</template>
<template #item.municipality="{ item }">
{{ item.municipality_detail?.name }}
</template>
<template #item.actions="{ item }">
<v-btn
:data-testid="'supplier-edit-' + item.id"
icon="mdi-pencil"
size="small"
variant="text"
@click="openEdit(item)"
/>
<v-btn
color="error"
:data-testid="'supplier-delete-' + item.id"
icon="mdi-delete"
size="small"
variant="text"
@click="openDelete(item)"
/>
</template>
<template #loading>
<v-skeleton-loader type="table-row@10" />
</template>
<template #no-data>
<v-alert class="my-4" type="info" variant="tonal">
No hay proveedores para mostrar
</v-alert>
</template>
</v-data-table>
</v-card>
</v-col>
</v-row>
<v-dialog v-model="dialog.show" max-width="560">
<v-card>
<v-card-title>
{{ dialog.isEdit ? 'Editar proveedor' : 'Nuevo proveedor' }}
</v-card-title>
<v-card-text>
<v-form @submit.prevent="save">
<v-text-field
v-model="formData.name"
data-testid="supplier-form-name"
label="Nombre"
required
/>
<v-textarea
v-model="formData.description"
data-testid="supplier-form-description"
label="Descripción"
rows="3"
/>
<v-select
v-model="formData.organization"
clearable
data-testid="supplier-form-organization"
item-title="name"
item-value="id"
:items="organizations"
label="Organización"
/>
<v-autocomplete
v-model="formData.municipality"
clearable
data-testid="supplier-form-municipality"
:item-title="municipalityItemTitle"
item-value="id"
:items="municipalityItems"
label="Municipio"
:search-input="municipalitySearch"
/>
<v-text-field
v-model="formData.contact_email"
data-testid="supplier-form-email"
label="Correo de contacto"
/>
<v-text-field
v-model="formData.contact_phone"
data-testid="supplier-form-phone"
label="Teléfono de contacto"
/>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="dialog.show = false">Cancelar</v-btn>
<v-btn
color="primary"
data-testid="supplier-save"
:disabled="saving"
variant="elevated"
@click="save"
>
Guardar
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="deleteDialog.show" max-width="420">
<v-card>
<v-card-title>Eliminar proveedor</v-card-title>
<v-card-text>
¿Está seguro de eliminar "{{ deleteDialog.item?.name }}"?
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="deleteDialog.show = false">Cancelar</v-btn>
<v-btn
color="error"
data-testid="supplier-confirm-delete"
:disabled="saving"
variant="elevated"
@click="confirmDelete"
>
Eliminar
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar
v-model="snackbar.show"
:color="snackbar.color"
location="top"
:timeout="3000"
>
{{ snackbar.message }}
<template #actions>
<v-btn variant="text" @click="snackbar.show = false">Cerrar</v-btn>
</template>
</v-snackbar>
</v-container>
</template>
<script setup>
import { computed, inject, onMounted, ref } from 'vue'
const api = inject('api')
const suppliers = ref([])
const organizations = ref([])
const municipalities = ref([])
const departments = ref([])
const loading = ref(false)
const saving = ref(false)
const searchQuery = ref('')
const municipalitySearch = ref('')
const snackbar = ref({ show: false, message: '', color: 'success' })
const dialog = ref({ show: false, isEdit: false, item: null })
const deleteDialog = ref({ show: false, item: null })
const headers = [
{ title: 'ID', key: 'id' },
{ title: 'Nombre', key: 'name' },
{ title: 'Organización', key: 'organization' },
{ title: 'Municipio', key: 'municipality' },
{ title: 'Correo', key: 'contact_email' },
{ title: 'Teléfono', key: 'contact_phone' },
{ title: 'Acciones', key: 'actions', sortable: false },
]
const emptyForm = () => ({
name: '',
description: '',
organization: null,
municipality: null,
contact_email: '',
contact_phone: '',
})
const formData = ref(emptyForm())
const filteredSuppliers = computed(() => {
const query = searchQuery.value.trim().toLowerCase()
if (!query) return suppliers.value
return suppliers.value.filter(supplier => {
return (supplier.name || '').toLowerCase().includes(query)
})
})
function municipalityItemTitle (item) {
const department = departments.value.find(department => department.id === item.department)
if (!department) return item.name || ''
return `${item.name} (${department.name})`
}
const municipalityItems = computed(() => {
const query = municipalitySearch.value.trim().toLowerCase()
let filtered = municipalities.value
if (query) {
filtered = municipalities.value.filter(municipality => {
return municipalityItemTitle(municipality).toLowerCase().includes(query)
})
}
const selectedId = Number(formData.value.municipality)
const hasSelected = filtered.some(municipality => municipality.id === selectedId)
if (selectedId && !hasSelected) {
const selected = municipalities.value.find(municipality => municipality.id === selectedId)
if (selected) filtered = [selected, ...filtered]
}
return filtered
})
async function load () {
loading.value = true
try {
const [suppliersData, organizationsData, municipalitiesData, departmentsData] = await Promise.all([
api.getSuppliers(),
api.getOrganizations(),
api.getMunicipalities(),
api.getDepartments(),
])
suppliers.value = suppliersData
organizations.value = organizationsData
municipalities.value = municipalitiesData
departments.value = departmentsData
} catch (error) {
console.error('Error al cargar proveedores:', error)
showSnackbar('Error al cargar proveedores', 'error')
} finally {
loading.value = false
}
}
function openCreate () {
formData.value = emptyForm()
dialog.value = { show: true, isEdit: false, item: null }
}
function openEdit (item) {
formData.value = {
name: item.name,
description: item.description,
organization: item.organization,
municipality: item.municipality,
contact_email: item.contact_email,
contact_phone: item.contact_phone,
}
dialog.value = { show: true, isEdit: true, item }
}
async function save () {
saving.value = true
try {
if (dialog.value.isEdit) {
await api.updateSupplier(dialog.value.item.id, formData.value)
showSnackbar('Proveedor actualizado', 'success')
} else {
await api.createSupplier(formData.value)
showSnackbar('Proveedor creado', 'success')
}
dialog.value.show = false
await load()
} catch (error) {
console.error('Error al guardar proveedor:', error)
showSnackbar('Error al guardar proveedor', 'error')
} finally {
saving.value = false
}
}
function openDelete (item) {
deleteDialog.value = { show: true, item }
}
async function confirmDelete () {
saving.value = true
try {
await api.deleteSupplier(deleteDialog.value.item.id)
deleteDialog.value.show = false
showSnackbar('Proveedor eliminado', 'success')
await load()
} catch (error) {
console.error('Error al eliminar proveedor:', error)
showSnackbar('Error al eliminar proveedor', 'error')
} finally {
saving.value = false
}
}
function showSnackbar (message, color) {
snackbar.value = { show: true, message, color }
}
onMounted(load)
</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,145 @@
/**
* Builders de grafos de provenance (específicos del dominio).
*
* Convierten el payload `product_provenance` del resumen de compra/pedido en
* un grafo con la semántica de certeza:
* - Arista cierta (continua): el destino es inequívoco.
* - Arista dudosa (discontinua): el destino es una posibilidad entre varias.
* - Con varios proveedores para un producto se inserta un nodo de disyunción
* (junction): sólida hasta él y discontinua hacia cada proveedor.
*
* `buildProvenanceGraph` permite elegir los niveles a graficar
* (product, supplier, organization, municipality, department, country);
* los niveles no solicitados se omiten conectando el nivel previo con el
* siguiente.
*
* El modelo devuelto es agnóstico de la herramienta de renderizado:
* node: { id, kind, label, image, entity }
* edge: { from, to, certain }
*/
const DEFAULT_KINDS = ['product', 'supplier']
const TERRITORY_ORDER = ['municipality', 'department', 'country']
function makeCollector () {
const nodes = []
const edges = []
const seen = new Map()
const edgeSeen = new Map()
return {
nodes,
edges,
addNode (kind, entity) {
const id = `${kind}:${entity.id}`
if (!seen.has(id)) {
const node = {
id,
kind,
label: entity.name || '',
image: entity.catalogue_images?.[0] || null,
entity: { ...entity, kind },
}
seen.set(id, node)
nodes.push(node)
}
return seen.get(id)
},
addEdge (from, to, certain) {
const key = `${from}\u0000${to}`
if (edgeSeen.has(key)) {
edgeSeen.get(key).certain = edgeSeen.get(key).certain && Boolean(certain)
return
}
const edge = { from, to, certain: Boolean(certain) }
edgeSeen.set(key, edge)
edges.push(edge)
},
}
}
function distinctValues (items) {
const seen = new Map()
for (const item of items) {
if (item && !seen.has(item.id)) seen.set(item.id, item)
}
return [...seen.values()]
}
function isCertain (values) {
return values.length <= 1
}
function wirePath (collector, path, certainties) {
for (let index = 0; index < path.length - 1; index++) {
const to = path[index + 1]
const kind = to.split(':')[0]
collector.addEdge(path[index], to, certainties[kind])
}
}
export function buildProvenanceGraph (provenance, kinds = DEFAULT_KINDS) {
const active = new Set(kinds)
const collector = makeCollector()
for (const entry of provenance || []) {
if (!entry.product) continue
const relations = (entry.suppliers || []).filter(rel => rel.supplier)
if (relations.length === 0) continue
const product = active.has('product') ? collector.addNode('product', entry.product) : null
const suppliersActive = active.has('supplier')
const certainties = {
municipality: isCertain(distinctValues(relations.map(rel => rel.municipality))),
department: isCertain(distinctValues(relations.map(rel => rel.department))),
country: isCertain(distinctValues(relations.map(rel => rel.country))),
organization: isCertain(distinctValues(relations.map(rel => rel.organization))),
}
for (const rel of relations) {
if (suppliersActive) collector.addNode('supplier', rel.supplier)
if (active.has('municipality') && rel.municipality) collector.addNode('municipality', rel.municipality)
if (active.has('department') && rel.department) collector.addNode('department', rel.department)
if (active.has('country') && rel.country) collector.addNode('country', rel.country)
if (active.has('organization') && rel.organization) collector.addNode('organization', rel.organization)
}
if (suppliersActive && product) {
if (relations.length === 1) {
collector.addEdge(product.id, `supplier:${relations[0].supplier.id}`, true)
} else {
const junction = collector.addNode('junction', { id: entry.product.id, name: '' })
collector.addEdge(product.id, junction.id, true)
for (const rel of relations) {
collector.addEdge(junction.id, `supplier:${rel.supplier.id}`, false)
}
}
}
for (const rel of relations) {
const path = []
if (suppliersActive) {
path.push(`supplier:${rel.supplier.id}`)
} else if (product) {
path.push(product.id)
}
for (const level of TERRITORY_ORDER) {
if (active.has(level) && rel[level]) path.push(`${level}:${rel[level].id}`)
}
wirePath(collector, path, certainties)
}
if (active.has('organization')) {
for (const rel of relations) {
if (!rel.organization) continue
const source = suppliersActive ? `supplier:${rel.supplier.id}` : (product ? product.id : null)
if (source) collector.addEdge(source, `organization:${rel.organization.id}`, certainties.organization)
}
}
}
return { nodes: collector.nodes, edges: collector.edges }
}
export function hasAnySupplier (provenance) {
return (provenance || []).some(entry => (entry.suppliers || []).length > 0)
}

View File

@@ -0,0 +1,80 @@
/**
* Adapta el modelo de grafo de provenance ({ id, kind, label, image, entity }
* y { from, to, certain }) al formato de vis-network, incluyendo la semántica
* de certeza: arista cierta = sólida, arista dudosa = discontinua.
*/
const COLORS = {
product: '#26a69a',
supplier: '#42a5f5',
organization: '#ffb74d',
municipality: '#66bb6a',
department: '#5c6bc0',
country: '#ab47bc',
junction: '#78909c',
}
const PRODUCT_SPACING = 110
const COLUMN_X = {
product: -240,
junction: -160,
supplier: -80,
organization: 80,
municipality: 240,
department: 400,
country: 560,
}
export const KIND_COLORS = COLORS
export function toVisNodes (nodes) {
const products = nodes.filter(node => node.kind === 'product')
const productY = new Map()
products.forEach((node, index) => {
productY.set(node.entity.id, (index - (products.length - 1) / 2) * PRODUCT_SPACING)
})
return nodes.map(node => {
const color = COLORS[node.kind] || '#cfd8dc'
const { image, ...rest } = node
if (node.kind === 'junction') {
return {
...rest,
shape: 'dot',
color,
size: 10,
label: '',
x: COLUMN_X.junction,
y: productY.get(node.entity.id),
fixed: { x: true, y: true },
}
}
const base = image
? { ...rest, image, shape: 'circularImage', borderWidth: 2 }
: { ...rest, shape: 'dot', color, borderWidth: 2 }
if (node.kind === 'product') {
return {
...base,
x: COLUMN_X.product,
y: productY.get(node.entity.id),
fixed: { x: true, y: true },
}
}
return { ...base, x: COLUMN_X[node.kind], fixed: { x: true, y: false } }
})
}
export function toVisEdges (edges) {
return edges.map(edge => ({
from: edge.from,
to: edge.to,
dashes: !edge.certain,
}))
}
export const chartOptions = {
nodes: { font: { size: 13, color: '#263238' } },
edges: { color: { color: '#546e7a' } },
physics: {
barnesHut: { gravitationalConstant: -4000, springLength: 120, springConstant: 0.02 },
},
}

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>
<GeographyManagement v-if="authStore.isAdmin" />
</template>
<script setup>
import { useAuthStore } from '@/stores/auth'
import GeographyManagement from '@/components/provenance/admin/GeographyManagement.vue'
const authStore = useAuthStore()
</script>

View File

@@ -0,0 +1,10 @@
<template>
<OrganizationsManagement v-if="authStore.isAdmin" />
</template>
<script setup>
import { useAuthStore } from '@/stores/auth'
import OrganizationsManagement from '@/components/provenance/admin/OrganizationsManagement.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

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

View File

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

View File

@@ -0,0 +1,7 @@
<template>
<Login />
</template>
<script setup>
import Login from '@/components/Login.vue'
</script>

872
src/pages/catalog.vue Normal file
View File

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

View File

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

View File

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

View File

@@ -1,20 +1,14 @@
<template> <template>
<div> <ReconciliationJar v-if="authStore.isAdmin" />
<CodeDialog @code-verified="(verified) => showComponent = verified"/>
</div>
<ReconciliationJar v-if="showComponent" />
</template> </template>
<script > <script >
import CodeDialog from '../components/CodeDialog.vue' import { useAuthStore } from '@/stores/auth';
export default { export default {
data() { setup() {
return { const authStore = useAuthStore();
showComponent: false, return { authStore };
}
}, },
components: { CodeDialog },
methods: {},
} }
</script> </script>

View File

@@ -1,20 +1,14 @@
<template> <template>
<div> <ReconciliationJarIndex v-if="authStore.isAdmin" />
<CodeDialog @code-verified="(verified) => showComponent = verified" />
</div>
<ReconciliationJarIndex v-if="showComponent" />
</template> </template>
<script> <script>
import CodeDialog from '../components/CodeDialog.vue' import { useAuthStore } from '@/stores/auth';
export default { export default {
data() { setup() {
return { const authStore = useAuthStore();
showComponent: false, return { authStore };
}
}, },
components: { CodeDialog },
methods: {},
} }
</script> </script>

View File

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

View File

@@ -0,0 +1,79 @@
<template>
<v-container class="pa-4 pa-md-6" fluid>
<v-sheet class="rounded-lg pa-4 pa-md-6 mb-4">
<h1 class="text-h5 font-weight-bold">Consultar mi pedido o compra</h1>
<p class="text-body-2 text-medium-emphasis mb-4">
Ingresa el código de tu pedido o compra, o abre el link que recibiste para revisar su estado.
</p>
<v-form @submit.prevent="onConsult">
<v-row align="center">
<v-col cols="12" md="6" sm="8">
<v-text-field
v-model="inputCode"
clearable
density="comfortable"
hide-details
label="Código"
variant="outlined"
/>
</v-col>
<v-col class="d-flex align-center" cols="12" md="6" sm="4">
<v-btn color="primary" prepend-icon="mdi-magnify-scan" type="submit">
Consultar
</v-btn>
</v-col>
</v-row>
</v-form>
</v-sheet>
<PublicOrderSummary :error="error" :loading="loading" :purchase="purchase" />
</v-container>
</template>
<script setup>
import { inject, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import PublicOrderSummary from '@/components/PublicOrderSummary.vue'
const route = useRoute()
const router = useRouter()
const api = inject('api')
const inputCode = ref(route.params.code || '')
const purchase = ref(null)
const loading = ref(false)
const error = ref('')
async function fetchOrder (code) {
loading.value = true
error.value = ''
purchase.value = null
try {
purchase.value = await api.getPublicOrderSummary(code)
} catch (e) {
error.value =
e?.response?.status === 404
? 'No se encontró un pedido con ese código.'
: 'Ocurrió un error al consultar el pedido. Inténtalo de nuevo.'
} finally {
loading.value = false
}
}
function onConsult () {
const code = inputCode.value.trim()
if (!code) return
router.push(`/pedido/${code}`)
}
watch(
() => route.params.code,
code => {
if (code) {
inputCode.value = code
fetchOrder(code)
}
},
{ immediate: true }
)
</script>

7
src/pages/salir.vue Normal file
View File

@@ -0,0 +1,7 @@
<template>
<Logout />
</template>
<script setup>
//
</script>

View File

@@ -0,0 +1,131 @@
<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="formatSalesResults(result.failed)"
density="compact"
:headers="[
{ title: 'ID', key: 'id' },
{ title: 'Código', key: 'code' },
{ title: 'Detalle', key: 'detail' }
]"
></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="formatSalesResults(result.successful)"
density="compact"
:headers="[
{ title: 'ID', key: 'id' },
{ title: 'Código', key: 'code' }
]"
></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: {
formatSalesResults(items) {
if (!items || items.length === 0) return [];
return items.map(item =>
typeof item === 'object'
? { id: item.id, code: item.code ?? '', detail: item.error ?? '' }
: { id: item, code: '', detail: '' }
);
},
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

@@ -0,0 +1,184 @@
<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 Clientes
</v-card-title>
<v-card-text>
<p>
Esta acción sincronizará los <strong>clientes</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>
<p class="mt-4">
Durante la sincronización, no se podrán modificar clientes en la
plataforma para evitar conflictos.
</p>
</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 clientes...</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_parties?.length || 0 }})</v-card-title>
<v-card-text>
<v-data-table
:items="formatResults(result.failed_parties)"
density="compact"
:headers="[
{ title: 'ID', key: 'id' },
{ title: 'Nombre', key: 'name' },
{ title: 'Detalle', key: 'detail' }
]"
></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">✅ Creados ({{ result.created_customers?.length || 0 }})</v-card-title>
<v-card-text>
<v-data-table
:items="formatResults(result.created_customers)"
density="compact"
:headers="[
{ title: 'ID', key: 'id' },
{ title: 'Nombre', key: 'name' }
]"
></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-warning">🔄 Actualizados ({{ result.updated_customers?.length || 0 }})</v-card-title>
<v-card-text>
<v-data-table
:items="formatResults(result.updated_customers)"
density="compact"
:headers="[
{ title: 'ID', key: 'id' },
{ title: 'Nombre', key: 'name' },
{ title: 'Detalle', key: 'detail' }
]"
></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-grey-lighten-1">⏭️ Sin cambios ({{ result.untouched_customers?.length || 0 }})</v-card-title>
<v-card-text>
<v-data-table
:items="formatResults(result.untouched_customers)"
density="compact"
:headers="[
{ title: 'ID', key: 'id' },
{ title: 'Nombre', key: 'name' }
]"
></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-info text-white">🔍 Verificados ({{ result.checked_tryton_parties?.length || 0 }})</v-card-title>
<v-card-text>
<v-data-table
:items="formatResults(result.checked_tryton_parties)"
density="compact"
:headers="[
{ title: 'ID', key: 'id' },
{ title: 'Nombre', key: 'name' }
]"
></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: 'CustomersFromTryton',
setup() {
const authStore = useAuthStore();
return { authStore };
},
data() {
return {
api: inject('api'),
loading: false,
result: null,
}
},
methods: {
formatResults(items) {
if (!items || items.length === 0) return [];
return items.map(item =>
typeof item === 'object'
? { id: item.id ?? item.external_id, name: item.name ?? '', detail: item.error ?? item.detail ?? '' }
: { id: item, name: '', detail: '' }
);
},
startSync() {
this.loading = true;
this.api.getCustomersFromTryton()
.then(response => {
this.result = response;
this.loading = false;
})
.catch(error => {
console.error('Error al sincronizar clientes:', error);
this.loading = false;
});
}
}
}
</script>

View File

@@ -0,0 +1,201 @@
<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 Productos
</v-card-title>
<v-card-text>
<p>
Esta acción sincronizará los <strong>productos</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>
<p class="mt-4">
Durante la sincronización, no se podrán modificar productos en la
plataforma para evitar conflictos.
</p>
</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 productos...</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_products?.length || 0 }})</v-card-title>
<v-card-text>
<v-data-table
:items="formatResults(result.failed_products)"
density="compact"
:headers="[
{ title: 'ID', key: 'id' },
{ title: 'Nombre', key: 'name' },
{ title: 'Detalle', key: 'detail' }
]"
></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">✅ Creados ({{ result.created_products?.length || 0 }})</v-card-title>
<v-card-text>
<v-data-table
:items="formatResults(result.created_products)"
density="compact"
:headers="[
{ title: 'ID', key: 'id' },
{ title: 'Nombre', key: 'name' }
]"
></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-info text-white">🔄 Actualizados ({{ result.updated_products?.length || 0 }})</v-card-title>
<v-card-text>
<v-data-table
:items="formatResults(result.updated_products)"
density="compact"
:headers="[
{ title: 'ID', key: 'id' },
{ title: 'Nombre', key: 'name' },
{ title: 'Detalle', key: 'detail' }
]"
></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-grey-lighten-1">⏭️ Sin cambios ({{ result.untouched_products?.length || 0 }})</v-card-title>
<v-card-text>
<v-data-table
:items="formatResults(result.untouched_products)"
density="compact"
:headers="[
{ title: 'ID', key: 'id' },
{ title: 'Nombre', key: 'name' }
]"
></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-indigo text-white">🏷️ Categorías creadas ({{ result.created_categories?.length || 0 }})</v-card-title>
<v-card-text>
<v-data-table
:items="formatResults(result.created_categories)"
density="compact"
:headers="[
{ title: 'ID', key: 'id' },
{ title: 'Nombre', key: 'name' }
]"
></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-deep-purple text-white">♻️ Categorías actualizadas ({{ result.updated_categories?.length || 0 }})</v-card-title>
<v-card-text>
<v-data-table
:items="formatResults(result.updated_categories)"
density="compact"
:headers="[
{ title: 'ID', key: 'id' },
{ title: 'Nombre', key: 'name' },
{ title: 'Detalle', key: 'detail' }
]"
></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: 'ProductsFromTryton',
setup() {
const authStore = useAuthStore();
return { authStore };
},
data() {
return {
api: inject('api'),
loading: false,
result: null,
}
},
methods: {
formatResults(items) {
if (!items || items.length === 0) return [];
return items.map(item =>
typeof item === 'object'
? { id: item.id ?? item.external_id, name: item.name ?? '', detail: item.error ?? item.detail ?? '' }
: { id: item, name: '', detail: '' }
);
},
startSync() {
this.loading = true;
this.api.getProductsFromTryton()
.then(response => {
this.result = response;
this.loading = false;
})
.catch(error => {
console.error('Error al sincronizar productos:', error);
this.loading = false;
});
}
}
}
</script>

View File

@@ -0,0 +1,131 @@
<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
</v-card-title>
<v-card-text>
<p>
Esta acción sincronizará las <strong>ventas</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...</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="formatSalesResults(result.failed)"
density="compact"
:headers="[
{ title: 'ID', key: 'id' },
{ title: 'Código', key: 'code' },
{ title: 'Detalle', key: 'detail' }
]"
></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="formatSalesResults(result.successful)"
density="compact"
:headers="[
{ title: 'ID', key: 'id' },
{ title: 'Código', key: 'code' }
]"
></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: 'SalesToTryton',
setup() {
const authStore = useAuthStore();
return { authStore };
},
data() {
return {
api: inject('api'),
loading: false,
result: null,
}
},
methods: {
formatSalesResults(items) {
if (!items || items.length === 0) return [];
return items.map(item =>
typeof item === 'object'
? { id: item.id, code: item.code ?? '', detail: item.error ?? '' }
: { id: item, code: '', detail: '' }
);
},
startSync() {
this.loading = true;
this.api.sendSalesToTryton()
.then(response => {
this.result = response;
this.loading = false;
})
.catch(error => {
console.error('Error al sincronizar ventas:', error);
this.loading = false;
});
}
}
}
</script>

View File

@@ -1,7 +1,11 @@
<template> <template>
<SummaryPurchase :id="$route.query.id"/> <SummaryPurchase :id="$route.query.id" :type="$route.query.type"/>
</template> </template>
<script setup> <script setup>
// definePage({
meta: {
requiresAuth: true
}
})
</script> </script>

View File

@@ -1,19 +1,13 @@
<template> <template>
<div> <ExportPurchasesForTryton v-if="authStore.isAdmin" />
<CodeDialog @code-verified="(verified) => showComponent = verified"/>
</div>
<ExportPurchasesForTryton v-if="showComponent" />
</template> </template>
<script> <script>
import CodeDialog from '../components/CodeDialog.vue' import { useAuthStore } from '@/stores/auth';
export default { export default {
data() { setup() {
return { const authStore = useAuthStore();
showComponent: false, return { authStore };
}
}, },
components: { CodeDialog },
methods: {},
} }
</script> </script>

View File

@@ -9,12 +9,50 @@
import { createRouter, createWebHistory } from 'vue-router/auto' import { createRouter, createWebHistory } from 'vue-router/auto'
import { setupLayouts } from 'virtual:generated-layouts' import { setupLayouts } from 'virtual:generated-layouts'
import { routes } from 'vue-router/auto-routes' 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',
'/sincronizar_productos_tryton',
'/ventas_para_tryton',
'/cuadres_de_tarro',
'/compra_admin',
'/cuadrar_tarro',
'/admin/products',
'/admin/catalog-sales',
'/admin/catalogue-images',
'/admin/store-settings',
'/admin/organizations',
'/admin/suppliers',
'/admin/geography',
]
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
routes: setupLayouts(routes), routes: setupLayouts(routes),
}) })
router.beforeEach((to, from, next) => {
const isAuthenticated = !!localStorage.getItem('access_token')
const requiresAuth = to.meta.requiresAuth === true
const requiresAdmin = to.meta.requiresAdmin === true || ADMIN_ROUTES.includes(to.path)
const authStore = useAuthStore()
if (requiresAuth && !isAuthenticated) {
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()
}
})
// Workaround for https://github.com/vitejs/vite/issues/11804 // Workaround for https://github.com/vitejs/vite/issues/11804
router.onError((err, to) => { router.onError((err, to) => {
if (err?.message?.includes?.('Failed to fetch dynamically imported module')) { if (err?.message?.includes?.('Failed to fetch dynamically imported module')) {

View File

@@ -3,7 +3,7 @@ import Api from './api';
class ApiImplementation { class ApiImplementation {
constructor() { constructor() {
const implementation = process.env.API_IMPLEMENTATION; const implementation = import.meta.env.VITE_API_IMPLEMENTATION;
let apiImplementation; let apiImplementation;
if (implementation === 'django') { if (implementation === 'django') {
apiImplementation = new DjangoApi(); apiImplementation = new DjangoApi();

View File

@@ -1,55 +1,199 @@
class Api { class Api {
constructor (apiImplementation) { constructor(apiImplementation) {
this.apiImplementation = apiImplementation; this.apiImplementation = apiImplementation;
} }
getCustomers() { getCustomers() {
return this.apiImplementation.getCustomers(); return this.apiImplementation.getCustomers();
} }
getProducts() { getProducts(active = 'all') {
return this.apiImplementation.getProducts(); return this.apiImplementation.getProducts(active);
} }
getPaymentMethods() { updateProduct(productId, data) {
return this.apiImplementation.getPaymentMethods(); return this.apiImplementation.updateProduct(productId, data);
} }
getSummaryPurchase(purchaseId) { getProduct(productId) {
return this.apiImplementation.getSummaryPurchase(purchaseId); return this.apiImplementation.getProduct(productId);
} }
getPurchasesForReconciliation() { getOrganizations() {
return this.apiImplementation.getPurchasesForReconciliation(); return this.apiImplementation.getOrganizations();
} }
getListReconcliations(page=1, itemsPerPage=10) { createOrganization(data) {
return this.apiImplementation.getListReconcliations(page, itemsPerPage); return this.apiImplementation.createOrganization(data);
} }
getReconciliation(reconciliationId) { updateOrganization(id, data) {
return this.apiImplementation.getReconciliation(reconciliationId); return this.apiImplementation.updateOrganization(id, data);
} }
isValidAdminCode(code) { deleteOrganization(id) {
return this.apiImplementation.isValidAdminCode(code); return this.apiImplementation.deleteOrganization(id);
} }
createPurchase(purchase) { getSuppliers() {
return this.apiImplementation.createPurchase(purchase); return this.apiImplementation.getSuppliers();
} }
createReconciliationJar(reconciliation) { createSupplier(data) {
return this.apiImplementation.createReconciliationJar(reconciliation); return this.apiImplementation.createSupplier(data);
} }
createCustomer(customer) { updateSupplier(id, data) {
return this.apiImplementation.createCustomer(customer); return this.apiImplementation.updateSupplier(id, data);
} }
getCSVForTryton() { deleteSupplier(id) {
return this.apiImplementation.getCSVForTryton(); return this.apiImplementation.deleteSupplier(id);
} }
getCountries() {
return this.apiImplementation.getCountries();
}
createCountry(data) {
return this.apiImplementation.createCountry(data);
}
updateCountry(id, data) {
return this.apiImplementation.updateCountry(id, data);
}
deleteCountry(id) {
return this.apiImplementation.deleteCountry(id);
}
getDepartments() {
return this.apiImplementation.getDepartments();
}
createDepartment(data) {
return this.apiImplementation.createDepartment(data);
}
updateDepartment(id, data) {
return this.apiImplementation.updateDepartment(id, data);
}
deleteDepartment(id) {
return this.apiImplementation.deleteDepartment(id);
}
getMunicipalities() {
return this.apiImplementation.getMunicipalities();
}
createMunicipality(data) {
return this.apiImplementation.createMunicipality(data);
}
updateMunicipality(id, data) {
return this.apiImplementation.updateMunicipality(id, data);
}
deleteMunicipality(id) {
return this.apiImplementation.deleteMunicipality(id);
}
getPaymentMethods() {
return this.apiImplementation.getPaymentMethods();
}
getSummaryPurchase(purchaseId) {
return this.apiImplementation.getSummaryPurchase(purchaseId);
}
getSummaryCatalogPurchase(purchaseId) {
return this.apiImplementation.getSummaryCatalogPurchase(purchaseId);
}
getPublicOrderSummary(code) {
return this.apiImplementation.getPublicOrderSummary(code);
}
getPurchasesForReconciliation() {
return this.apiImplementation.getPurchasesForReconciliation();
}
getListReconcliations(page = 1, itemsPerPage = 10) {
return this.apiImplementation.getListReconcliations(page, itemsPerPage);
}
getReconciliation(reconciliationId) {
return this.apiImplementation.getReconciliation(reconciliationId);
}
createPurchase(purchase) {
return this.apiImplementation.createPurchase(purchase);
}
createCatalogPurchase(purchase) {
return this.apiImplementation.createCatalogPurchase(purchase);
}
createReconciliationJar(reconciliation) {
return this.apiImplementation.createReconciliationJar(reconciliation);
}
createCustomer(customer) {
return this.apiImplementation.createCustomer(customer);
}
getCSVForTryton() {
return this.apiImplementation.getCSVForTryton();
}
getProductsFromTryton() {
return this.apiImplementation.getProductsFromTryton();
}
getCustomersFromTryton() {
return this.apiImplementation.getCustomersFromTryton();
}
sendSalesToTryton() {
return this.apiImplementation.sendSalesToTryton();
}
sendCatalogSalesToTryton() {
return this.apiImplementation.sendCatalogSalesToTryton();
}
getCatalogSales() {
return this.apiImplementation.getCatalogSales();
}
getCurrentUser() {
return this.apiImplementation.getCurrentUser();
}
getCatalogueImages() {
return this.apiImplementation.getCatalogueImages();
}
createCatalogueImage(data) {
return this.apiImplementation.createCatalogueImage(data);
}
updateCatalogueImage(id, data) {
return this.apiImplementation.updateCatalogueImage(id, data);
}
deleteCatalogueImage(id) {
return this.apiImplementation.deleteCatalogueImage(id);
}
getStoreSettings() {
return this.apiImplementation.getStoreSettings();
}
updateStoreSettings(data) {
return this.apiImplementation.updateStoreSettings(data);
}
} }
export default Api; export default Api;

72
src/services/auth.js Normal file
View File

@@ -0,0 +1,72 @@
class AuthService {
static TOKEN_KEY = 'access_token';
static REFRESH_KEY = 'refresh_token';
static async login(credentials) {
const url = `${import.meta.env.VITE_DJANGO_BASE_URL}/api/token/`;
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
});
if (!resp.ok) {
let errMsg = resp.statusText;
try {
const errData = await resp.json();
errMsg = errData?.detail ?? errData?.message ?? errMsg;
} catch (_) { /* ignore */ }
throw new Error(errMsg);
}
const data = await resp.json();
if (data.access && data.refresh) {
localStorage.setItem(this.TOKEN_KEY, data.access);
localStorage.setItem(this.REFRESH_KEY, data.refresh);
}
return data;
}
static getAccessToken() {
return localStorage.getItem(this.TOKEN_KEY);
}
static getRefreshToken() {
return localStorage.getItem(this.REFRESH_KEY);
}
static async refresh() {
const refresh = this.getRefreshToken();
if (!refresh) throw new Error('No refresh token');
const url = `${import.meta.env.VITE_DJANGO_BASE_URL}/api/token/refresh/`;
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh }),
});
if (!resp.ok) {
const errData = await resp.json().catch(() => ({}));
throw new Error(errData?.detail ?? resp.statusText);
}
const data = await resp.json();
localStorage.setItem(this.TOKEN_KEY, data.access);
return data.access;
}
static isAuthenticated() {
return !!this.getAccessToken();
}
static logout() {
localStorage.removeItem(this.TOKEN_KEY);
localStorage.removeItem(this.REFRESH_KEY);
}
}
export default AuthService;

View File

@@ -1,106 +1,286 @@
import AuthService from "@/services/auth";
import http from "@/services/http";
class DjangoApi { class DjangoApi {
constructor() { constructor() {
this.base = process.env.DJANGO_BASE_URL; this.base = import.meta.env.VITE_DJANGO_BASE_URL;
}
getCustomers() {
const url = this.base + '/don_confiao/api/customers/';
return this.getRequest(url);
}
getProducts() {
const url = this.base + '/don_confiao/api/products/';
return this.getRequest(url);
}
getPaymentMethods() {
const url = this.base + '/don_confiao/payment_methods/all/select_format';
return this.getRequest(url);
}
getSummaryPurchase(purchaseId) {
const url = this.base + `/don_confiao/resumen_compra_json/${purchaseId}`;
return this.getRequest(url);
}
getPurchasesForReconciliation() {
const url = this.base + '/don_confiao/purchases/for_reconciliation';
return this.getRequest(url);
}
getListReconcliations(page, itemsPerPage) {
const url = this.base + `/don_confiao/api/reconciliate_jar/?page=${page}&page_size=${itemsPerPage}`;
return this.getRequest(url);
}
getReconciliation(reconciliationId) {
const url = this.base + `/don_confiao/api/reconciliate_jar/${reconciliationId}/`;
return this.getRequest(url);
}
isValidAdminCode(code) {
const url = this.base + `/don_confiao/api/admin_code/validate/${code}`
return this.getRequest(url)
} }
createPurchase(purchase) { getRequest(url) {
const url = this.base + '/don_confiao/api/sales/'; return http.get(url).then((r) => r.data);
return this.postRequest(url, purchase); }
}
createReconciliationJar(reconciliation) { postRequest(url, payload) {
const url = this.base + '/don_confiao/reconciliate_jar'; return http.post(url, payload).then((r) => r.data);
return this.postRequest(url, reconciliation); }
}
createCustomer(customer) { patchRequest(url, payload) {
const url = this.base + '/don_confiao/api/customers/'; return http.patch(url, payload).then((r) => r.data);
return this.postRequest(url, customer); }
}
getCSVForTryton() { deleteRequest(url) {
const url = this.base + '/don_confiao/api/sales/for_tryton'; return http.delete(url).then((r) => r.data);
return this.getRequest(url); }
}
getRequest(url) { getCustomers() {
return new Promise ((resolve, reject) => { const url = this.base + "/don_confiao/api/customers/";
fetch(url) return this.getRequest(url);
.then(response => response.json()) }
.then(data => {
resolve(data);
})
.catch(error => {
reject(error);
});
});
}
postRequest(url, content) { getProducts(active = 'all') {
return new Promise((resolve, reject) => { let url = this.base + "/don_confiao/api/products/";
fetch(url, {
method: 'POST', // Agregar query parameter según filtro
headers: { if (active !== 'all') {
'Content-Type': 'application/json', url += `?active=${active}`;
}, }
body: JSON.stringify(content)
}) return this.getRequest(url);
.then(response => { }
if (!response.ok) {
reject(new Error(`Error ${response.status}: ${response.statusText}`)); updateProduct(productId, data) {
} else { const url = this.base + `/don_confiao/api/products/${productId}/`;
response.json().then(data => { return this.patchRequest(url, data);
if (!data) { }
reject(new Error('La respuesta no es un JSON válido'));
} else { getProduct(productId) {
resolve(data); const url = this.base + `/don_confiao/api/products/${productId}/`;
} return this.getRequest(url);
}); }
}
}) getOrganizations() {
.catch(error => reject(error)); const url = this.base + "/don_confiao/api/organizations/";
}); return this.getRequest(url);
} }
createOrganization(data) {
const url = this.base + "/don_confiao/api/organizations/";
return this.postRequest(url, data);
}
updateOrganization(id, data) {
const url = this.base + `/don_confiao/api/organizations/${id}/`;
return this.patchRequest(url, data);
}
deleteOrganization(id) {
const url = this.base + `/don_confiao/api/organizations/${id}/`;
return this.deleteRequest(url);
}
getSuppliers() {
const url = this.base + "/don_confiao/api/suppliers/";
return this.getRequest(url);
}
createSupplier(data) {
const url = this.base + "/don_confiao/api/suppliers/";
return this.postRequest(url, data);
}
updateSupplier(id, data) {
const url = this.base + `/don_confiao/api/suppliers/${id}/`;
return this.patchRequest(url, data);
}
deleteSupplier(id) {
const url = this.base + `/don_confiao/api/suppliers/${id}/`;
return this.deleteRequest(url);
}
getCountries() {
const url = this.base + "/don_confiao/api/countries/";
return this.getRequest(url);
}
createCountry(data) {
const url = this.base + "/don_confiao/api/countries/";
return this.postRequest(url, data);
}
updateCountry(id, data) {
const url = this.base + `/don_confiao/api/countries/${id}/`;
return this.patchRequest(url, data);
}
deleteCountry(id) {
const url = this.base + `/don_confiao/api/countries/${id}/`;
return this.deleteRequest(url);
}
getDepartments() {
const url = this.base + "/don_confiao/api/departments/";
return this.getRequest(url);
}
createDepartment(data) {
const url = this.base + "/don_confiao/api/departments/";
return this.postRequest(url, data);
}
updateDepartment(id, data) {
const url = this.base + `/don_confiao/api/departments/${id}/`;
return this.patchRequest(url, data);
}
deleteDepartment(id) {
const url = this.base + `/don_confiao/api/departments/${id}/`;
return this.deleteRequest(url);
}
getMunicipalities() {
const url = this.base + "/don_confiao/api/municipalities/";
return this.getRequest(url);
}
createMunicipality(data) {
const url = this.base + "/don_confiao/api/municipalities/";
return this.postRequest(url, data);
}
updateMunicipality(id, data) {
const url = this.base + `/don_confiao/api/municipalities/${id}/`;
return this.patchRequest(url, data);
}
deleteMunicipality(id) {
const url = this.base + `/don_confiao/api/municipalities/${id}/`;
return this.deleteRequest(url);
}
getPaymentMethods() {
const url =
this.base + "/don_confiao/payment_methods/all/select_format";
return this.getRequest(url);
}
getSummaryPurchase(purchaseId) {
const url =
this.base + `/don_confiao/resumen_compra_json/${purchaseId}`;
return this.getRequest(url);
}
getSummaryCatalogPurchase(purchaseId) {
const url =
this.base + `/don_confiao/resumen_compra_catalogo_json/${purchaseId}`;
return this.getRequest(url);
}
getPublicOrderSummary(code) {
const url =
this.base + `/don_confiao/resumen_publico/${code}`;
return this.getRequest(url);
}
getPurchasesForReconciliation() {
const url = this.base + "/don_confiao/purchases/for_reconciliation";
return this.getRequest(url);
}
getListReconcliations(page, itemsPerPage) {
const url =
this.base +
`/don_confiao/api/reconciliate_jar/?page=${page}&page_size=${itemsPerPage}`;
return this.getRequest(url);
}
getReconciliation(reconciliationId) {
const url =
this.base +
`/don_confiao/api/reconciliate_jar/${reconciliationId}/`;
return this.getRequest(url);
}
createPurchase(purchase) {
const url = this.base + "/don_confiao/api/sales/";
return this.postRequest(url, purchase);
}
createCatalogPurchase(purchase) {
const url = this.base + "/don_confiao/api/catalog_sales/";
return this.postRequest(url, purchase);
}
createReconciliationJar(reconciliation) {
const url = this.base + "/don_confiao/reconciliate_jar";
return this.postRequest(url, reconciliation);
}
createCustomer(customer) {
const url = this.base + "/don_confiao/api/customers/";
return this.postRequest(url, customer);
}
getCSVForTryton() {
const url = this.base + "/don_confiao/api/sales/for_tryton";
return this.getRequest(url);
}
getProductsFromTryton() {
const url = this.base + "/don_confiao/api/importar_productos_de_tryton";
return this.postRequest(url, {});
}
getCustomersFromTryton() {
const url = this.base + "/don_confiao/api/importar_clientes_de_tryton";
return this.postRequest(url, {});
}
sendSalesToTryton() {
const url = this.base + "/don_confiao/api/enviar_ventas_a_tryton";
return this.postRequest(url, {});
}
sendCatalogSalesToTryton() {
const url = this.base + "/don_confiao/api/enviar_catalog_sales_a_tryton";
return this.postRequest(url, {});
}
getCatalogSales() {
const url = this.base + "/don_confiao/api/catalog_sales/";
return this.getRequest(url);
}
getCurrentUser() {
const url = this.base + "/api/users/me/";
return this.getRequest(url);
}
getCatalogueImages() {
const url = this.base + "/don_confiao/api/catalogue_images/";
return this.getRequest(url);
}
createCatalogueImage(data) {
const url = this.base + "/don_confiao/api/catalogue_images/";
return http.post(url, data, {
headers: { 'Content-Type': undefined },
}).then((r) => r.data);
}
updateCatalogueImage(id, data) {
const url = this.base + `/don_confiao/api/catalogue_images/${id}/`;
return http.put(url, data, {
headers: { 'Content-Type': undefined },
}).then((r) => r.data);
}
deleteCatalogueImage(id) {
const url = this.base + `/don_confiao/api/catalogue_images/${id}/`;
return http.delete(url).then((r) => r.data);
}
getStoreSettings() {
const url = this.base + "/don_confiao/api/store_settings";
return this.getRequest(url);
}
updateStoreSettings(data) {
const url = this.base + "/don_confiao/api/store_settings";
return http.patch(url, data, {
headers: { 'Content-Type': undefined },
}).then((r) => r.data);
}
} }
export default DjangoApi; export default DjangoApi;

45
src/services/http.js Normal file
View File

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

20
src/stores/auth.js Normal file
View File

@@ -0,0 +1,20 @@
import { defineStore } from 'pinia'
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null
}),
getters: {
isAdmin: (state) => state.user?.role === 'administrator',
isPublico: (state) => state.user?.role === 'publico',
isAuthenticated: (state) => !!state.user
},
actions: {
setUser(user) {
this.user = user
},
clearUser() {
this.user = null
}
}
})

43
src/stores/cart.js Normal file
View File

@@ -0,0 +1,43 @@
import { defineStore } from 'pinia'
export const useCartStore = defineStore('cart', {
state: () => ({
items: []
}),
getters: {
cartCount: (state) => state.items.reduce((sum, item) => sum + item.quantity, 0),
cartTotal: (state) => state.items.reduce((sum, item) => sum + (item.price * item.quantity), 0)
},
actions: {
addItem(product) {
const existing = this.items.find(i => i.id === product.id)
if (existing) {
existing.quantity = product.quantity
} else {
this.items.push({
id: product.id,
name: product.name,
img: product.img,
price: product.price,
quantity: product.quantity,
measuring_unit: product.measuring_unit || 'Unidad'
})
}
},
removeItem(itemId) {
const index = this.items.findIndex(i => i.id === itemId)
if (index > -1) {
this.items.splice(index, 1)
}
},
updateQuantity({ itemId, quantity }) {
const item = this.items.find(i => i.id === itemId)
if (item) {
item.quantity = quantity
}
},
clearCart() {
this.items = []
}
}
})

26
src/stores/settings.js Normal file
View File

@@ -0,0 +1,26 @@
import { defineStore } from 'pinia'
export const useSettingsStore = defineStore('settings', {
state: () => ({
settings: null,
loaded: false
}),
getters: {
logo: (state) => state.settings?.logo || null,
address: (state) => state.settings?.address || '',
latitude: (state) => state.settings?.latitude,
longitude: (state) => state.settings?.longitude
},
actions: {
async fetchSettings(api) {
if (this.loaded) return this.settings
this.settings = await api.getStoreSettings()
this.loaded = true
return this.settings
},
setSettings(settings) {
this.settings = settings
this.loaded = true
}
}
})

44
tests/setup.js Normal file
View File

@@ -0,0 +1,44 @@
import { afterEach, vi } from 'vitest'
import { enableAutoUnmount } from '@vue/test-utils'
enableAutoUnmount(afterEach)
if (!navigator.clipboard) {
navigator.clipboard = { writeText: vi.fn().mockResolvedValue(undefined) }
}
if (typeof globalThis.ResizeObserver === 'undefined') {
globalThis.ResizeObserver = class ResizeObserver {
observe () {}
unobserve () {}
disconnect () {}
}
}
if (typeof globalThis.IntersectionObserver === 'undefined') {
globalThis.IntersectionObserver = class IntersectionObserver {
observe () {}
unobserve () {}
disconnect () {}
takeRecords () {
return []
}
}
}
if (typeof window.matchMedia !== 'function') {
window.matchMedia = query => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
})
}
afterEach(() => {
vi.restoreAllMocks()
})

View File

@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import NavBar from '@/components/NavBar.vue'
import vuetify from '@/plugins/vuetify'
import AuthService from '@/services/auth'
vi.mock('@/services/auth', () => ({
default: {
isAuthenticated: vi.fn(),
logout: vi.fn(),
},
}))
function mountNavBar (api) {
const pinia = createPinia()
setActivePinia(pinia)
const wrapper = mount(NavBar, {
global: {
plugins: [pinia, vuetify],
provide: { api },
mocks: { $router: { push: vi.fn() } },
stubs: {
VAppBar: { template: '<div><slot /></div>' },
VNavigationDrawer: { template: '<div><slot /></div>' },
},
},
})
return { wrapper }
}
describe('NavBar', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
})
it('sin sesión muestra el botón de login y no el menú de administración', async () => {
AuthService.isAuthenticated.mockReturnValue(false)
const { wrapper } = mountNavBar({ getCurrentUser: vi.fn() })
await flushPromises()
expect(wrapper.text()).toContain('Login')
expect(wrapper.text()).not.toContain('Administracion')
})
it('el administrador ve los ítems de administración de provenance', async () => {
AuthService.isAuthenticated.mockReturnValue(true)
const api = {
getCurrentUser: vi.fn().mockResolvedValue({ username: 'admin', role: 'administrator' }),
}
const { wrapper } = mountNavBar(api)
await flushPromises()
expect(api.getCurrentUser).toHaveBeenCalled()
expect(wrapper.text()).toContain('Administracion')
const adminItem = wrapper.findAll('.v-list-item')
.find(item => item.text().includes('Administracion'))
await adminItem.trigger('click')
expect(wrapper.text()).toContain('Organizaciones')
expect(wrapper.text()).toContain('Proveedores')
expect(wrapper.text()).toContain('Geografía')
})
it('el usuario público no ve el menú de administración y no ve "Comprar"', async () => {
AuthService.isAuthenticated.mockReturnValue(true)
const api = {
getCurrentUser: vi.fn().mockResolvedValue({ username: 'cliente', role: 'publico' }),
}
const { wrapper } = mountNavBar(api)
await flushPromises()
expect(wrapper.text()).not.toContain('Administracion')
expect(wrapper.text()).not.toContain('Comprar')
expect(wrapper.text()).toContain('Ver Catálogo')
})
})

View File

@@ -0,0 +1,47 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import OrderAccessInfo from '@/components/order/OrderAccessInfo.vue'
import vuetify from '@/plugins/vuetify'
function mountAccessInfo (code) {
return mount(OrderAccessInfo, {
props: { code },
global: { plugins: [vuetify] },
})
}
describe('OrderAccessInfo', () => {
beforeEach(() => {
navigator.clipboard.writeText.mockClear()
})
it('muestra el código del pedido', () => {
const wrapper = mountAccessInfo('abc123')
expect(wrapper.text()).toContain('abc123')
})
it('muestra el link público construido con el código', () => {
const wrapper = mountAccessInfo('abc123')
expect(wrapper.text()).toContain(
`${window.location.origin}/pedido/abc123`
)
})
it('copia el link al presionar el botón de copiar link', async () => {
const wrapper = mountAccessInfo('abc123')
await wrapper.find('[data-test="copy-link"]').trigger('click')
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
`${window.location.origin}/pedido/abc123`
)
})
it('copia el código al presionar el botón de copiar código', async () => {
const wrapper = mountAccessInfo('abc123')
await wrapper.find('[data-test="copy-code"]').trigger('click')
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('abc123')
})
})

View File

@@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import PublicOrderSummary from '@/components/PublicOrderSummary.vue'
import OrderAccessInfo from '@/components/order/OrderAccessInfo.vue'
import OrderCustomer from '@/components/order/OrderCustomer.vue'
import OrderLines from '@/components/order/OrderLines.vue'
import OrderTotal from '@/components/order/OrderTotal.vue'
import OrderPayment from '@/components/order/OrderPayment.vue'
import ProvenanceSection from '@/components/provenance/ProvenanceSection.vue'
import vuetify from '@/plugins/vuetify'
const ProvenanceSectionStub = {
name: 'ProvenanceSection',
props: ['provenance'],
template: '<div data-test="provenance-section" />',
}
const saleData = {
id: 5,
code: 'abc123',
date: '2024-09-02T00:00:00Z',
customer: { id: 1, name: 'Camilo' },
payment_method: 'CASH',
lines: [
{ product: { id: 10, name: 'Panela' }, quantity: 2, unit_price: 3000 },
{ product: { id: 11, name: 'Arroz' }, quantity: 1, unit_price: 5000 },
],
type: 'sale',
}
const catalogData = {
id: 7,
code: 'def456',
date: '2024-09-02T00:00:00Z',
customer: { id: 1, name: 'Camilo' },
lines: [{ product: { id: 10, name: 'Panela' }, quantity: 2, unit_price: 3000 }],
type: 'catalog',
}
function mountSummary (props) {
return mount(PublicOrderSummary, {
props,
global: {
plugins: [vuetify],
stubs: { ProvenanceSection: ProvenanceSectionStub },
},
})
}
describe('PublicOrderSummary', () => {
it('renderiza el resumen de una venta con sus partes', () => {
const wrapper = mountSummary({ purchase: saleData })
expect(wrapper.findComponent(OrderAccessInfo).exists()).toBe(true)
expect(wrapper.findComponent(OrderCustomer).exists()).toBe(true)
expect(wrapper.findComponent(OrderLines).exists()).toBe(true)
expect(wrapper.findComponent(OrderTotal).exists()).toBe(true)
expect(wrapper.findComponent(OrderPayment).exists()).toBe(true)
expect(wrapper.text()).toContain('Resumen de la compra')
expect(wrapper.text()).toContain('Camilo')
expect(wrapper.text()).toContain('Panela')
expect(wrapper.text()).toContain('Arroz')
expect(wrapper.text().replace(/\u00A0/g, ' ')).toContain('11.000')
})
it('renderiza un pedido de catálogo sin bloque de pago', () => {
const wrapper = mountSummary({ purchase: catalogData })
expect(wrapper.findComponent(OrderAccessInfo).exists()).toBe(true)
expect(wrapper.findComponent(OrderCustomer).exists()).toBe(true)
expect(wrapper.findComponent(OrderLines).exists()).toBe(true)
expect(wrapper.findComponent(OrderTotal).exists()).toBe(true)
expect(wrapper.findComponent(OrderPayment).exists()).toBe(false)
expect(wrapper.text()).toContain('Resumen del pedido')
expect(wrapper.text()).toContain('Camilo')
expect(wrapper.text()).toContain('Panela')
})
it('muestra el estado de carga', () => {
const wrapper = mountSummary({ purchase: null, loading: true })
expect(wrapper.text()).toContain('Cargando')
})
it('muestra el mensaje de error cuando lo recibe', () => {
const wrapper = mountSummary({
purchase: null,
error: 'No se encontró un pedido con ese código.',
})
expect(wrapper.text()).toContain('No se encontró un pedido con ese código.')
})
it('no renderiza datos cuando no hay pedido, carga ni error', () => {
const wrapper = mountSummary({ purchase: null })
expect(wrapper.text()).not.toContain('Camilo')
expect(wrapper.findComponent(OrderLines).exists()).toBe(false)
})
it('no renderiza la sección de provenance cuando la respuesta no la incluye', () => {
const wrapper = mountSummary({ purchase: saleData })
expect(wrapper.findComponent(ProvenanceSection).exists()).toBe(false)
})
it('renderiza la sección de provenance cuando el resumen la incluye', () => {
const withProvenance = {
...saleData,
product_provenance: [
{
product: { id: 10, name: 'Panela' },
suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, organization: { id: 3, name: 'Red' }, municipality: { id: 7, name: 'La Mesa' }, department: { id: 2, name: 'Cundinamarca' }, country: { id: 1, name: 'Colombia', code: 'CO' } }],
},
],
}
const wrapper = mountSummary({ purchase: withProvenance })
expect(wrapper.findComponent(ProvenanceSection).exists()).toBe(true)
expect(wrapper.findComponent(ProvenanceSection).props('provenance')).toEqual(
withProvenance.product_provenance
)
})
})

View File

@@ -0,0 +1,157 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import VisChart from '@/components/graph/VisChart.vue'
const vis = vi.hoisted(() => {
const networkInstance = {
on: vi.fn(),
once: vi.fn(),
setData: vi.fn(),
setOptions: vi.fn(),
fit: vi.fn(),
destroy: vi.fn(),
getPositions: vi.fn(() => ({})),
moveNode: vi.fn(),
}
const dataSetInstance = {
get: vi.fn(),
}
return {
networkInstance,
dataSetInstance,
Network: vi.fn(() => networkInstance),
DataSet: vi.fn(() => dataSetInstance),
}
})
vi.mock('vis-network/standalone', () => ({
Network: vis.Network,
DataSet: vis.DataSet,
}))
function handlerFor (mock, eventName) {
const call = mock.mock.calls.find(args => args[0] === eventName)
return call ? call[1] : null
}
describe('VisChart', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('crea la red con los nodos y aristas recibidos', () => {
const nodes = [{ id: 'product:1', label: 'Panela' }]
const edges = [{ from: 'product:1', to: 'supplier:5', dashes: true }]
mount(VisChart, { props: { nodes, edges } })
expect(vis.Network).toHaveBeenCalledTimes(1)
const [container, , options] = vis.Network.mock.calls[0]
expect(container).toBeInstanceOf(HTMLElement)
expect(options.physics.stabilization.enabled).toBe(true)
expect(vis.dataSetInstance.get).toBeDefined()
const data = vis.networkInstance.setData.mock.calls[0][0]
expect(data.nodes).toBe(vis.dataSetInstance)
expect(data.edges).toBe(vis.dataSetInstance)
})
it('mezcla las opciones base con las recibidas', () => {
mount(VisChart, {
props: {
nodes: [],
edges: [],
options: { nodes: { font: { size: 16 } }, physics: { stabilization: { iterations: 100 } } },
},
})
const options = vis.Network.mock.calls[0][2]
expect(options.nodes.font.size).toBe(16)
expect(options.nodes.shape).toBe('dot')
expect(options.physics.stabilization.iterations).toBe(100)
expect(options.physics.barnesHut.springLength).toBe(140)
})
it('emite select con el nodo al hacer click', () => {
const wrapper = mount(VisChart, { props: { nodes: [{ id: 'supplier:5', kind: 'supplier' }], edges: [] } })
vis.dataSetInstance.get.mockReturnValue({ id: 'supplier:5', kind: 'supplier', label: 'La Mesa' })
handlerFor(vis.networkInstance.on, 'click')({ nodes: ['supplier:5'] })
expect(wrapper.emitted('select')[0][0]).toEqual({ id: 'supplier:5', kind: 'supplier', label: 'La Mesa' })
})
it('no emite select cuando el click no cae sobre un nodo', () => {
const wrapper = mount(VisChart, { props: { nodes: [], edges: [] } })
handlerFor(vis.networkInstance.on, 'click')({ nodes: [] })
expect(wrapper.emitted('select')).toBeUndefined()
})
it('desactiva la física y ajusta la vista cuando la estabilización termina', () => {
mount(VisChart, { props: { nodes: [], edges: [] } })
const onStabilized = handlerFor(vis.networkInstance.once, 'stabilizationIterationsDone')
expect(onStabilized).toBeDefined()
onStabilized()
expect(vis.networkInstance.setOptions).toHaveBeenCalledWith({ physics: { enabled: false } })
expect(vis.networkInstance.fit).toHaveBeenCalled()
})
it('separa verticalmente los nodos de una misma columna tras estabilizar', () => {
vis.networkInstance.getPositions.mockReturnValue({
'supplier:5': { x: 0, y: 100 },
'supplier:6': { x: 0, y: 112 },
'municipality:7': { x: 240, y: 100 },
'product:1': { x: -240, y: 60 },
})
mount(VisChart, { props: { nodes: [], edges: [], minVerticalSpacing: 70 } })
const onStabilized = handlerFor(vis.networkInstance.once, 'stabilizationIterationsDone')
onStabilized()
expect(vis.networkInstance.moveNode).toHaveBeenCalledWith('supplier:6', 0, 170)
expect(vis.networkInstance.moveNode).not.toHaveBeenCalledWith('supplier:5', 0, expect.anything())
expect(vis.networkInstance.moveNode).not.toHaveBeenCalledWith('municipality:7', 240, expect.anything())
expect(vis.networkInstance.moveNode).not.toHaveBeenCalledWith('product:1', -240, expect.anything())
})
it('no separa nodos cuando no se indica espaciado vertical mínimo', () => {
vis.networkInstance.getPositions.mockReturnValue({
'supplier:5': { x: 0, y: 100 },
'supplier:6': { x: 0, y: 112 },
})
mount(VisChart, { props: { nodes: [], edges: [] } })
const onStabilized = handlerFor(vis.networkInstance.once, 'stabilizationIterationsDone')
onStabilized()
expect(vis.networkInstance.moveNode).not.toHaveBeenCalled()
})
it('vuelve a estabilizar el grafo cuando cambian los nodos o aristas', async () => {
const wrapper = mount(VisChart, { props: { nodes: [{ id: 'product:1' }], edges: [] } })
expect(vis.Network).toHaveBeenCalledTimes(1)
expect(vis.networkInstance.destroy).not.toHaveBeenCalled()
await wrapper.setProps({ nodes: [{ id: 'product:2' }], edges: [{ from: 'product:2', to: 'supplier:5' }] })
expect(vis.networkInstance.destroy).toHaveBeenCalled()
expect(vis.Network).toHaveBeenCalledTimes(2)
expect(vis.networkInstance.setData).toHaveBeenCalledTimes(2)
})
it('aplica la altura recibida al contenedor', () => {
const wrapper = mount(VisChart, { props: { height: 340 } })
expect(wrapper.element.style.height).toBe('340px')
})
it('destruye la red al desmontar', () => {
const wrapper = mount(VisChart, { props: { nodes: [], edges: [] } })
wrapper.unmount()
expect(vis.networkInstance.destroy).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import ProvenanceDetailModal from '@/components/provenance/ProvenanceDetailModal.vue'
import vuetify from '@/plugins/vuetify'
const supplier = {
id: 'supplier:5',
kind: 'supplier',
label: 'Asociación Agropecuaria La Mesa',
image: null,
entity: {
id: 5,
name: 'Asociación Agropecuaria La Mesa',
description: 'Cooperativa de campesinos',
website: 'https://agro.example.org',
contact_email: 'contacto@agro.example.org',
contact_phone: '300 123 4567',
kind: 'supplier',
},
}
const product = {
id: 'product:1',
kind: 'product',
label: 'Panela regional por Kg',
image: 'http://localhost/media/panela.jpg',
entity: { id: 1, name: 'Panela regional por Kg', kind: 'product' },
}
function mountModal (props) {
return mount(ProvenanceDetailModal, {
props,
global: { plugins: [vuetify] },
})
}
function bodyText () {
return document.body.textContent
}
describe('ProvenanceDetailModal', () => {
it('muestra la información detallada del proveedor seleccionado', () => {
mountModal({ visible: true, selected: supplier })
expect(bodyText()).toContain('Proveedor')
expect(bodyText()).toContain('Asociación Agropecuaria La Mesa')
expect(bodyText()).toContain('Cooperativa de campesinos')
expect(bodyText()).toContain('https://agro.example.org')
expect(bodyText()).toContain('contacto@agro.example.org')
expect(bodyText()).toContain('300 123 4567')
})
it('muestra la imagen del producto cuando existe', () => {
mountModal({ visible: true, selected: product })
expect(document.querySelector('img').getAttribute('src')).toBe(
'http://localhost/media/panela.jpg'
)
})
it('no muestra contenido cuando no hay entidad seleccionada', () => {
mountModal({ visible: true, selected: null })
expect(bodyText()).not.toContain('Proveedor')
expect(bodyText()).not.toContain('Asociación')
})
it('no muestra el diálogo cuando visible es false', () => {
mountModal({ visible: false, selected: supplier })
expect(bodyText()).not.toContain('Asociación Agropecuaria La Mesa')
})
it('omite los campos vacíos', () => {
const minimal = {
id: 'country:1',
kind: 'country',
label: 'Colombia',
image: null,
entity: { id: 1, name: 'Colombia', kind: 'country' },
}
mountModal({ visible: true, selected: minimal })
expect(bodyText()).toContain('Colombia')
expect(bodyText()).not.toContain('Descripción')
expect(bodyText()).not.toContain('Sitio web')
})
})

View File

@@ -0,0 +1,164 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import ProvenanceDetailModal from '@/components/provenance/ProvenanceDetailModal.vue'
import ProvenanceGraph from '@/components/provenance/ProvenanceGraph.vue'
import vuetify from '@/plugins/vuetify'
vi.mock('@/components/graph/VisChart.vue', () => ({
default: {
name: 'VisChart',
template: '<div class="vis-chart-stub" />',
props: ['nodes', 'edges', 'height', 'options', 'minVerticalSpacing'],
},
}))
const provenance = [
{
product: {
id: 1,
name: 'Panela regional por Kg',
catalogue_images: ['http://localhost/media/panela.jpg'],
},
suppliers: [
{
supplier: { id: 5, name: 'Asociación Agropecuaria La Mesa' },
organization: { id: 3, name: 'Red de Economía Solidaria' },
municipality: { id: 7, name: 'La Mesa' },
department: { id: 2, name: 'Cundinamarca' },
country: { id: 1, name: 'Colombia' },
},
],
},
]
function mountGraph (props = {}) {
return mount(ProvenanceGraph, {
props: { provenance, ...props },
global: { plugins: [vuetify] },
})
}
function visChart (wrapper) {
return wrapper.findComponent({ name: 'VisChart' })
}
function checkbox (wrapper, label) {
return wrapper.findAllComponents({ name: 'VCheckbox' }).find(cb => cb.text().trim() === label)
}
async function setKinds (wrapper, kinds) {
checkbox(wrapper, 'Productos').vm.$emit('update:modelValue', kinds)
await nextTick()
}
describe('ProvenanceGraph', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('por defecto grafica solo productos y proveedores', () => {
const wrapper = mountGraph()
const nodeKinds = visChart(wrapper).props('nodes').map(node => node.kind)
expect(nodeKinds).toEqual(['product', 'supplier'])
const edges = visChart(wrapper).props('edges')
expect(edges.find(edge => edge.from === 'product:1' && edge.to === 'supplier:5').dashes).toBe(false)
})
it('pide separación vertical mínima equivalente al círculo más una línea de label', () => {
const wrapper = mountGraph()
expect(visChart(wrapper).props('minVerticalSpacing')).toBe(70)
})
it('muestra un checkbox por cada nivel con productos y proveedores activos', () => {
const wrapper = mountGraph()
const labels = wrapper.findAllComponents({ name: 'VCheckbox' }).map(cb => cb.text().trim())
expect(labels).toEqual(['Productos', 'Proveedores', 'Organizaciones', 'Municipios', 'Departamentos', 'País'])
})
it('dibuja un círculo de color junto a cada checkbox como leyenda', () => {
const wrapper = mountGraph()
const dots = wrapper.findAll('.legend-dot')
expect(dots).toHaveLength(6)
expect(dots[0].element.style.backgroundColor).toBe('rgb(38, 166, 154)')
expect(dots[1].element.style.backgroundColor).toBe('rgb(66, 165, 245)')
expect(dots[2].element.style.backgroundColor).toBe('rgb(255, 183, 77)')
})
it('al activar municipios agrega nodos de municipio y la arista proveedor→municipio', async () => {
const wrapper = mountGraph()
await setKinds(wrapper, ['product', 'supplier', 'municipality'])
const nodeIds = visChart(wrapper).props('nodes').map(node => node.id)
expect(nodeIds).toContain('municipality:7')
const edges = visChart(wrapper).props('edges')
expect(edges.find(edge => edge.from === 'supplier:5' && edge.to === 'municipality:7').dashes).toBe(false)
})
it('con país activo crea el nodo país', async () => {
const wrapper = mountGraph()
await setKinds(wrapper, ['product', 'supplier', 'country'])
expect(visChart(wrapper).props('nodes').map(node => node.id)).toContain('country:1')
})
it('al desactivar productos no se crean nodos de producto', async () => {
const wrapper = mountGraph()
await setKinds(wrapper, ['supplier', 'municipality', 'department'])
const nodeKinds = visChart(wrapper).props('nodes').map(node => node.kind)
expect(nodeKinds).not.toContain('product')
expect(nodeKinds).not.toContain('junction')
})
it('avisa cuando no queda ningún nivel seleccionado', async () => {
const wrapper = mountGraph()
await setKinds(wrapper, [])
expect(visChart(wrapper).exists()).toBe(false)
expect(wrapper.text()).toContain('Selecciona al menos un elemento')
})
it('muestra mensaje de próximamente cuando ningún producto tiene proveedores', () => {
const wrapper = mountGraph({
provenance: [{ product: { id: 1, name: 'Panela' }, suppliers: [] }],
})
expect(visChart(wrapper).exists()).toBe(false)
expect(wrapper.text()).toContain('próximamente')
})
it('abre el modal de detalle al seleccionar un proveedor', async () => {
const wrapper = mountGraph()
await visChart(wrapper).vm.$emit('select', { id: 'supplier:5', kind: 'supplier', label: 'Asociación Agropecuaria La Mesa', entity: { kind: 'supplier' } })
expect(wrapper.findComponent(ProvenanceDetailModal).props('visible')).toBe(true)
expect(document.body.textContent).toContain('Asociación Agropecuaria La Mesa')
})
it('ignora la selección del punto de disyunción', async () => {
const provenanceWithTwoSuppliers = [
{
product: { id: 1, name: 'Panela' },
suppliers: [
{ supplier: { id: 5, name: 'A' }, municipality: { id: 7, name: 'La Mesa' } },
{ supplier: { id: 6, name: 'B' }, municipality: { id: 8, name: 'San Antonio' } },
],
},
]
const wrapper = mountGraph({ provenance: provenanceWithTwoSuppliers })
await visChart(wrapper).vm.$emit('select', { id: 'junction:1', kind: 'junction', label: '' })
expect(wrapper.findComponent(ProvenanceDetailModal).props('visible')).toBe(false)
})
})

View File

@@ -0,0 +1,402 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import { createPinia, setActivePinia } from 'pinia'
import ProvenanceMap from '@/components/provenance/ProvenanceMap.vue'
import vuetify from '@/plugins/vuetify'
const leaflet = vi.hoisted(() => {
const markers = []
const polylines = []
const layers = []
function makeLayer (type) {
const layer = {
type,
addTo: vi.fn(function () { return this }),
on: vi.fn(),
bindTooltip: vi.fn(function () { return this }),
bindPopup: vi.fn(function () { return this }),
setPopupContent: vi.fn(),
openPopup: vi.fn(),
closePopup: vi.fn(),
remove: vi.fn(),
getLatLng: vi.fn(function () { return this.pos }),
setLatLng: vi.fn(),
}
layers.push(layer)
return layer
}
const map = {
setView: vi.fn(function () { return this }),
fitBounds: vi.fn(),
flyTo: vi.fn(),
remove: vi.fn(),
invalidateSize: vi.fn(),
on: vi.fn(),
getZoom: vi.fn(() => 8),
}
const bounds = { extend: vi.fn(), isValid: vi.fn(() => true) }
return {
markers,
polylines,
layers,
map,
bounds,
L: {
map: vi.fn(() => map),
tileLayer: vi.fn(() => makeLayer('tileLayer')),
marker: vi.fn((pos, opts) => {
const marker = makeLayer('marker')
marker.pos = pos
marker.opts = opts
markers.push(marker)
return marker
}),
divIcon: vi.fn(opts => opts),
polyline: vi.fn((points, opts) => {
const polyline = makeLayer('polyline')
polyline.points = points
polyline.opts = opts
polylines.push(polyline)
return polyline
}),
latLngBounds: vi.fn(() => bounds),
},
}
})
vi.mock('leaflet', () => ({ default: leaflet.L }))
const settings = { latitude: 4.6, longitude: -74.08, address: 'Cra 1 #2-3' }
const provenance = [
{
product: { id: 1, name: 'Panela regional', catalogue_images: ['http://localhost/media/panela.jpg'] },
suppliers: [
{
supplier: { id: 5, name: 'Asociación La Mesa' },
organization: { id: 3, name: 'Red de Economía Solidaria' },
municipality: { id: 7, name: 'Santa Bárbara', latitude: 6.23, longitude: -75.56 },
},
],
},
{
product: { id: 2, name: 'Arroz blanco', catalogue_images: [] },
suppliers: [
{ supplier: { id: 6, name: 'Campesinos del Oriente' }, municipality: { id: 8, name: 'La Mesa', latitude: 4.86, longitude: -74.63 } },
{ supplier: { id: 7, name: 'Finca El Paraíso' }, municipality: { id: 9, name: 'San Antonio', latitude: 6.22, longitude: -75.57 } },
],
},
]
const sameMuni = [
{
product: { id: 1, name: 'Panela', catalogue_images: [] },
suppliers: [{ supplier: { id: 5, name: 'A' }, municipality: { id: 7, name: 'LA MESA', latitude: '4.6310280', longitude: '-74.4615880' } }],
},
{
product: { id: 2, name: 'Arroz', catalogue_images: [] },
suppliers: [{ supplier: { id: 6, name: 'B' }, municipality: { id: 7, name: 'LA MESA', latitude: '4.6310280', longitude: '-74.4615880' } }],
},
]
function mountMap (props = {}) {
const api = { getStoreSettings: vi.fn().mockResolvedValue(settings) }
const pinia = createPinia()
setActivePinia(pinia)
const wrapper = mount(ProvenanceMap, {
props: { provenance, ...props },
global: {
plugins: [pinia, vuetify],
provide: { api },
},
})
return { api, wrapper }
}
function handlerOf (marker, event) {
return marker.on.mock.calls.find(args => args[0] === event)[1]
}
beforeEach(() => {
vi.clearAllMocks()
leaflet.markers.length = 0
leaflet.polylines.length = 0
leaflet.layers.length = 0
document.body.innerHTML = ''
})
describe('ProvenanceMap', () => {
it('carga la configuración de la tienda al montarse', async () => {
const { api } = mountMap()
await flushPromises()
expect(api.getStoreSettings).toHaveBeenCalled()
})
it('crea el mapa con la tienda y un marcador por municipio de origen', async () => {
mountMap()
await flushPromises()
expect(leaflet.L.map).toHaveBeenCalledTimes(1)
expect(leaflet.L.tileLayer).toHaveBeenCalledTimes(1)
expect(leaflet.markers).toHaveLength(4)
expect(leaflet.map.fitBounds).toHaveBeenCalled()
expect(leaflet.markers[0].bindTooltip).toHaveBeenCalled()
})
it('al pasar sobre un producto dibuja una línea hasta la tienda', async () => {
mountMap()
await flushPromises()
handlerOf(leaflet.markers[1], 'mouseover')()
expect(leaflet.L.polyline).toHaveBeenCalledTimes(1)
expect(leaflet.L.polyline.mock.calls[0][0]).toEqual([[6.23, -75.56], [4.6, -74.08]])
expect(leaflet.polylines[0].addTo).toHaveBeenCalled()
})
it('al pasar sobre la tienda dibuja las líneas de todos los productos', async () => {
mountMap()
await flushPromises()
handlerOf(leaflet.markers[0], 'mouseover')()
expect(leaflet.L.polyline).toHaveBeenCalledTimes(3)
})
it('al salir del marcador se limpian las líneas', async () => {
mountMap()
await flushPromises()
handlerOf(leaflet.markers[1], 'mouseover')()
expect(leaflet.polylines).toHaveLength(1)
handlerOf(leaflet.markers[1], 'mouseout')()
expect(leaflet.polylines[0].remove).toHaveBeenCalled()
})
it('muestra el detalle del producto en un popup sobre el marcador', async () => {
mountMap()
await flushPromises()
const marker = leaflet.markers[1]
expect(marker.bindPopup).toHaveBeenCalledTimes(1)
const popupEl = marker.bindPopup.mock.calls[0][0]
expect(popupEl.textContent).toContain('Panela regional')
expect(popupEl.textContent).toContain('Asociación La Mesa')
expect(popupEl.textContent).toContain('Santa Bárbara')
})
it('no abre el modal web para productos con ubicación', async () => {
mountMap()
await flushPromises()
expect(document.body.textContent).not.toContain('Proveedor')
})
it('no dibuja la línea de la tienda cuando la tienda no tiene coordenadas', async () => {
const api = { getStoreSettings: vi.fn().mockResolvedValue({ address: 'Cra 1' }) }
const pinia = createPinia()
setActivePinia(pinia)
mount(ProvenanceMap, {
props: { provenance },
global: {
plugins: [pinia, vuetify],
provide: { api },
},
})
await flushPromises()
expect(leaflet.markers).toHaveLength(3)
handlerOf(leaflet.markers[0], 'mouseover')()
expect(leaflet.L.polyline).not.toHaveBeenCalled()
})
it('muestra un aviso cuando ningún municipio tiene coordenadas', async () => {
const noCoords = [
{ product: { id: 1, name: 'Panela' }, suppliers: [{ supplier: { id: 5, name: 'A' }, municipality: { id: 7, name: 'M' } }] },
]
const { wrapper } = mountMap({ provenance: noCoords })
await flushPromises()
expect(leaflet.L.map).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('coordenadas')
})
it('mantiene la posición exacta del municipio aunque varios productos coincidan', async () => {
mountMap({ provenance: sameMuni })
await flushPromises()
expect(leaflet.markers).toHaveLength(2)
expect(leaflet.markers[1].pos).toEqual([4.631028, -74.461588])
expect(leaflet.map.on).not.toHaveBeenCalledWith('zoomend', expect.anything())
})
it('agrupa los productos del mismo punto en un marcador con contador', async () => {
mountMap({ provenance: sameMuni })
await flushPromises()
const groupIcon = leaflet.L.divIcon.mock.calls.find(args => args[0].className === 'provenance-group-pin')
expect(groupIcon).toBeDefined()
expect(groupIcon[0].html).toContain('2')
expect(groupIcon[0].html).toContain('mdi-package-variant')
const groupMarker = leaflet.markers[1]
expect(groupMarker.bindPopup).toHaveBeenCalled()
expect(groupMarker.bindTooltip.mock.calls[0][0]).toContain('2 productos')
})
it('al hacer clic en el marcador agrupado despliega los productos internos', async () => {
mountMap({ provenance: sameMuni })
await flushPromises()
const popupEl = leaflet.markers[1].bindPopup.mock.calls[0][0]
expect(popupEl.textContent).toContain('Panela')
expect(popupEl.textContent).toContain('Arroz')
})
it('al hacer clic en un producto del popup agrupado muestra su detalle sin cerrar el popup ni propagar el clic al mapa', async () => {
mountMap({ provenance: sameMuni })
await flushPromises()
const groupMarker = leaflet.markers[1]
const popupEl = groupMarker.bindPopup.mock.calls[0][0]
let bubbled = false
popupEl.addEventListener('click', () => { bubbled = true })
const item = popupEl.querySelector('[data-test="map-popup-item-1"]')
item.dispatchEvent(new MouseEvent('click', { bubbles: true }))
expect(bubbled).toBe(false)
expect(groupMarker.closePopup).not.toHaveBeenCalled()
expect(groupMarker.setPopupContent).toHaveBeenCalledTimes(1)
const detailEl = groupMarker.setPopupContent.mock.calls[0][0]
expect(detailEl.textContent).toContain('Arroz')
expect(detailEl.textContent).toContain('B')
expect(groupMarker.openPopup).toHaveBeenCalled()
})
it('al pasar sobre la tienda dibuja una línea por cada producto agrupado', async () => {
mountMap({ provenance: sameMuni })
await flushPromises()
handlerOf(leaflet.markers[0], 'mouseover')()
expect(leaflet.L.polyline).toHaveBeenCalledTimes(2)
})
it('al pasar sobre el marcador agrupado dibuja las líneas de sus productos', async () => {
mountMap({ provenance: sameMuni })
await flushPromises()
handlerOf(leaflet.markers[1], 'mouseover')()
expect(leaflet.L.polyline).toHaveBeenCalledTimes(2)
expect(leaflet.polylines[0].points[0]).toEqual([4.631028, -74.461588])
})
it('muestra un recuadro con todos los productos, incluidos los sin geolocalización', async () => {
const withNoGeo = [
{
product: { id: 1, name: 'Panela regional', catalogue_images: ['http://localhost/media/panela.jpg'] },
suppliers: [{ supplier: { id: 5, name: 'Asociación La Mesa' }, municipality: { id: 7, name: 'Santa Bárbara', latitude: 6.23, longitude: -75.56 } }],
},
{
product: { id: 2, name: 'Café de montaña', catalogue_images: [] },
suppliers: [{ supplier: { id: 8, name: 'Caficultores del Valle' }, municipality: null }],
},
{
product: { id: 3, name: 'Miel pura', catalogue_images: [] },
suppliers: [],
},
]
const { wrapper } = mountMap({ provenance: withNoGeo })
await flushPromises()
const rows = wrapper.findAll('[data-test="map-product-row"]')
expect(rows).toHaveLength(3)
expect(rows.map(row => row.text().replace(/\s+/g, ' ').trim())).toEqual([
expect.stringContaining('Panela regional'),
expect.stringContaining('Café de montaña'),
expect.stringContaining('Miel pura'),
])
expect(rows[1].text()).toContain('Sin geolocalización')
expect(rows[2].text()).toContain('Sin geolocalización')
expect(rows[0].text()).toContain('Santa Bárbara')
})
it('al hacer clic en un producto con ubicación centra el mapa y abre su detalle en el marcador', async () => {
const { wrapper } = mountMap()
await flushPromises()
await wrapper.find('[data-test="map-product-row"]').trigger('click')
await nextTick()
expect(leaflet.map.flyTo).toHaveBeenCalledTimes(1)
expect(leaflet.map.flyTo.mock.calls[0][0]).toEqual([6.23, -75.56])
const marker = leaflet.markers[1]
expect(marker.setPopupContent).toHaveBeenCalled()
expect(marker.setPopupContent.mock.calls[0][0].textContent).toContain('Panela regional')
expect(marker.setPopupContent.mock.calls[0][0].textContent).toContain('Asociación La Mesa')
expect(marker.openPopup).toHaveBeenCalled()
expect(document.body.textContent).not.toContain('Proveedor')
})
it('al hacer clic en un producto sin geolocalización abre el diálogo con la información disponible sin centrar el mapa', async () => {
const withNoGeo = [
{
product: { id: 1, name: 'Panela regional', catalogue_images: [] },
suppliers: [{ supplier: { id: 5, name: 'Asociación La Mesa' }, municipality: { id: 7, name: 'Santa Bárbara', latitude: 6.23, longitude: -75.56 } }],
},
{
product: { id: 2, name: 'Café de montaña', catalogue_images: [] },
suppliers: [{ supplier: { id: 8, name: 'Caficultores del Valle' }, municipality: null }],
},
]
const { wrapper } = mountMap({ provenance: withNoGeo })
await flushPromises()
const noGeoRow = wrapper.findAll('[data-test="map-product-row"]').find(row => row.text().includes('Café de montaña'))
await noGeoRow.trigger('click')
await nextTick()
expect(leaflet.map.flyTo).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('Café de montaña')
expect(document.body.textContent).toContain('Caficultores del Valle')
expect(document.body.textContent).toContain('sin geolocalización')
})
it('al hacer clic en el botón de zoom general vuelve a encuadrar todos los marcadores', async () => {
const { wrapper } = mountMap()
await flushPromises()
leaflet.map.fitBounds.mockClear()
await wrapper.find('[data-test="map-reset-zoom"]').trigger('click')
expect(leaflet.map.fitBounds).toHaveBeenCalledTimes(1)
})
it('lee las coordenadas string del backend y da fondo visible al producto sin imagen', async () => {
const backendProvenance = [
{
product: { id: 110, name: 'Panela condimentada 150 grs', catalogue_images: [] },
suppliers: [
{
supplier: { id: 4, name: 'Asociación Agropecuaria La Mesa' },
organization: null,
municipality: { id: 2653, name: 'LA MESA', latitude: '4.6310280', longitude: '-74.4615880' },
department: { id: 91, name: 'Cundinamarca' },
country: { id: 3, name: 'Colombia', code: 'CO' },
},
],
},
]
mountMap({ provenance: backendProvenance })
await flushPromises()
expect(leaflet.L.map).toHaveBeenCalledTimes(1)
expect(leaflet.markers).toHaveLength(2)
expect(leaflet.markers[1].pos).toEqual([4.631028, -74.461588])
const productIcon = leaflet.L.divIcon.mock.calls.find(args => args[0].html.includes('provenance-product-fallback'))
expect(productIcon).toBeDefined()
})
})

View File

@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import ProvenanceRelationModal from '@/components/provenance/ProvenanceRelationModal.vue'
import vuetify from '@/plugins/vuetify'
const relation = {
supplier: { id: 5, name: 'Asociación La Mesa', contact_email: 'info@mesa.co' },
organization: { id: 3, name: 'Red de Economía Solidaria' },
municipality: { id: 7, name: 'Santa Bárbara' },
department: { id: 2, name: 'Antioquia' },
country: { id: 1, name: 'Colombia', code: 'CO' },
}
const product = {
id: 1,
name: 'Panela regional',
catalogue_images: ['http://localhost/media/panela.jpg'],
}
function mountModal (props = {}) {
return mount(ProvenanceRelationModal, {
props: { product, relation, visible: true, ...props },
global: { plugins: [vuetify] },
})
}
function bodyText () {
return document.body.textContent
}
describe('ProvenanceRelationModal', () => {
it('muestra el producto y todas las entidades de la relación', () => {
mountModal()
expect(bodyText()).toContain('Panela regional')
expect(bodyText()).toContain('Proveedor')
expect(bodyText()).toContain('Asociación La Mesa')
expect(bodyText()).toContain('Organización')
expect(bodyText()).toContain('Red de Economía Solidaria')
expect(bodyText()).toContain('Municipio')
expect(bodyText()).toContain('Santa Bárbara')
expect(bodyText()).toContain('Departamento')
expect(bodyText()).toContain('Antioquia')
expect(bodyText()).toContain('País')
expect(bodyText()).toContain('Colombia')
})
it('incluye los detalles de contacto del proveedor', () => {
mountModal()
expect(bodyText()).toContain('info@mesa.co')
})
it('muestra la imagen del producto cuando existe', () => {
mountModal()
expect(document.querySelector('img').getAttribute('src')).toBe(
'http://localhost/media/panela.jpg'
)
})
it('no muestra las entidades ausentes', () => {
mountModal({ relation: { supplier: { id: 5, name: 'Asociación La Mesa' } } })
expect(bodyText()).not.toContain('Organización')
expect(bodyText()).not.toContain('Municipio')
expect(bodyText()).not.toContain('Departamento')
expect(bodyText()).not.toContain('País')
})
it('muestra el producto y un aviso cuando no hay proveedor vinculado', () => {
mountModal({ relation: null })
expect(bodyText()).toContain('Panela regional')
expect(bodyText()).toContain('no se ha vinculado un proveedor')
})
it('no muestra contenido cuando no hay producto ni relación', () => {
mountModal({ relation: null, product: null })
expect(bodyText()).not.toContain('Proveedor')
expect(bodyText()).not.toContain('Producto')
})
it('indica que aún no tiene geolocalización cuando la relación no tiene municipio', () => {
mountModal({ relation: { supplier: { id: 5, name: 'Asociación La Mesa' } } })
expect(bodyText()).toContain('Asociación La Mesa')
expect(bodyText()).toContain('sin geolocalización')
})
it('no muestra el diálogo cuando visible es false', () => {
mountModal({ visible: false })
expect(bodyText()).not.toContain('Asociación La Mesa')
})
})

View File

@@ -0,0 +1,127 @@
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import ProvenanceGraph from '@/components/provenance/ProvenanceGraph.vue'
import ProvenanceSection from '@/components/provenance/ProvenanceSection.vue'
import vuetify from '@/plugins/vuetify'
vi.mock('@/components/graph/VisChart.vue', () => ({
default: {
name: 'VisChart',
template: '<div class="vis-chart-stub" />',
props: ['nodes', 'edges', 'height', 'options', 'minVerticalSpacing'],
},
}))
const provenance = [
{
product: { id: 1, name: 'Panela regional por Kg' },
suppliers: [
{
supplier: { id: 5, name: 'Asociación La Mesa' },
organization: { id: 3, name: 'Red de Economía Solidaria' },
municipality: { id: 7, name: 'La Mesa' },
department: { id: 2, name: 'Cundinamarca' },
country: { id: 1, name: 'Colombia', code: 'CO' },
},
],
},
]
function mountSection (props = {}) {
return mount(ProvenanceSection, {
props: { provenance, ...props },
global: { plugins: [vuetify], stubs: { ProvenanceMap: true } },
})
}
function mapWrapper (wrapper) {
return wrapper.findComponent({ name: 'ProvenanceMap' })
}
describe('ProvenanceSection', () => {
it('no renderiza nada cuando no hay provenance', () => {
const wrapper = mountSection({ provenance: null })
expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(false)
})
it('no renderiza nada cuando provenance es una lista vacía', () => {
const wrapper = mountSection({ provenance: [] })
expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(false)
})
it('muestra el título y oculta el gráfico por defecto', () => {
const wrapper = mountSection()
expect(wrapper.text()).toContain('Origen de los productos')
expect(wrapper.text()).not.toContain('historia')
expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(false)
})
it('muestra el gráfico al hacer clic en el título y lo oculta al volver a hacer clic', async () => {
const wrapper = mountSection()
await wrapper.find('[data-test="provenance-toggle"]').trigger('click')
expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(true)
await wrapper.find('[data-test="provenance-toggle"]').trigger('click')
expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(false)
})
it('también despliega y repliega con el botón de chevron', async () => {
const wrapper = mountSection()
const button = wrapper.find('[data-test="provenance-toggle-button"]')
expect(button.exists()).toBe(true)
await button.trigger('click')
expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(true)
await button.trigger('click')
expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(false)
})
it('pasa el provenance al gráfico al desplegarlo', async () => {
const wrapper = mountSection()
await wrapper.find('[data-test="provenance-toggle"]').trigger('click')
expect(wrapper.findComponent(ProvenanceGraph).props('provenance')).toStrictEqual(provenance)
})
it('oculta el mapa por defecto', () => {
const wrapper = mountSection()
expect(mapWrapper(wrapper).exists()).toBe(false)
})
it('muestra el mapa al hacer clic en su título y lo oculta al volver a hacer clic', async () => {
const wrapper = mountSection()
await wrapper.find('[data-test="map-toggle"]').trigger('click')
expect(mapWrapper(wrapper).exists()).toBe(true)
await wrapper.find('[data-test="map-toggle"]').trigger('click')
expect(mapWrapper(wrapper).exists()).toBe(false)
})
it('también despliega y repliega el mapa con el botón de chevron', async () => {
const wrapper = mountSection()
const button = wrapper.find('[data-test="map-toggle-button"]')
expect(button.exists()).toBe(true)
await button.trigger('click')
expect(mapWrapper(wrapper).exists()).toBe(true)
await button.trigger('click')
expect(mapWrapper(wrapper).exists()).toBe(false)
})
it('pasa el provenance al mapa al desplegarlo', async () => {
const wrapper = mountSection()
await wrapper.find('[data-test="map-toggle"]').trigger('click')
expect(mapWrapper(wrapper).props('provenance')).toStrictEqual(provenance)
})
})

View File

@@ -0,0 +1,114 @@
import { describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import GeographyManagement from '@/components/provenance/admin/GeographyManagement.vue'
import vuetify from '@/plugins/vuetify'
import { clickBody, setBodyInput } from './helpers'
const countries = [
{ id: 1, name: 'Colombia', code: 'CO' },
{ id: 2, name: 'Venezuela', code: 'VE' },
]
const departments = [
{ id: 2, name: 'Cundinamarca', country: 1, country_detail: { id: 1, name: 'Colombia', code: 'CO' } },
{ id: 3, name: 'Antioquia', country: 1, country_detail: { id: 1, name: 'Colombia', code: 'CO' } },
]
const municipalities = [
{ id: 7, name: 'La Mesa', department: 2, country: 1, department_detail: { id: 2, name: 'Cundinamarca' } },
{ id: 8, name: 'San Antonio', department: 3, country: 1, department_detail: { id: 3, name: 'Antioquia' } },
]
function mockApi () {
return {
getCountries: vi.fn().mockResolvedValue(countries),
createCountry: vi.fn().mockResolvedValue({}),
updateCountry: vi.fn().mockResolvedValue({}),
deleteCountry: vi.fn().mockResolvedValue({}),
getDepartments: vi.fn().mockResolvedValue(departments),
createDepartment: vi.fn().mockResolvedValue({}),
updateDepartment: vi.fn().mockResolvedValue({}),
deleteDepartment: vi.fn().mockResolvedValue({}),
getMunicipalities: vi.fn().mockResolvedValue(municipalities),
createMunicipality: vi.fn().mockResolvedValue({}),
updateMunicipality: vi.fn().mockResolvedValue({}),
deleteMunicipality: vi.fn().mockResolvedValue({}),
}
}
function mountComponent (api) {
return mount(GeographyManagement, {
global: { plugins: [vuetify], provide: { api } },
})
}
describe('GeographyManagement', () => {
it('muestra los países por defecto y permite crear uno', async () => {
const api = mockApi()
const wrapper = mountComponent(api)
await flushPromises()
expect(api.getCountries).toHaveBeenCalled()
expect(wrapper.text()).toContain('Colombia')
await wrapper.find('[data-testid="geo-country-create"]').trigger('click')
setBodyInput('[data-testid="geo-country-form-name"] input', 'Ecuador')
setBodyInput('[data-testid="geo-country-form-code"] input', 'EC')
clickBody('[data-testid="geo-country-save"]')
await flushPromises()
expect(api.createCountry).toHaveBeenCalledWith({ name: 'Ecuador', code: 'EC' })
expect(api.getCountries).toHaveBeenCalledTimes(2)
})
it('cambia a la pestaña de departamentos y crea uno', async () => {
const api = mockApi()
const wrapper = mountComponent(api)
await flushPromises()
await wrapper.find('[data-testid="geo-tab-departments"]').trigger('click')
await flushPromises()
expect(api.getDepartments).toHaveBeenCalled()
expect(wrapper.text()).toContain('Cundinamarca')
await wrapper.find('[data-testid="geo-department-create"]').trigger('click')
setBodyInput('[data-testid="geo-department-form-name"] input', 'Risaralda')
const countrySelect = wrapper.findAllComponents({ name: 'VSelect' })
.find(select => select.props('items').some(item => item.id === 2))
await countrySelect.vm.$emit('update:modelValue', 2)
clickBody('[data-testid="geo-department-save"]')
await flushPromises()
expect(api.createDepartment).toHaveBeenCalledWith(expect.objectContaining({
name: 'Risaralda',
country: 2,
}))
expect(api.getDepartments).toHaveBeenCalledTimes(2)
})
it('cambia a la pestaña de municipios y crea uno con departamento y país', async () => {
const api = mockApi()
const wrapper = mountComponent(api)
await flushPromises()
await wrapper.find('[data-testid="geo-tab-municipalities"]').trigger('click')
await flushPromises()
expect(api.getMunicipalities).toHaveBeenCalled()
expect(wrapper.text()).toContain('San Antonio')
await wrapper.find('[data-testid="geo-municipality-create"]').trigger('click')
setBodyInput('[data-testid="geo-municipality-form-name"] input', 'Pereira')
await wrapper.findAllComponents({ name: 'VAutocomplete' })[0].vm.$emit('update:modelValue', 3)
clickBody('[data-testid="geo-municipality-save"]')
await flushPromises()
expect(api.createMunicipality).toHaveBeenCalledWith(expect.objectContaining({
name: 'Pereira',
department: 3,
country: 1,
}))
expect(api.getMunicipalities).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,117 @@
import { describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import OrganizationsManagement from '@/components/provenance/admin/OrganizationsManagement.vue'
import vuetify from '@/plugins/vuetify'
import { clickBody, setBodyInput } from './helpers'
const organizations = [
{
id: 1,
name: 'Red de Economía Solidaria',
description: 'Red de organizaciones',
website: 'https://red.example.org',
contact_email: 'contacto@red.example.org',
contact_phone: '3001112233',
},
{
id: 2,
name: 'Cooperativa El Campo',
description: null,
website: null,
contact_email: null,
contact_phone: null,
},
]
function mockApi () {
return {
getOrganizations: vi.fn().mockResolvedValue(organizations),
createOrganization: vi.fn().mockResolvedValue({}),
updateOrganization: vi.fn().mockResolvedValue({}),
deleteOrganization: vi.fn().mockResolvedValue({}),
}
}
function mountComponent (api) {
return mount(OrganizationsManagement, {
global: { plugins: [vuetify], provide: { api } },
})
}
function fillForm () {
setBodyInput('[data-testid="org-form-name"] input', 'Fundación Montaña')
setBodyInput('[data-testid="org-form-description"] textarea', 'Fundación')
setBodyInput('[data-testid="org-form-website"] input', 'https://montana.example.org')
setBodyInput('[data-testid="org-form-email"] input', 'info@montana.example.org')
setBodyInput('[data-testid="org-form-phone"] input', '3109876543')
}
describe('OrganizationsManagement', () => {
it('carga y muestra las organizaciones', async () => {
const api = mockApi()
const wrapper = mountComponent(api)
await flushPromises()
expect(api.getOrganizations).toHaveBeenCalled()
expect(wrapper.text()).toContain('Red de Economía Solidaria')
expect(wrapper.text()).toContain('Cooperativa El Campo')
})
it('filtra las organizaciones por búsqueda', async () => {
const wrapper = mountComponent(mockApi())
await flushPromises()
await wrapper.find('[data-testid="org-search"] input').setValue('Cooperativa')
expect(wrapper.text()).toContain('Cooperativa El Campo')
expect(wrapper.text()).not.toContain('Red de Economía Solidaria')
})
it('crea una organización', async () => {
const api = mockApi()
const wrapper = mountComponent(api)
await flushPromises()
await wrapper.find('[data-testid="org-create"]').trigger('click')
fillForm()
clickBody('[data-testid="org-save"]')
await flushPromises()
expect(api.createOrganization).toHaveBeenCalledWith({
name: 'Fundación Montaña',
description: 'Fundación',
website: 'https://montana.example.org',
contact_email: 'info@montana.example.org',
contact_phone: '3109876543',
})
expect(api.getOrganizations).toHaveBeenCalledTimes(2)
})
it('edita una organización', async () => {
const api = mockApi()
const wrapper = mountComponent(api)
await flushPromises()
await wrapper.find('[data-testid="org-edit-1"]').trigger('click')
setBodyInput('[data-testid="org-form-name"] input', 'Red de Economía Solidaria Colombia')
clickBody('[data-testid="org-save"]')
await flushPromises()
expect(api.updateOrganization).toHaveBeenCalledWith(1, expect.objectContaining({
name: 'Red de Economía Solidaria Colombia',
}))
})
it('elimina una organización', async () => {
const api = mockApi()
const wrapper = mountComponent(api)
await flushPromises()
await wrapper.find('[data-testid="org-delete-2"]').trigger('click')
clickBody('[data-testid="org-confirm-delete"]')
await flushPromises()
expect(api.deleteOrganization).toHaveBeenCalledWith(2)
expect(api.getOrganizations).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,72 @@
import { describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import SupplierLinkDialog from '@/components/provenance/admin/SupplierLinkDialog.vue'
import vuetify from '@/plugins/vuetify'
import { clickBody } from './helpers'
const suppliers = [
{ id: 5, name: 'Asociación Agropecuaria La Mesa' },
{ id: 9, name: 'Finca El Paraíso' },
{ id: 12, name: 'Cooperativa del Valle' },
]
function mockApi () {
return {
getSuppliers: vi.fn().mockResolvedValue(suppliers),
getProduct: vi.fn().mockResolvedValue({ id: 1, name: 'Panela regional por Kg', suppliers: [5] }),
updateProduct: vi.fn().mockResolvedValue({}),
}
}
function mountDialog (api, props = {}) {
return mount(SupplierLinkDialog, {
props: { visible: true, product: { id: 1, name: 'Panela regional por Kg' }, ...props },
global: { plugins: [vuetify], provide: { api } },
})
}
describe('SupplierLinkDialog', () => {
it('carga los proveedores y los suppliers actuales del producto al abrir', async () => {
const api = mockApi()
mountDialog(api)
await flushPromises()
expect(api.getSuppliers).toHaveBeenCalled()
expect(api.getProduct).toHaveBeenCalledWith(1)
expect(document.body.textContent).toContain('Panela regional por Kg')
})
it('permite cambiar la selección y guarda los proveedores', async () => {
const api = mockApi()
const wrapper = mountDialog(api)
await flushPromises()
const autocomplete = wrapper.findAllComponents({ name: 'VAutocomplete' })
.find(component => component.props('items').some(item => item.id === 5))
await autocomplete.vm.$emit('update:modelValue', [5, 9])
expect(document.body.textContent).toContain('Seleccionados (2)')
clickBody('[data-testid="supplier-link-save"]')
await flushPromises()
expect(api.updateProduct).toHaveBeenCalledWith(1, { suppliers: [5, 9] })
})
it('mantiene los proveedores seleccionados aunque el filtro no los incluya', async () => {
const api = mockApi()
const wrapper = mountDialog(api)
await flushPromises()
const autocomplete = wrapper.findAllComponents({ name: 'VAutocomplete' })
.find(component => component.props('items').some(item => item.id === 5))
await autocomplete.vm.$emit('update:modelValue', [5, 12])
await autocomplete.vm.$emit('update:search', 'Finca')
const items = autocomplete.props('items')
expect(items.some(item => item.id === 5)).toBe(true)
expect(items.some(item => item.id === 12)).toBe(true)
expect(items.some(item => item.id === 9)).toBe(true)
expect(items.some(item => item.id === 7)).toBe(false)
})
})

Some files were not shown because too many files have changed in this diff Show More