99 Commits

Author SHA1 Message Date
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
1c294a5ba6 fix: Modify tasks service name 2025-03-29 16:13:28 -05:00
58 changed files with 6319 additions and 1037 deletions

2
.env.example Normal file
View File

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

5
.gitignore vendored
View File

@@ -5,6 +5,7 @@ node_modules
# local env files
.env.local
.env.*.local
.env
# Log files
npm-debug.log*
@@ -25,3 +26,7 @@ pnpm-debug.log*
/.eslintrc-auto-import.json
/.eslintrc.js
/.vite/
# Deploy environment files
deploy/.env.staging
deploy/.env.production

114
AGENTS.md Normal file
View File

@@ -0,0 +1,114 @@
# 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
├── layouts/ # Layouts de página
├── pages/ # Vistas (auto-routed desde文件名)
├── 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 requiere configuración manual en `router/index.js`
## Environment Variables
- `VITE_DJANGO_BASE_URL` - URL del backend Django
## Commands
```bash
npm run dev # Desarrollo (puerto 3000)
npm run build # Producción
npm run preview # Preview build
npm run lint # ESLint fix
```
## Common Issues
1. **Página en blanco:** Verificar que los componentes en `src/pages/*.vue` tengan import explícito
2. **Errores de lint:** Ejecutar `npm run lint`
## 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

View File

@@ -12,12 +12,12 @@ namespace :live do
desc 'monitorear salida'
task :tail do
compose('logs', '-f', 'django', compose: DOCKER_COMPOSE)
compose('logs', '-f', 'frontend', compose: DOCKER_COMPOSE)
end
desc 'monitorear salida'
task :tail_end do
compose('logs', '-f', '-n 50', 'django', compose: DOCKER_COMPOSE)
compose('logs', '-f', '-n 50', 'frontend', compose: DOCKER_COMPOSE)
end
desc 'iniciar entorno'

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

2
deploy/.env.example Normal file
View File

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

26
deploy/Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
# Stage 1: Build with Node.js
FROM node:20-alpine AS builder
ARG VITE_DJANGO_BASE_URL
ARG VITE_API_IMPLEMENTATION
ENV VITE_DJANGO_BASE_URL=$VITE_DJANGO_BASE_URL
ENV VITE_API_IMPLEMENTATION=$VITE_API_IMPLEMENTATION
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;"]

81
deploy/build.sh Executable file
View File

@@ -0,0 +1,81 @@
#!/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"
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/
ports:
- "7001:3000"
environment:
- VITE_DJANGO_BASE_URL=http://localhost:7000
- VITE_API_IMPLEMENTATION=django

636
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@
},
"dependencies": {
"@mdi/font": "7.4.47",
"axios": "^1.13.5",
"core-js": "^3.37.1",
"roboto-fontface": "*",
"vee-validate": "^4.14.6",

View File

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

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

View File

@@ -0,0 +1,383 @@
<template>
<v-container fluid>
<v-row align="center">
<v-col cols="12" md="6">
<h1 class="text-h4">Imágenes de Catálogo</h1>
</v-col>
<v-col cols="12" md="6" class="text-md-right">
<v-btn
color="primary"
variant="elevated"
prepend-icon="mdi-plus"
@click="openCreateDialog"
>
Agregar Imagen
</v-btn>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-card>
<v-data-table
:headers="headers"
:items="images"
:loading="loading"
density="compact"
item-value="id"
items-per-page="25"
:items-per-page-options="[10, 25, 50, 100]"
>
<template #item.image="{ item }">
<v-img
:src="item.image"
max-width="60"
aspect-ratio="1"
cover
class="rounded"
/>
</template>
<template #item.product="{ item }">
{{ productMap[item.product] || `ID: ${item.product}` }}
</template>
<template #item.uploaded_at="{ item }">
{{ formatDate(item.uploaded_at) }}
</template>
<template #item.actions="{ item }">
<v-btn
icon
size="small"
variant="text"
@click="openEditDialog(item)"
>
<v-icon>mdi-pencil</v-icon>
</v-btn>
<v-btn
icon
size="small"
variant="text"
color="error"
@click="openDeleteDialog(item)"
>
<v-icon>mdi-delete</v-icon>
</v-btn>
</template>
<template #loading>
<v-skeleton-loader type="table-row@10" />
</template>
<template #no-data>
<v-alert type="info" variant="tonal" class="my-4">
No hay imágenes de catálogo
</v-alert>
</template>
</v-data-table>
</v-card>
</v-col>
</v-row>
<v-dialog v-model="dialog.show" max-width="500" persistent>
<v-card>
<v-card-title>
{{ dialog.isEdit ? 'Editar' : 'Agregar' }} Imagen de Catálogo
</v-card-title>
<v-card-text>
<v-form ref="formRef">
<v-select
v-model="form.product"
:items="productOptions"
label="Producto"
:rules="[(v) => !!v || 'Seleccione un producto']"
item-title="name"
item-value="id"
variant="outlined"
:disabled="dialog.isEdit"
class="mb-3"
/>
<v-file-input
ref="fileInputRef"
label="Imagen"
accept="image/*"
:rules="[(v) => !!v || 'Seleccione una imagen']"
variant="outlined"
prepend-icon="mdi-camera"
class="mb-3"
@update:model-value="onFileSelected"
/>
<v-img
v-if="form.preview"
:src="form.preview"
max-height="200"
contain
class="mb-3 rounded"
/>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="closeDialog">Cancelar</v-btn>
<v-btn
color="primary"
variant="elevated"
:loading="submitting"
:disabled="submitting"
@click="submitForm"
>
{{ dialog.isEdit ? 'Actualizar' : 'Agregar' }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="deleteDialog.show" max-width="400">
<v-card>
<v-card-title>Confirmar Eliminación</v-card-title>
<v-card-text>
¿Está seguro de eliminar esta imagen de catálogo?
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="deleteDialog.show = false">
Cancelar
</v-btn>
<v-btn
color="error"
variant="elevated"
:loading="deleting"
:disabled="deleting"
@click="confirmDelete"
>
Eliminar
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar
v-model="snackbar.show"
:color="snackbar.color"
:timeout="3000"
location="top"
>
{{ snackbar.message }}
<template #actions>
<v-btn variant="text" @click="snackbar.show = false">Cerrar</v-btn>
</template>
</v-snackbar>
</v-container>
</template>
<script setup>
import { ref, inject, onMounted, computed, watch } from "vue";
const api = inject("api");
const images = ref([]);
const products = ref([]);
const loading = ref(false);
const submitting = ref(false);
const deleting = ref(false);
const formRef = ref(null);
const snackbar = ref({ show: false, message: "", color: "success" });
const headers = [
{ title: "ID", key: "id", sortable: true },
{ title: "Producto", key: "product", sortable: true },
{ title: "Imagen", key: "image", sortable: false },
{ title: "Subida el", key: "uploaded_at", sortable: true },
{ title: "Acciones", key: "actions", sortable: false },
];
const dialog = ref({
show: false,
isEdit: false,
editingItem: null,
});
const fileInputRef = ref(null);
const selectedFile = ref(null);
const form = ref({
product: null,
preview: null,
});
const deleteDialog = ref({
show: false,
item: null,
});
let previewObjectUrl = null;
const productMap = computed(() => {
const map = {};
for (const p of products.value) {
map[p.id] = p.name;
}
return map;
});
const productOptions = computed(() => {
return products.value.map((p) => ({
name: p.name,
id: p.id,
}));
});
watch(
selectedFile,
(file) => {
if (previewObjectUrl) {
URL.revokeObjectURL(previewObjectUrl);
previewObjectUrl = null;
}
if (file) {
previewObjectUrl = URL.createObjectURL(file);
form.value.preview = previewObjectUrl;
} else if (dialog.value.isEdit && dialog.value.editingItem) {
form.value.preview = dialog.value.editingItem.image;
} else {
form.value.preview = null;
}
},
);
function onFileSelected(file) {
selectedFile.value = file;
}
function formatDate(dateStr) {
if (!dateStr) return "-";
const d = new Date(dateStr);
return d.toLocaleString("es-CO");
}
async function loadImages() {
loading.value = true;
try {
const data = await api.getCatalogueImages();
images.value = data;
} catch (error) {
console.error("Error al cargar imágenes:", error);
showSnackbar("Error al cargar imágenes", "error");
} finally {
loading.value = false;
}
}
async function loadProducts() {
try {
const data = await api.getProducts("all");
products.value = data;
} catch (error) {
console.error("Error al cargar productos:", error);
}
}
function openCreateDialog() {
dialog.value = { show: true, isEdit: false, editingItem: null };
selectedFile.value = null;
form.value = { product: null, preview: null };
}
function openEditDialog(item) {
dialog.value = { show: true, isEdit: true, editingItem: item };
selectedFile.value = null;
form.value = {
product: item.product,
preview: item.image,
};
}
function closeDialog() {
if (previewObjectUrl) {
URL.revokeObjectURL(previewObjectUrl);
previewObjectUrl = null;
}
selectedFile.value = null;
dialog.value.show = false;
dialog.value.isEdit = false;
dialog.value.editingItem = null;
form.value = { product: null, preview: null };
}
async function submitForm() {
const formComponent = formRef.value;
if (formComponent) {
const { valid } = await formComponent.validate();
if (!valid) return;
}
submitting.value = true;
try {
const fd = new FormData();
fd.append("product", form.value.product);
if (selectedFile.value) {
fd.append("image", selectedFile.value);
}
if (dialog.value.isEdit) {
await api.updateCatalogueImage(dialog.value.editingItem.id, fd);
showSnackbar("Imagen actualizada exitosamente", "success");
} else {
await api.createCatalogueImage(fd);
showSnackbar("Imagen agregada exitosamente", "success");
}
closeDialog();
await loadImages();
} catch (error) {
console.error("Error al guardar imagen:", error);
showSnackbar("Error al guardar imagen", "error");
} finally {
submitting.value = false;
}
}
function openDeleteDialog(item) {
deleteDialog.value = { show: true, item };
}
async function confirmDelete() {
deleting.value = true;
try {
await api.deleteCatalogueImage(deleteDialog.value.item.id);
showSnackbar("Imagen eliminada exitosamente", "success");
deleteDialog.value.show = false;
await loadImages();
} catch (error) {
console.error("Error al eliminar imagen:", error);
showSnackbar("Error al eliminar imagen", "error");
} finally {
deleting.value = false;
}
}
function showSnackbar(message, color) {
snackbar.value = { show: true, message, color };
}
onMounted(() => {
loadImages();
loadProducts();
});
</script>
<style scoped>
.text-md-right {
text-align: right;
}
@media (max-width: 960px) {
.text-md-right {
text-align: left;
}
}
</style>

View File

@@ -1,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>

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

@@ -0,0 +1,272 @@
<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">
<v-img
:src="logo"
alt="Don Confiao"
max-width="140"
class="mx-auto mb-4"
/>
<h1 class="text-h5 text-sm-h4 font-weight-bold text-center mb-1">
Iniciar Sesión
</h1>
<p class="text-body-2 text-medium-emphasis text-center mb-6">
Ingresa tus credenciales para acceder
</p>
<v-form ref="loginForm" @submit.prevent="onSubmit">
<v-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 logo from '@/assets/logo_colorful.png'
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,69 @@
<template>
<v-dialog v-model="show" max-width="400">
<v-card>
<v-card-title class="headline">Iniciar sesión</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>
<v-app-bar color="primary" prominent>
<v-app-bar color="primary" prominent app>
<v-app-bar-nav-icon variant="text" @click.stop="drawer = !drawer"></v-app-bar-nav-icon>
<v-toolbar-title>Menu</v-toolbar-title>
<v-spacer></v-spacer>
<template v-if="$vuetify.display.mdAndUp">
<v-btn icon="mdi-magnify" variant="text"></v-btn>
<v-btn icon="mdi-filter" variant="text"></v-btn>
</template>
<v-btn icon="mdi-dots-vertical" variant="text"></v-btn>
<v-btn
v-if="!isAuthenticated"
prepend-icon="mdi-login"
variant="text"
@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-navigation-drawer v-model="drawer"
:location="$vuetify.display.mobile ? 'bottom' : undefined"
@@ -17,22 +54,25 @@
nav
>
<v-list-item
v-for="item in menuItems"
v-for="item in filteredMenuItems"
:key="item.title"
:title="item.title"
:prepend-icon="item.icon"
@click="navigate(item.route)"
></v-list-item>
<v-list-item prepend-icon="mdi-cog" title="Administracion" @click="toggleAdminMenu()"></v-list-item>
<v-list-item>
<v-list v-if="showAdminMenu">
<v-list-item prepend-icon="mdi-cog" title="Administracion" @click="toggleAdminMenu()" v-if="isAuthenticated && isAdmin"></v-list-item>
<v-list-item v-if="isAuthenticated && isAdmin && showAdminMenu">
<v-list>
<template v-for="(item, index) in menuAdminItems" :key="index">
<v-divider v-if="item.divider"></v-divider>
<v-list-subheader v-else-if="item.header">{{ item.header }}</v-list-subheader>
<v-list-item
v-for="item in menuAdminItems"
:key="item.title"
v-else
:title="item.title"
:prepend-icon="item.icon"
@click="navigateAdmin(item.route)"
></v-list-item>
</template>
</v-list>
</v-list-item>
</v-list>
@@ -40,29 +80,83 @@
</template>
<script>
import AuthService from '@/services/auth';
import { useAuthStore } from '@/stores/auth';
import { inject } from 'vue';
export default {
name: 'NavBar',
setup() {
const authStore = useAuthStore();
return { authStore };
},
data: () => ({
drawer: false,
group: null,
showAdminMenu: false,
isAuthenticated: false,
user: null,
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'},
],
menuAdminItems: [
{ title: 'Cuadrar tarro', route: '/cuadrar_tarro', icon: 'mdi-calculator'},
{ title: 'Cuadres de tarro', route: '/cuadres_de_tarro', icon: 'mdi-chart-bar'},
{ title: 'CSV Tryton', route: '/ventas_para_tryton', icon: 'mdi-file-table'},
{ title: 'Compra adm', route: '/compra_admin', icon: 'mdi-cart'},
{ title: 'Gestión de Productos', route: '/admin/products', icon: 'mdi-package-variant'},
{ title: 'Imágenes de Catálogo', route: '/admin/catalogue-images', icon: 'mdi-image-multiple'},
{ title: 'Ver Ventas por Catálogo', route: '/admin/catalog-sales', icon: 'mdi-cart-arrow-down'},
{ divider: true },
{ header: 'Sincronización Tryton' },
{ title: 'Importar Productos', route: '/sincronizar_productos_tryton', icon: 'mdi-download'},
{ title: 'Importar Clientes', route: '/sincronizar_clientes_tryton', icon: 'mdi-download'},
{ title: 'Exportar Ventas', route: '/sincronizar_ventas_tryton', icon: 'mdi-upload'},
{ title: 'Exportar Ventas Catálogo', route: '/sincronizar_catalog_sales_tryton', icon: 'mdi-upload'}
],
}),
computed: {
isAdmin() {
return this.user?.role === 'administrator';
},
filteredMenuItems() {
if (this.user?.role === 'publico') {
return this.menuItems.filter(item => item.route !== '/comprar');
}
return this.menuItems;
},
},
mounted() {
this.checkAuth();
if (this.isAuthenticated) {
this.fetchUser();
}
},
watch: {
group () {
this.drawer = false
},
$route() {
this.checkAuth();
if (this.isAuthenticated && !this.user) {
this.fetchUser();
}
},
},
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) {
this.$router.push(route);
},
@@ -73,6 +167,14 @@
toggleAdminMenu() {
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,262 @@
<template>
<v-container fluid>
<!-- Header + Filtros -->
<v-row align="center">
<v-col cols="12" md="6">
<h1 class="text-h4">Gestión de Productos</h1>
</v-col>
<v-col cols="12" md="6" class="text-md-right">
<!-- Chips de filtro -->
<v-chip-group v-model="activeFilter" mandatory color="primary">
<v-chip value="false">Inactivos</v-chip>
<v-chip value="true">Activos</v-chip>
<v-chip value="all">Todos</v-chip>
</v-chip-group>
</v-col>
</v-row>
<!-- Barra de búsqueda -->
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="searchQuery"
label="Buscar por nombre"
prepend-inner-icon="mdi-magnify"
clearable
density="compact"
variant="outlined"
hide-details
></v-text-field>
</v-col>
</v-row>
<!-- Barra de acciones por lote -->
<v-row v-if="selected.length > 0">
<v-col cols="12">
<v-alert type="info" variant="tonal" border="start">
<v-row align="center">
<v-col cols="12" md="6">
<strong>{{ selected.length }} producto(s) seleccionado(s)</strong>
</v-col>
<v-col cols="12" md="6" class="text-md-right">
<!-- Botón Activar: solo visible en filtro "Inactivos" -->
<v-btn
v-if="activeFilter === 'false'"
@click="activateSelected"
color="success"
variant="elevated"
prepend-icon="mdi-check-circle"
:disabled="loading"
>
Activar seleccionados
</v-btn>
<!-- Botón Desactivar: solo visible en filtro "Activos" -->
<v-btn
v-if="activeFilter === 'true'"
@click="deactivateSelected"
color="error"
variant="elevated"
prepend-icon="mdi-close-circle"
:disabled="loading"
>
Desactivar seleccionados
</v-btn>
<!-- Ambos botones visibles en filtro "Todos" -->
<template v-if="activeFilter === 'all'">
<v-btn
@click="activateSelected"
color="success"
variant="elevated"
prepend-icon="mdi-check-circle"
class="mr-2"
:disabled="loading"
>
Activar seleccionados
</v-btn>
<v-btn
@click="deactivateSelected"
color="error"
variant="elevated"
prepend-icon="mdi-close-circle"
:disabled="loading"
>
Desactivar seleccionados
</v-btn>
</template>
</v-col>
</v-row>
</v-alert>
</v-col>
</v-row>
<!-- Tabla de productos -->
<v-row>
<v-col cols="12">
<v-card>
<v-data-table
v-model="selected"
:headers="headers"
:items="filteredProducts"
:loading="loading"
show-select
density="compact"
item-value="id"
items-per-page="25"
:items-per-page-options="[10, 25, 50, 100]"
>
<!-- Slot para columna de precio (formato) -->
<template #item.price="{ item }">
${{ Number(item.price).toLocaleString("es-CO") }}
</template>
<!-- Slot para columna de estado -->
<template #item.active="{ item }">
<v-chip
:color="item.active ? 'success' : 'error'"
size="small"
variant="flat"
>
{{ item.active ? "Activo" : "Inactivo" }}
</v-chip>
</template>
<!-- Loading state -->
<template #loading>
<v-skeleton-loader type="table-row@10"></v-skeleton-loader>
</template>
<!-- No data state -->
<template #no-data>
<v-alert type="info" variant="tonal" class="my-4">
No hay productos para mostrar
</v-alert>
</template>
</v-data-table>
</v-card>
</v-col>
</v-row>
<!-- Snackbar de feedback -->
<v-snackbar
v-model="snackbar.show"
:color="snackbar.color"
:timeout="3000"
location="top"
>
{{ snackbar.message }}
<template #actions>
<v-btn variant="text" @click="snackbar.show = false"> Cerrar </v-btn>
</template>
</v-snackbar>
</v-container>
</template>
<script setup>
import { ref, watch, inject, onMounted, computed } from "vue";
// Estado
const api = inject("api");
const activeFilter = ref("false");
const products = ref([]);
const selected = ref([]);
const loading = ref(false);
const snackbar = ref({ show: false, message: "", color: "success" });
const searchQuery = ref("");
// Headers de la tabla
const headers = [
{ title: "ID", key: "id", sortable: true },
{ title: "Nombre", key: "name", sortable: true },
{ title: "Precio", key: "price", sortable: true },
{ title: "Estado", key: "active", sortable: true },
];
// Computed - Productos filtrados por búsqueda
const filteredProducts = computed(() => {
if (!searchQuery.value) {
return products.value;
}
const query = searchQuery.value.toLowerCase().trim();
return products.value.filter((product) =>
product.name.toLowerCase().includes(query),
);
});
// Métodos
async function loadProducts() {
loading.value = true;
try {
const data = await api.getProducts(activeFilter.value);
products.value = data;
} catch (error) {
console.error("Error al cargar productos:", error);
showSnackbar("Error al cargar productos", "error");
} finally {
loading.value = false;
}
}
async function activateSelected() {
await updateSelectedStatus(true);
}
async function deactivateSelected() {
await updateSelectedStatus(false);
}
async function updateSelectedStatus(active) {
loading.value = true;
try {
// Actualizar productos en paralelo
await Promise.all(
selected.value.map((id) => api.updateProduct(id, { active })),
);
const action = active ? "activado(s)" : "desactivado(s)";
showSnackbar(
`${selected.value.length} producto(s) ${action} exitosamente`,
"success",
);
// Limpiar selección y recargar
selected.value = [];
await loadProducts();
} catch (error) {
console.error("Error al actualizar productos:", error);
showSnackbar("Error al actualizar productos", "error");
} finally {
loading.value = false;
}
}
function showSnackbar(message, color) {
snackbar.value = { show: true, message, color };
}
// Watchers
watch(activeFilter, () => {
selected.value = [];
searchQuery.value = "";
loadProducts();
});
// Inicialización
onMounted(() => {
loadProducts();
});
</script>
<style scoped>
.text-md-right {
text-align: right;
}
@media (max-width: 960px) {
.text-md-right {
text-align: left;
}
}
</style>

View File

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

View File

@@ -52,6 +52,7 @@
<td>{{ item.id }}</td>
<td>{{ item.date }}</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>
</tr>
</template>
@@ -71,7 +72,8 @@
name: 'SummaryPurchase',
props: {
msg: String,
id: Number
id: Number,
type: String
},
data () {
return {
@@ -87,6 +89,7 @@
{title: 'Compra', value: 'id'},
{title: 'Fecha', value: 'date'},
{title: 'Nombre', value: 'customer.name'},
{title: 'Método de pago', value: 'payment_method'},
{title: 'Valor', value: ''},
],
};
@@ -100,7 +103,11 @@
},
methods: {
fetchPurchase(purchaseId) {
this.api.getSummaryPurchase(purchaseId)
const apiMethod = this.type === 'catalog'
? this.api.getSummaryCatalogPurchase(purchaseId)
: this.api.getSummaryPurchase(purchaseId);
apiMethod
.then(data => {
this.purchase = data;
})

View File

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

View File

@@ -0,0 +1,450 @@
<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 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">
<div class="quantity-controls">
<v-btn
icon
size="small"
variant="tonal"
color="error"
class="qty-btn"
@click="decrease(product)"
:disabled="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"
:disabled="disabled"
@input="handleQuantityChange"
/>
<v-btn
icon
size="small"
variant="tonal"
color="success"
class="qty-btn"
@click="handleIncrease"
:disabled="disabled"
>
<v-icon size="20">mdi-plus</v-icon>
</v-btn>
</div>
</v-card-actions>
</v-card>
</template>
<script>
export default {
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
============================================ */
/* 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="$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 Compra
</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

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

View File

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,781 @@
<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>
<!-- Controles de paginación superiores -->
<PaginationControls
v-if="filteredItems.length > 0"
:current-page="currentPage"
:total-pages="totalPages"
:items-per-page="itemsPerPage"
:items-per-page-options="itemsPerPageOptions"
:pagination-info="paginationInfo"
:total-visible-pages="totalVisiblePages"
@page-change="handlePageChange"
@items-per-page-change="handleItemsPerPageChange"
position="top"
/>
<!-- Grid de productos paginados -->
<v-row class="product-grid" v-if="paginatedItems.length > 0">
<v-col
v-for="item in paginatedItems"
:key="item.id"
cols="12"
sm="6"
md="6"
lg="4"
class="product-col"
>
<Card
:product="item"
:increase="increase"
:decrease="decrease"
:currency="currency"
:updateQuantity="updateQuantity"
:disabled="!isAuthenticated"
@add-to-cart="addToCart"
/>
</v-col>
</v-row>
<!-- Mensaje cuando no hay productos -->
<v-alert
v-if="items.length === 0"
type="info"
class="my-4"
variant="tonal"
>
No hay productos disponibles en el catálogo
</v-alert>
<v-alert
v-else-if="filteredItems.length === 0"
type="warning"
class="my-4"
variant="tonal"
>
No se encontraron productos con ese nombre
</v-alert>
<!-- Controles de paginación inferiores -->
<PaginationControls
v-if="filteredItems.length > 0"
:current-page="currentPage"
:total-pages="totalPages"
:items-per-page="itemsPerPage"
:items-per-page-options="itemsPerPageOptions"
:pagination-info="paginationInfo"
:total-visible-pages="totalVisiblePages"
@page-change="handlePageChange"
@items-per-page-change="handleItemsPerPageChange"
position="bottom"
/>
</v-col>
<v-col cols="12" md="2" lg="3">
<div
class="cart-sidebar"
:class="{ 'cart-is-collapsed': cartCollapsed && isMobile }"
>
<Cart
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 class="login-prompt-card pa-4 text-center">
<v-icon size="48" color="primary" class="mb-2">mdi-cart-lock</v-icon>
<p class="text-body-2 text-medium-emphasis mb-3">
Inicia sesión para agregar productos al carrito y finalizar tu compra
</p>
<v-btn
color="primary"
variant="tonal"
prepend-icon="mdi-login"
@click="openLoginDialog"
>
Iniciar Sesión
</v-btn>
</v-card>
</div>
</v-col>
</v-row>
<!-- Modal 1: Confirmación de productos -->
<v-dialog v-model="checkoutDialog" max-width="600" persistent>
<v-card>
<v-card-title class="headline">Confirmar Compra</v-card-title>
<v-card-text>
<v-list v-if="cartItems.length > 0" class="product-list-scroll">
<v-list-item v-for="item in cartItems" :key="item.id">
<div class="d-flex justify-space-between align-center">
<div>
<div class="font-weight-medium">{{ item.name }}</div>
<div class="text-caption text-grey">
{{ currency(item.price) }} x {{ item.quantity }}
</div>
</div>
<div class="font-weight-bold text-success">
{{ currency(item.price * item.quantity) }}
</div>
</div>
</v-list-item>
</v-list>
<v-divider class="my-3"></v-divider>
<div class="d-flex justify-space-between text-h6">
<span class="font-weight-bold">Total</span>
<span class="font-weight-bold text-success">{{
currency(cartStore.cartTotal)
}}</span>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn variant="text" @click="checkoutDialog = false">Cancelar</v-btn>
<v-btn color="primary" variant="elevated" @click="onConfirmCheckout"
>Confirmar</v-btn
>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Modal 2: Datos personales -->
<v-dialog v-model="personalDataDialog" max-width="500" persistent>
<v-card>
<v-card-title class="headline">Datos de Contacto</v-card-title>
<v-card-text>
<v-form ref="personalForm">
<v-text-field
v-model="customerName"
label="Nombre completo"
:rules="[rules.required]"
required
variant="outlined"
class="mb-3"
></v-text-field>
<v-text-field
v-model="customerAddress"
label="Dirección"
variant="outlined"
class="mb-3"
></v-text-field>
<v-text-field
v-model="customerPhone"
label="Teléfono"
variant="outlined"
class="mb-3"
></v-text-field>
<v-select
v-model="pickupMethod"
:items="pickupOptions"
item-title="text"
item-value="value"
label="Recogida"
:rules="[rules.required]"
required
variant="outlined"
></v-select>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn variant="text" @click="cancelPurchase">Cancelar</v-btn>
<v-btn
color="primary"
variant="elevated"
@click="onSubmitPurchase"
:loading="isSubmitting"
:disabled="isSubmitting"
>
Finalizar Compra
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 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); // Cambiado a false para que inicie expandido en desktop
const windowWidth = ref(window.innerWidth);
const isMobile = computed(() => windowWidth.value < 960); // Cambiado de 680 a 960
const updateWindowWidth = () => {
windowWidth.value = window.innerWidth;
};
onMounted(() => {
window.addEventListener("resize", updateWindowWidth);
});
onUnmounted(() => {
window.removeEventListener("resize", updateWindowWidth);
});
return {
cartStore,
authStore,
cartCollapsed,
isMobile,
windowWidth,
};
},
data() {
return {
api: inject("api"),
items: [],
searchQuery: "",
// Paginación
currentPage: 1,
itemsPerPage: 20,
itemsPerPageOptions: [10, 20, 50, 100],
checkoutDialog: false,
personalDataDialog: false,
customerName: "",
customerAddress: "",
customerPhone: "",
pickupMethod: "STORE",
pickupOptions: [
{ text: "En Sitio", value: "STORE" },
{ text: "Domicilio", value: "DELIVERY" },
],
isSubmitting: false,
rules: {
required: (value) => !!value || "Requerido.",
},
};
},
computed: {
cartItems: {
get() {
return this.cartStore.items;
},
set(value) {
this.cartStore.items = value;
},
},
cartCount() {
return this.cartStore.cartCount;
},
isAuthenticated() {
return this.authStore.isAuthenticated;
},
// 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();
},
onLoginSuccess() {
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;
}
/* ============================================
MODALES
============================================ */
.product-list-scroll {
max-height: 400px;
overflow-y: auto;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,163 @@
<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="formatItems(result.failed_parties)"
density="compact"
:headers="[{ title: 'ID', key: 'id' }]"
></v-data-table>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card elevation="2">
<v-card-title class="bg-success text-white"> Creados ({{ result.created_customers?.length || 0 }})</v-card-title>
<v-card-text>
<v-data-table
:items="formatItems(result.created_customers)"
density="compact"
:headers="[{ title: 'ID', key: 'id' }]"
></v-data-table>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card elevation="2">
<v-card-title class="bg-warning">🔄 Actualizados ({{ result.updated_customers?.length || 0 }})</v-card-title>
<v-card-text>
<v-data-table
:items="formatItems(result.updated_customers)"
density="compact"
:headers="[{ title: 'ID', key: 'id' }]"
></v-data-table>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card elevation="2">
<v-card-title class="bg-grey-lighten-1"> Sin cambios ({{ result.untouched_customers?.length || 0 }})</v-card-title>
<v-card-text>
<v-data-table
:items="formatItems(result.untouched_customers)"
density="compact"
:headers="[{ title: 'ID', key: 'id' }]"
></v-data-table>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card elevation="2">
<v-card-title class="bg-info text-white">🔍 Verificados ({{ result.checked_tryton_parties?.length || 0 }})</v-card-title>
<v-card-text>
<v-data-table
:items="formatItems(result.checked_tryton_parties)"
density="compact"
:headers="[{ title: 'ID', key: 'id' }]"
></v-data-table>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" class="text-center mt-4">
<v-btn color="primary" @click="$router.push('/')">
Volver al inicio
</v-btn>
</v-col>
</v-row>
</v-container>
</template>
<script>
import { useAuthStore } from '@/stores/auth';
import { inject } from 'vue';
export default {
name: 'CustomersFromTryton',
setup() {
const authStore = useAuthStore();
return { authStore };
},
data() {
return {
api: inject('api'),
loading: false,
result: null,
}
},
methods: {
formatItems(ids) {
if (!ids || ids.length === 0) return [];
return ids.map(id => ({ id }));
},
startSync() {
this.loading = true;
this.api.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,150 @@
<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="formatItems(result.failed_products)"
density="compact"
:headers="[{ title: 'ID', key: 'id' }]"
></v-data-table>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card elevation="2">
<v-card-title class="bg-success text-white"> Creados ({{ result.created_products?.length || 0 }})</v-card-title>
<v-card-text>
<v-data-table
:items="formatItems(result.created_products)"
density="compact"
:headers="[{ title: 'ID', key: 'id' }]"
></v-data-table>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card elevation="2">
<v-card-title class="bg-info text-white">🔄 Actualizados ({{ result.updated_products?.length || 0 }})</v-card-title>
<v-card-text>
<v-data-table
:items="formatItems(result.updated_products)"
density="compact"
:headers="[{ title: 'ID', key: 'id' }]"
></v-data-table>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card elevation="2">
<v-card-title class="bg-grey-lighten-1"> Sin cambios ({{ result.untouched_products?.length || 0 }})</v-card-title>
<v-card-text>
<v-data-table
:items="formatItems(result.untouched_products)"
density="compact"
:headers="[{ title: 'ID', key: 'id' }]"
></v-data-table>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" class="text-center mt-4">
<v-btn color="primary" @click="$router.push('/')">
Volver al inicio
</v-btn>
</v-col>
</v-row>
</v-container>
</template>
<script>
import { useAuthStore } from '@/stores/auth';
import { inject } from 'vue';
export default {
name: 'ProductsFromTryton',
setup() {
const authStore = useAuthStore();
return { authStore };
},
data() {
return {
api: inject('api'),
loading: false,
result: null,
}
},
methods: {
formatItems(ids) {
if (!ids || ids.length === 0) return [];
return ids.map(id => ({ id }));
},
startSync() {
this.loading = true;
this.api.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,120 @@
<template>
<v-container v-if="authStore.isAdmin" class="fill-height">
<v-row v-if="!result && !loading" justify="center">
<v-col cols="12" md="8">
<v-card class="pa-6" elevation="4">
<v-card-title class="text-h5 font-weight-bold text-center">
🔄 Sincronización de Ventas
</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="formatItems(result.failed)"
density="compact"
:headers="[{ title: 'ID', key: 'id' }]"
></v-data-table>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card elevation="2">
<v-card-title class="bg-success text-white"> Exitosos ({{ result.successful?.length || 0 }})</v-card-title>
<v-card-text>
<v-data-table
:items="formatItems(result.successful)"
density="compact"
:headers="[{ title: 'ID', key: 'id' }]"
></v-data-table>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" class="text-center mt-4">
<v-btn color="primary" @click="$router.push('/')">
Volver al inicio
</v-btn>
</v-col>
</v-row>
</v-container>
</template>
<script>
import { useAuthStore } from '@/stores/auth';
import { inject } from 'vue';
export default {
name: 'SalesToTryton',
setup() {
const authStore = useAuthStore();
return { authStore };
},
data() {
return {
api: inject('api'),
loading: false,
result: null,
}
},
methods: {
formatItems(ids) {
if (!ids || ids.length === 0) return [];
return ids.map(id => ({ id }));
},
startSync() {
this.loading = true;
this.api.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>
<SummaryPurchase :id="$route.query.id"/>
<SummaryPurchase :id="$route.query.id" :type="$route.query.type"/>
</template>
<script setup>
//
definePage({
meta: {
requiresAuth: true
}
})
</script>

View File

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

View File

@@ -9,12 +9,46 @@
import { createRouter, createWebHistory } from 'vue-router/auto'
import { setupLayouts } from 'virtual:generated-layouts'
import { routes } from 'vue-router/auto-routes'
import { useAuthStore } from '@/stores/auth'
const PUBLICO_RESTRICTED = ['/comprar']
const ADMIN_ROUTES = [
'/sincronizar_clientes_tryton',
'/sincronizar_ventas_tryton',
'/sincronizar_productos_tryton',
'/ventas_para_tryton',
'/cuadres_de_tarro',
'/compra_admin',
'/cuadrar_tarro',
'/admin/products',
'/admin/catalog-sales',
'/admin/catalogue-images',
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
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
router.onError((err, to) => {
if (err?.message?.includes?.('Failed to fetch dynamically imported module')) {

View File

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

View File

@@ -1,5 +1,5 @@
class Api {
constructor (apiImplementation) {
constructor(apiImplementation) {
this.apiImplementation = apiImplementation;
}
@@ -7,8 +7,12 @@ class Api {
return this.apiImplementation.getCustomers();
}
getProducts() {
return this.apiImplementation.getProducts();
getProducts(active = 'all') {
return this.apiImplementation.getProducts(active);
}
updateProduct(productId, data) {
return this.apiImplementation.updateProduct(productId, data);
}
getPaymentMethods() {
@@ -19,11 +23,15 @@ class Api {
return this.apiImplementation.getSummaryPurchase(purchaseId);
}
getSummaryCatalogPurchase(purchaseId) {
return this.apiImplementation.getSummaryCatalogPurchase(purchaseId);
}
getPurchasesForReconciliation() {
return this.apiImplementation.getPurchasesForReconciliation();
}
getListReconcliations(page=1, itemsPerPage=10) {
getListReconcliations(page = 1, itemsPerPage = 10) {
return this.apiImplementation.getListReconcliations(page, itemsPerPage);
}
@@ -31,14 +39,14 @@ class Api {
return this.apiImplementation.getReconciliation(reconciliationId);
}
isValidAdminCode(code) {
return this.apiImplementation.isValidAdminCode(code);
}
createPurchase(purchase) {
return this.apiImplementation.createPurchase(purchase);
}
createCatalogPurchase(purchase) {
return this.apiImplementation.createCatalogPurchase(purchase);
}
createReconciliationJar(reconciliation) {
return this.apiImplementation.createReconciliationJar(reconciliation);
}
@@ -50,6 +58,46 @@ class Api {
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);
}
}
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,159 @@
import AuthService from "@/services/auth";
import http from "@/services/http";
class DjangoApi {
constructor() {
this.base = process.env.DJANGO_BASE_URL;
this.base = import.meta.env.VITE_DJANGO_BASE_URL;
}
getRequest(url) {
return http.get(url).then((r) => r.data);
}
postRequest(url, payload) {
return http.post(url, payload).then((r) => r.data);
}
patchRequest(url, payload) {
return http.patch(url, payload).then((r) => r.data);
}
getCustomers() {
const url = this.base + '/don_confiao/api/customers/';
const url = this.base + "/don_confiao/api/customers/";
return this.getRequest(url);
}
getProducts() {
const url = this.base + '/don_confiao/api/products/';
getProducts(active = 'all') {
let url = this.base + "/don_confiao/api/products/";
// Agregar query parameter según filtro
if (active !== 'all') {
url += `?active=${active}`;
}
return this.getRequest(url);
}
updateProduct(productId, data) {
const url = this.base + `/don_confiao/api/products/${productId}/`;
return this.patchRequest(url, data);
}
getPaymentMethods() {
const url = this.base + '/don_confiao/payment_methods/all/select_format';
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}`;
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);
}
getPurchasesForReconciliation() {
const url = this.base + '/don_confiao/purchases/for_reconciliation';
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}`;
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}/`;
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) {
const url = this.base + "/don_confiao/api/sales/";
return this.postRequest(url, purchase);
}
createPurchase(purchase) {
const url = this.base + '/don_confiao/api/sales/';
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';
const url = this.base + "/don_confiao/reconciliate_jar";
return this.postRequest(url, reconciliation);
}
createCustomer(customer) {
const url = this.base + '/don_confiao/api/customers/';
const url = this.base + "/don_confiao/api/customers/";
return this.postRequest(url, customer);
}
getCSVForTryton() {
const url = this.base + '/don_confiao/api/sales/for_tryton';
const url = this.base + "/don_confiao/api/sales/for_tryton";
return this.getRequest(url);
}
getRequest(url) {
return new Promise ((resolve, reject) => {
fetch(url)
.then(response => response.json())
.then(data => {
resolve(data);
})
.catch(error => {
reject(error);
});
});
getProductsFromTryton() {
const url = this.base + "/don_confiao/api/importar_productos_de_tryton";
return this.postRequest(url, {});
}
postRequest(url, content) {
return new Promise((resolve, reject) => {
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(content)
})
.then(response => {
if (!response.ok) {
reject(new Error(`Error ${response.status}: ${response.statusText}`));
} else {
response.json().then(data => {
if (!data) {
reject(new Error('La respuesta no es un JSON válido'));
} else {
resolve(data);
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, {});
}
})
.catch(error => reject(error));
});
sendCatalogSalesToTryton() {
const url = this.base + "/don_confiao/api/enviar_catalog_sales_a_tryton";
return this.postRequest(url, {});
}
getCatalogSales() {
const url = this.base + "/don_confiao/api/catalog_sales/";
return this.getRequest(url);
}
getCurrentUser() {
const url = this.base + "/api/users/me/";
return this.getRequest(url);
}
getCatalogueImages() {
const url = this.base + "/don_confiao/api/catalogue_images/";
return this.getRequest(url);
}
createCatalogueImage(data) {
const url = this.base + "/don_confiao/api/catalogue_images/";
return http.post(url, data, {
headers: { 'Content-Type': undefined },
}).then((r) => r.data);
}
updateCatalogueImage(id, data) {
const url = this.base + `/don_confiao/api/catalogue_images/${id}/`;
return http.put(url, data, {
headers: { 'Content-Type': undefined },
}).then((r) => r.data);
}
deleteCatalogueImage(id) {
const url = this.base + `/don_confiao/api/catalogue_images/${id}/`;
return http.delete(url).then((r) => r.data);
}
}
export default DjangoApi;
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 = []
}
}
})

View File

@@ -39,6 +39,9 @@ export default defineConfig({
imports: [
'vue',
'vue-router',
{
'unplugin-vue-router': ['definePage'],
},
],
eslintrc: {
enabled: true,
@@ -47,12 +50,10 @@ export default defineConfig({
}),
],
define: { 'process.env': {
API_IMPLEMENTATION: 'django',
DJANGO_BASE_URL: 'http://localhost:7000'
} },
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
extensions: [
'.js',