diff --git a/package-lock.json b/package-lock.json
index 6e1802a..4f8b8d0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,6 +17,7 @@
"axios": "^1.8.1",
"laravel-echo": "^2.0.2",
"luxon": "^3.5.0",
+ "material-symbols": "^0.36.2",
"pdf-lib": "^1.17.1",
"pinia": "^3.0.1",
"pusher-js": "^8.4.0",
@@ -2976,6 +2977,12 @@
"@jridgewell/sourcemap-codec": "^1.5.0"
}
},
+ "node_modules/material-symbols": {
+ "version": "0.36.2",
+ "resolved": "https://registry.npmjs.org/material-symbols/-/material-symbols-0.36.2.tgz",
+ "integrity": "sha512-FbxzGgQSmAb53Kajv+jyqcZ3Ck0ebfTBSMwHkMoyThsbrINiJb5mzheoiFXA/9MGc3cIl9XbhW8JxPM5vEP6iA==",
+ "license": "Apache-2.0"
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
diff --git a/package.json b/package.json
index cc69e1c..740790c 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"axios": "^1.8.1",
"laravel-echo": "^2.0.2",
"luxon": "^3.5.0",
+ "material-symbols": "^0.36.2",
"pdf-lib": "^1.17.1",
"pinia": "^3.0.1",
"pusher-js": "^8.4.0",
diff --git a/src/components/Holos/Button/Button.vue b/src/components/Holos/Button/Button.vue
index 362eba4..33bf969 100644
--- a/src/components/Holos/Button/Button.vue
+++ b/src/components/Holos/Button/Button.vue
@@ -23,7 +23,6 @@ interface Props {
loading?: boolean;
fullWidth?: boolean;
iconOnly?: boolean;
- asLink?: boolean; // Nueva prop para comportamiento de link
}
const props = withDefaults(defineProps(), {
@@ -35,7 +34,6 @@ const props = withDefaults(defineProps(), {
loading: false,
fullWidth: false,
iconOnly: false,
- asLink: true, // Por defecto no es link
});
@@ -44,15 +42,8 @@ const emit = defineEmits<{
}>();
function handleClick(event: MouseEvent) {
- // Si es usado como link, no bloquear la navegación
- if (props.asLink) {
- emit('click', event);
- return;
- }
-
- // Para botones normales, validar estados
if (props.disabled || props.loading) return;
-
+ emit('click', event);
}
const buttonClasses = computed(() => {
@@ -82,7 +73,7 @@ const buttonClasses = computed(() => {
solid: ['shadow-sm'],
outline: ['border', 'bg-white', 'hover:bg-gray-50'],
ghost: ['bg-transparent', 'hover:bg-gray-100'],
- smooth: ['bg-opacity-20', 'font-bold', 'shadow-none'],
+ smooth: ['bg-opacity-20', 'font-bold', 'uppercase', 'shadow-none'],
};
// Colores por tipo
diff --git a/src/components/ui/Icons/MaterialIcon.vue b/src/components/ui/Icons/MaterialIcon.vue
new file mode 100644
index 0000000..1dbbcad
--- /dev/null
+++ b/src/components/ui/Icons/MaterialIcon.vue
@@ -0,0 +1,49 @@
+
+
+ {{ name }}
+
+
+
+
+
+
diff --git a/src/components/ui/Table/Table.vue b/src/components/ui/Table/Table.vue
new file mode 100644
index 0000000..269620d
--- /dev/null
+++ b/src/components/ui/Table/Table.vue
@@ -0,0 +1,117 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/src/components/ui/Table/TableBody.vue b/src/components/ui/Table/TableBody.vue
new file mode 100644
index 0000000..9f95778
--- /dev/null
+++ b/src/components/ui/Table/TableBody.vue
@@ -0,0 +1,56 @@
+
+
+
+ |
+
+ {{ formatCell(row[column.key], row, column) }}
+
+ |
+
+
+ |
+ {{ emptyMessage }}
+ |
+
+
+
+
+
\ No newline at end of file
diff --git a/src/components/ui/Table/TableHeader.vue b/src/components/ui/Table/TableHeader.vue
new file mode 100644
index 0000000..b7625f8
--- /dev/null
+++ b/src/components/ui/Table/TableHeader.vue
@@ -0,0 +1,65 @@
+
+
+
+
+
+ {{ column.label }}
+
+
+
+
+
+ |
+
+
+
+
+
diff --git a/src/components/ui/Table/TablePagination.vue b/src/components/ui/Table/TablePagination.vue
new file mode 100644
index 0000000..b7a6d3a
--- /dev/null
+++ b/src/components/ui/Table/TablePagination.vue
@@ -0,0 +1,148 @@
+
+
+
+
+
+
+
+
+
+ Mostrando
+ {{ startIndex + 1 }}
+ a
+ {{ endIndex }}
+ de
+ {{ totalItems }}
+ resultados
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/ui/Table/composables/usePagination.js b/src/components/ui/Table/composables/usePagination.js
new file mode 100644
index 0000000..9cc490c
--- /dev/null
+++ b/src/components/ui/Table/composables/usePagination.js
@@ -0,0 +1,60 @@
+import { computed, ref } from 'vue';
+
+export function usePagination(initialConfig) {
+ const currentPage = ref(initialConfig.currentPage);
+ const pageSize = ref(initialConfig.pageSize);
+ const totalItems = ref(initialConfig.totalItems);
+
+ const totalPages = computed(() => Math.ceil(totalItems.value / pageSize.value));
+
+ const startIndex = computed(() => (currentPage.value - 1) * pageSize.value);
+
+ const endIndex = computed(() => Math.min(startIndex.value + pageSize.value, totalItems.value));
+
+ const hasNextPage = computed(() => currentPage.value < totalPages.value);
+
+ const hasPreviousPage = computed(() => currentPage.value > 1);
+
+ const goToPage = (page) => {
+ if (page >= 1 && page <= totalPages.value) {
+ currentPage.value = page;
+ }
+ };
+
+ const nextPage = () => {
+ if (hasNextPage.value) {
+ currentPage.value++;
+ }
+ };
+
+ const previousPage = () => {
+ if (hasPreviousPage.value) {
+ currentPage.value--;
+ }
+ };
+
+ const setPageSize = (size) => {
+ pageSize.value = size;
+ currentPage.value = 1;
+ };
+
+ const updateTotalItems = (total) => {
+ totalItems.value = total;
+ };
+
+ return {
+ currentPage,
+ pageSize,
+ totalItems,
+ totalPages,
+ startIndex,
+ endIndex,
+ hasNextPage,
+ hasPreviousPage,
+ goToPage,
+ nextPage,
+ previousPage,
+ setPageSize,
+ updateTotalItems,
+ };
+}
diff --git a/src/components/ui/Table/composables/useSort.js b/src/components/ui/Table/composables/useSort.js
new file mode 100644
index 0000000..9a3e992
--- /dev/null
+++ b/src/components/ui/Table/composables/useSort.js
@@ -0,0 +1,50 @@
+import { ref, computed } from 'vue';
+
+export function useSort(initialData) {
+ const sortConfig = ref(null);
+
+ const sortedData = computed(() => {
+ if (!sortConfig.value) {
+ return initialData;
+ }
+
+ const { key, direction } = sortConfig.value;
+ const sorted = [...initialData];
+
+ sorted.sort((a, b) => {
+ const aValue = a[key];
+ const bValue = b[key];
+
+ if (aValue === bValue) return 0;
+
+ const comparison = aValue > bValue ? 1 : -1;
+ return direction === 'asc' ? comparison : -comparison;
+ });
+
+ return sorted;
+ });
+
+ const toggleSort = (key) => {
+ if (!sortConfig.value || sortConfig.value.key !== key) {
+ sortConfig.value = { key, direction: 'asc' };
+ } else if (sortConfig.value.direction === 'asc') {
+ sortConfig.value = { key, direction: 'desc' };
+ } else {
+ sortConfig.value = null;
+ }
+ };
+
+ const getSortDirection = (key) => {
+ if (!sortConfig.value || sortConfig.value.key !== key) {
+ return null;
+ }
+ return sortConfig.value.direction;
+ };
+
+ return {
+ sortConfig,
+ sortedData,
+ toggleSort,
+ getSortDirection,
+ };
+}
diff --git a/src/components/ui/Tags/Badge.vue b/src/components/ui/Tags/Badge.vue
new file mode 100644
index 0000000..cf4be8a
--- /dev/null
+++ b/src/components/ui/Tags/Badge.vue
@@ -0,0 +1,109 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/css/icons.css b/src/css/icons.css
index e673e8f..d81e33f 100644
--- a/src/css/icons.css
+++ b/src/css/icons.css
@@ -75,3 +75,58 @@
font-weight: 400;
src: url(./icons/google/gNNBW2J8Roq16WD5tFNRaeLQk6-SHQ_R00k4c2_whPnoY9ruReYU3rHmz74m0ZkGH-VBYe1x0TV6x4yFH8F-H5OdzEL3sVTgJtfbYxOLojCL.woff2) format('woff2');
}
+
+/* Clases para MaterialIcon component */
+.material-symbols-outlined {
+ font-family: 'Material Symbols Outlined';
+ font-weight: normal;
+ font-style: normal;
+ font-size: 24px;
+ display: inline-block;
+ line-height: 1;
+ text-transform: none;
+ letter-spacing: normal;
+ word-wrap: normal;
+ white-space: nowrap;
+ direction: ltr;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: optimizeLegibility;
+ font-feature-settings: 'liga';
+}
+
+.material-symbols-rounded {
+ font-family: 'Material Symbols Rounded';
+ font-weight: normal;
+ font-style: normal;
+ font-size: 24px;
+ display: inline-block;
+ line-height: 1;
+ text-transform: none;
+ letter-spacing: normal;
+ word-wrap: normal;
+ white-space: nowrap;
+ direction: ltr;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: optimizeLegibility;
+ font-feature-settings: 'liga';
+}
+
+.material-symbols-sharp {
+ font-family: 'Material Symbols Sharp';
+ font-weight: normal;
+ font-style: normal;
+ font-size: 24px;
+ display: inline-block;
+ line-height: 1;
+ text-transform: none;
+ letter-spacing: normal;
+ word-wrap: normal;
+ white-space: nowrap;
+ direction: ltr;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: optimizeLegibility;
+ font-feature-settings: 'liga';
+}
diff --git a/src/lang/es.js b/src/lang/es.js
index 698d571..d410a72 100644
--- a/src/lang/es.js
+++ b/src/lang/es.js
@@ -78,6 +78,19 @@ export default {
activity: {
title: 'Historial de acciones',
description: 'Historial de acciones realizadas por los usuarios en orden cronológico.'
+ },
+ products: {
+ name: 'Productos',
+ title: 'Productos',
+ description: 'Gestión del catálogo de productos',
+ create: {
+ title: 'Crear producto',
+ description: 'Permite crear un nuevo producto en el catálogo con sus clasificaciones y atributos personalizados.'
+ },
+ edit: {
+ title: 'Editar producto',
+ description: 'Actualiza la información del producto, sus clasificaciones y atributos.'
+ }
}
},
app: {
@@ -193,6 +206,8 @@ export default {
done:'Hecho.',
edit:'Editar',
edited:'Registro creado',
+ active:'Activo',
+ inactive:'Inactivo',
email:{
title:'Correo',
verification:'Verificar correo'
diff --git a/src/layouts/AdminLayout.vue b/src/layouts/AdminLayout.vue
index 3956bd5..c7b5031 100644
--- a/src/layouts/AdminLayout.vue
+++ b/src/layouts/AdminLayout.vue
@@ -105,6 +105,11 @@ onMounted(() => {
name="Productos"
to="admin.products.index"
/>
+
diff --git a/src/pages/Pos/Index.vue b/src/pages/Pos/Index.vue
new file mode 100644
index 0000000..04691b4
--- /dev/null
+++ b/src/pages/Pos/Index.vue
@@ -0,0 +1,851 @@
+
+
+
+
+
+
+
Punto de Venta
+
Gestiona las ventas de productos
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Productos
+
+
+
+
+
+
Cargando productos...
+
+
+
+
+
+
No hay productos disponibles
+
+
+
+
+
+
+
+
+
+
{{ product.name }}
+
{{ product.sku }}
+
+
+
+
+ {{ product.category }}
+
+
+
+
+
+
+
${{ product.price.toFixed(2) }}
+
+
+ Stock: {{ product.stock }} unidades
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Carrito
+
+ {{ cartItemsCount }} productos
+
+
+
+
+
+
+
+
El carrito está vacío
+
+
+
+
+
+
+
+
+
+
+
{{ item.name }}
+
{{ item.sku }}
+
+
+
+
+
+
+
+
+ {{ item.quantity }}
+
+
+
+
+ ${{ (item.price * item.quantity).toFixed(2) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Subtotal
+ ${{ subtotal.toFixed(2) }}
+
+
+ IVA (16%)
+ ${{ tax.toFixed(2) }}
+
+
+
+ Total
+ ${{ total.toFixed(2) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Total a Pagar
+ ${{ total.toFixed(2) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cambio
+
+ ${{ change.toFixed(2) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Resumen de Ventas
+
+
+
+ Total Ventas
+ {{ totalSalesCount }}
+
+
+
+
+ Ventas Efectivo
+ ${{ totalCashSales.toFixed(2) }}
+
+
+
+
+ Ventas Tarjeta
+ ${{ totalCardSales.toFixed(2) }}
+
+
+
+
+ Total Egresos
+ ${{ totalExpenses.toFixed(2) }}
+
+
+
+
+
+ Efectivo Esperado en Caja
+ ${{ expectedCash.toFixed(2) }}
+
+ (Ventas en efectivo - Egresos)
+
+
+
+
+
+
+
+
Detalle de Ventas
+
+
+
+
+
{{ sale.id }}
+
+ {{ sale.timestamp.toLocaleTimeString() }} -
+ {{ sale.items.length }} items -
+ {{ sale.paymentMethod === 'cash' ? 'Efectivo' : 'Tarjeta' }}
+
+
+
${{ sale.total.toFixed(2) }}
+
+
+
+
+
+
+
+
Detalle de Egresos
+
+
+
+
+
{{ expense.description }}
+
+ {{ expense.timestamp.toLocaleTimeString() }}
+
+
+
-${{ expense.amount.toFixed(2) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ cashDifference === 0 ? 'Cuadrado ✓' : cashDifference < 0 ? 'Faltante' : 'Sobrante' }}
+
+
+ ${{ Math.abs(cashDifference).toFixed(2) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Describe brevemente el motivo del egreso
+
+
+
+
+
+
+
+
+ $
+
+
+
+
+ Ingresa el monto exacto del egreso
+
+
+
+
+
+
+
+
+
+
+ Información Importante
+
+
+ Los egresos se descontarán del efectivo esperado en el corte de caja.
+ Asegúrate de ingresar la información correcta.
+
+
+
+
+
+
+
+
+
Egresos Registrados Hoy
+
+
+
+
+
{{ expense.description }}
+
+ {{ expense.timestamp.toLocaleTimeString() }}
+
+
+
+ -${{ expense.amount.toFixed(2) }}
+
+
+
+
+ Total Egresos:
+ -${{ totalExpenses.toFixed(2) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/pages/Pos/Module.js b/src/pages/Pos/Module.js
new file mode 100644
index 0000000..943a378
--- /dev/null
+++ b/src/pages/Pos/Module.js
@@ -0,0 +1,23 @@
+import { lang } from '@Lang/i18n';
+import { hasPermission } from '@Plugins/RolePermission.js';
+
+// Ruta API
+const apiTo = (name, params = {}) => route(`products.${name}`, params)
+
+// Ruta visual
+const viewTo = ({ name = '', params = {}, query = {} }) => view({
+ name: `admin.products.${name}`, params, query
+})
+
+// Obtener traducción del componente
+const transl = (str) => lang(`admin.products.${str}`)
+
+// Control de permisos
+const can = (permission) => hasPermission(`admin.products.${permission}`)
+
+export {
+ can,
+ viewTo,
+ apiTo,
+ transl
+}
\ No newline at end of file
diff --git a/src/pages/Products/Create.vue b/src/pages/Products/Create.vue
new file mode 100644
index 0000000..85bdc08
--- /dev/null
+++ b/src/pages/Products/Create.vue
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/pages/Products/Edit.vue b/src/pages/Products/Edit.vue
new file mode 100644
index 0000000..0e17d5c
--- /dev/null
+++ b/src/pages/Products/Edit.vue
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/Products/Form.vue b/src/pages/Products/Form.vue
new file mode 100644
index 0000000..a3decb0
--- /dev/null
+++ b/src/pages/Products/Form.vue
@@ -0,0 +1,546 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/pages/Products/Index.vue b/src/pages/Products/Index.vue
index 663d362..9012552 100644
--- a/src/pages/Products/Index.vue
+++ b/src/pages/Products/Index.vue
@@ -1,61 +1,121 @@
@@ -67,27 +127,31 @@ onMounted(() => {
Gestión del catálogo de productos
-
-
-
+
+
+
+
+
-
-
+
-
150
+
3
Total Productos
-
@@ -96,133 +160,38 @@ onMounted(() => {
-
-
- searcher.search({ search: e.target.value })"
- />
-
-
+
Buscador
- searcher.pagination(page)"
- >
-
- | {{ $t('code') }} |
- SKU |
- {{ $t('name') }} |
- {{ $t('description') }} |
- Atributos |
- Clasificaciones |
- {{ $t('status') }} |
- {{ $t('actions') }} |
+
+
+
+
+ {{ key }}
+
+
-
-
-
- |
-
-
-
- {{ product.code }}
-
-
- |
-
-
-
- {{ product.sku }}
-
- |
-
-
- {{ product.name }}
- |
-
-
-
- {{ product.description || '-' }}
-
- |
-
-
-
- {{ formatAttributes(product.attributes) }}
-
- |
-
-
-
-
- {{ classification.name }}
-
-
- Sin clasificar
-
-
- |
-
-
-
- {{ product.is_active ? $t('active') : $t('inactive') }}
-
- |
-
-
-
-
-
-
-
- |
-
+
+
+
+ {{ value ? 'Activo' : 'Inactivo' }}
+
-
-
-
-
-
-
- {{ $t('registers.empty') }}
-
-
- No se encontraron productos
-
-
- |
+
+
+
+
+
+
-
diff --git a/src/pages/Products/Modals/Show.vue b/src/pages/Products/Modals/Show.vue
new file mode 100644
index 0000000..b4b163b
--- /dev/null
+++ b/src/pages/Products/Modals/Show.vue
@@ -0,0 +1,342 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('details') }}
+
+
+
+
+ {{ $t('code') }}:
+
+ {{ model.code }}
+
+
+
+ SKU:
+
+ {{ model.sku }}
+
+
+
+ {{ $t('name') }}:
+ {{ model.name }}
+
+
+ {{ $t('description') }}:
+ {{ model.description }}
+
+
+
+
+ {{ $t('status') }}:
+
+
+
+ {{ $t('created_at') }}:
+ {{ getDateTime(model.created_at) }}
+
+
+ {{ $t('updated_at') }}:
+ {{ getDateTime(model.updated_at) }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('Atributos Personalizados') }}
+
+
+
+
+
+ {{ attr.key }}
+
+
+ {{ attr.value }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('Clasificaciones') }}
+
+
+
+
+
+ Clasificaciones de Almacén
+
+
+
+ {{ classification.code }}
+ {{ classification.name }}
+
+
+
+
+
+
+
+ Clasificaciones Comerciales
+
+
+
+ {{ classification.code }}
+ {{ classification.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/Products/Module.js b/src/pages/Products/Module.js
index 943a378..d410ef1 100644
--- a/src/pages/Products/Module.js
+++ b/src/pages/Products/Module.js
@@ -3,6 +3,7 @@ import { hasPermission } from '@Plugins/RolePermission.js';
// Ruta API
const apiTo = (name, params = {}) => route(`products.${name}`, params)
+const comercialTo = (name, params = {}) => route(`comercial-classifications.${name}`, params)
// Ruta visual
const viewTo = ({ name = '', params = {}, query = {} }) => view({
@@ -19,5 +20,6 @@ export {
can,
viewTo,
apiTo,
+ comercialTo,
transl
}
\ No newline at end of file
diff --git a/src/pages/Products/interfaces/products.interfaces.js b/src/pages/Products/interfaces/products.interfaces.js
new file mode 100644
index 0000000..d8035c5
--- /dev/null
+++ b/src/pages/Products/interfaces/products.interfaces.js
@@ -0,0 +1,82 @@
+/**
+ * Interfaces para Products
+ *
+ * @author Sistema
+ * @version 1.0.0
+ */
+
+/**
+ * @typedef {Object} Product
+ * @property {number} id - ID del producto
+ * @property {string} code - Código del producto
+ * @property {string} sku - SKU del producto
+ * @property {string} name - Nombre del producto
+ * @property {string|null} description - Descripción del producto
+ * @property {Object|null} attributes - Atributos dinámicos del producto (JSON)
+ * @property {boolean} is_active - Estado activo/inactivo
+ * @property {string} created_at - Fecha de creación
+ * @property {string} updated_at - Fecha de actualización
+ * @property {string|null} deleted_at - Fecha de eliminación
+ * @property {ProductClassification[]} classifications - Clasificaciones asociadas
+ * @property {ProductClassification[]} warehouse_classifications - Clasificaciones de almacén
+ * @property {ProductClassification[]} comercial_classifications - Clasificaciones comerciales
+ */
+
+/**
+ * @typedef {Object} ProductClassification
+ * @property {number} id - ID de la clasificación
+ * @property {string} code - Código de la clasificación
+ * @property {string} name - Nombre de la clasificación
+ * @property {string} type - Tipo de clasificación (warehouse/comercial)
+ */
+
+/**
+ * @typedef {Object} ProductResponse
+ * @property {string} status - Estado de la respuesta
+ * @property {Object} data - Datos de la respuesta
+ * @property {string} data.message - Mensaje de la respuesta
+ * @property {Product} data.product - Producto
+ */
+
+/**
+ * @typedef {Object} ProductsListResponse
+ * @property {string} status - Estado de la respuesta
+ * @property {Object} data - Datos de la respuesta
+ * @property {Object} data.products - Lista de productos con paginación
+ * @property {Product[]} data.products.data - Array de productos
+ * @property {number} data.products.current_page - Página actual
+ * @property {number} data.products.total - Total de productos
+ */
+
+/**
+ * @typedef {Object} CreateProductData
+ * @property {string} code - Código del producto
+ * @property {string} sku - SKU del producto
+ * @property {string} name - Nombre del producto
+ * @property {string|null} description - Descripción del producto
+ * @property {Object|null} attributes - Atributos dinámicos (JSON)
+ * @property {boolean} is_active - Estado activo/inactivo
+ * @property {number[]} warehouse_classification_ids - IDs de clasificaciones de almacén
+ * @property {number[]} comercial_classification_ids - IDs de clasificaciones comerciales
+ */
+
+/**
+ * @typedef {Object} UpdateProductData
+ * @property {string} [code] - Código del producto
+ * @property {string} [sku] - SKU del producto
+ * @property {string} [name] - Nombre del producto
+ * @property {string|null} [description] - Descripción del producto
+ * @property {Object|null} [attributes] - Atributos dinámicos (JSON)
+ * @property {boolean} [is_active] - Estado activo/inactivo
+ * @property {number[]} [warehouse_classification_ids] - IDs de clasificaciones de almacén
+ * @property {number[]} [comercial_classification_ids] - IDs de clasificaciones comerciales
+ */
+
+export {
+ Product,
+ ProductClassification,
+ ProductResponse,
+ ProductsListResponse,
+ CreateProductData,
+ UpdateProductData
+};
diff --git a/src/pages/Products/services/ProductService.js b/src/pages/Products/services/ProductService.js
new file mode 100644
index 0000000..4fb8c82
--- /dev/null
+++ b/src/pages/Products/services/ProductService.js
@@ -0,0 +1,263 @@
+/**
+ * Servicio para Products
+ *
+ * @author Sistema
+ * @version 1.0.0
+ */
+
+import { api, apiURL } from '@Services/Api';
+
+export default class ProductService {
+
+ /**
+ * Obtener todos los productos
+ * @param {Object} params - Parámetros de la consulta
+ * @returns {Promise} Promesa con la respuesta
+ */
+ async getAll(params = {}) {
+ return new Promise((resolve, reject) => {
+ api.get(apiURL('catalogs/products'), {
+ params,
+ onSuccess: (response) => resolve(response),
+ onError: (error) => reject(error)
+ });
+ });
+ }
+
+ /**
+ * Obtener un producto por ID
+ * @param {number} id - ID del producto
+ * @returns {Promise} Promesa con la respuesta
+ */
+ async getById(id) {
+ return new Promise((resolve, reject) => {
+ api.get(apiURL(`catalogs/products/${id}`), {
+ onSuccess: (response) => resolve(response),
+ onError: (error) => reject(error)
+ });
+ });
+ }
+
+ /**
+ * Crear un nuevo producto
+ * @param {Object} data - Datos del producto
+ * @param {string} data.code - Código del producto
+ * @param {string} data.sku - SKU del producto
+ * @param {string} data.name - Nombre del producto
+ * @param {string|null} data.description - Descripción del producto
+ * @param {Object|null} data.attributes - Atributos dinámicos
+ * @param {boolean} data.is_active - Estado activo/inactivo
+ * @param {number[]} data.warehouse_classification_ids - IDs de clasificaciones de almacén
+ * @param {number[]} data.comercial_classification_ids - IDs de clasificaciones comerciales
+ * @returns {Promise} Promesa con la respuesta
+ */
+ async create(data) {
+ return new Promise((resolve, reject) => {
+ api.post(apiURL('catalogs/products'), {
+ data,
+ onSuccess: (response) => {
+ resolve(response);
+ },
+ onError: (error) => { reject(error); }
+ });
+ });
+ }
+
+ /**
+ * Actualizar un producto
+ * @param {number} id - ID del producto
+ * @param {Object} data - Datos a actualizar
+ * @param {string} [data.code] - Código del producto
+ * @param {string} [data.sku] - SKU del producto
+ * @param {string} [data.name] - Nombre del producto
+ * @param {string|null} [data.description] - Descripción del producto
+ * @param {Object|null} [data.attributes] - Atributos dinámicos
+ * @param {boolean} [data.is_active] - Estado activo/inactivo
+ * @param {number[]} [data.warehouse_classification_ids] - IDs de clasificaciones de almacén
+ * @param {number[]} [data.comercial_classification_ids] - IDs de clasificaciones comerciales
+ * @returns {Promise} Promesa con la respuesta
+ */
+ async update(id, data) {
+ return new Promise((resolve, reject) => {
+ api.put(apiURL(`catalogs/products/${id}`), {
+ data,
+ onSuccess: (response) => resolve(response),
+ onError: (error) => reject(error)
+ });
+ });
+ }
+
+ /**
+ * Actualizar solo el estado de un producto
+ * @param {number} id - ID del producto
+ * @param {boolean} is_active - Nuevo estado
+ * @returns {Promise} Promesa con la respuesta
+ */
+ async updateStatus(id, is_active) {
+ return new Promise((resolve, reject) => {
+ api.put(apiURL(`catalogs/products/${id}`), {
+ data: { is_active },
+ onSuccess: (response) => {
+ resolve(response);
+ },
+ onError: (error) => {
+ // Mejorar el manejo de errores
+ const enhancedError = {
+ ...error,
+ timestamp: new Date().toISOString(),
+ action: 'updateStatus',
+ id: id,
+ is_active: is_active
+ };
+ reject(enhancedError);
+ }
+ });
+ });
+ }
+
+ /**
+ * Eliminar un producto
+ * @param {number} id - ID del producto
+ * @returns {Promise} Promesa con la respuesta
+ */
+ async delete(id) {
+ return new Promise((resolve, reject) => {
+ api.delete(apiURL(`catalogs/products/${id}`), {
+ onSuccess: (response) => resolve(response),
+ onError: (error) => reject(error)
+ });
+ });
+ }
+
+ /**
+ * Alternar el estado de un producto
+ * @param {Object} item - Objeto con el producto
+ * @returns {Promise} Promesa con la respuesta
+ */
+ async toggleStatus(item) {
+ const newStatus = !item.is_active;
+ return this.updateStatus(item.id, newStatus);
+ }
+
+ /**
+ * Obtener clasificaciones disponibles para productos
+ * @param {string} type - Tipo de clasificación ('warehouse' o 'comercial')
+ * @returns {Promise} Promesa con la respuesta
+ */
+ async getClassifications(type = 'warehouse') {
+ return new Promise((resolve, reject) => {
+ const endpoint = type === 'warehouse'
+ ? 'catalogs/warehouse-classifications'
+ : 'comercial-classifications';
+
+ api.get(apiURL(endpoint), {
+ onSuccess: (response) => {
+ // Aplanar la estructura jerárquica para selects
+ const flattenOptions = (items, level = 0) => {
+ let options = [];
+ items.forEach(item => {
+ options.push({
+ ...item,
+ label: ' '.repeat(level) + item.name,
+ value: item.id,
+ level
+ });
+
+ if (item.children && item.children.length > 0) {
+ options = options.concat(flattenOptions(item.children, level + 1));
+ }
+ });
+ return options;
+ };
+
+ // Determinar la clave de datos según el tipo
+ const dataKey = type === 'warehouse'
+ ? 'warehouse_classifications'
+ : 'comercial_classifications';
+
+ const data = response[dataKey]?.data || response.data || [];
+ const flatOptions = flattenOptions(data);
+ resolve(flatOptions);
+ },
+ onError: (error) => reject(error)
+ });
+ });
+ }
+
+ /**
+ * Obtener clasificaciones de almacén
+ * @returns {Promise} Promesa con la respuesta
+ */
+ async getWarehouseClassifications() {
+ return this.getClassifications('warehouse');
+ }
+
+ /**
+ * Obtener clasificaciones comerciales
+ * @returns {Promise} Promesa con la respuesta
+ */
+ async getComercialClassifications() {
+ return this.getClassifications('comercial');
+ }
+
+ /**
+ * Buscar productos con filtros avanzados
+ * @param {Object} filters - Filtros de búsqueda
+ * @param {string} [filters.search] - Búsqueda por nombre, código o SKU
+ * @param {boolean} [filters.is_active] - Filtrar por estado
+ * @param {number[]} [filters.warehouse_classification_ids] - Filtrar por clasificaciones de almacén
+ * @param {number[]} [filters.comercial_classification_ids] - Filtrar por clasificaciones comerciales
+ * @returns {Promise} Promesa con la respuesta
+ */
+ async search(filters = {}) {
+ return this.getAll({ ...filters });
+ }
+
+ /**
+ * Exportar productos a formato específico
+ * @param {string} format - Formato de exportación (csv, excel, pdf)
+ * @param {Object} filters - Filtros aplicados
+ * @returns {Promise} Promesa con la respuesta
+ */
+ async export(format = 'excel', filters = {}) {
+ return new Promise((resolve, reject) => {
+ api.get(apiURL(`catalogs/products/export/${format}`), {
+ params: filters,
+ responseType: 'blob',
+ onSuccess: (response) => resolve(response),
+ onError: (error) => reject(error)
+ });
+ });
+ }
+
+ /**
+ * Duplicar un producto
+ * @param {number} id - ID del producto a duplicar
+ * @returns {Promise} Promesa con la respuesta
+ */
+ async duplicate(id) {
+ return new Promise((resolve, reject) => {
+ api.post(apiURL(`catalogs/products/${id}/duplicate`), {
+ onSuccess: (response) => resolve(response),
+ onError: (error) => reject(error)
+ });
+ });
+ }
+
+ /**
+ * Validar si un código o SKU ya existe
+ * @param {string} field - Campo a validar ('code' o 'sku')
+ * @param {string} value - Valor a validar
+ * @param {number|null} excludeId - ID a excluir de la validación (para edición)
+ * @returns {Promise} True si existe, false si no
+ */
+ async checkExists(field, value, excludeId = null) {
+ return new Promise((resolve, reject) => {
+ api.get(apiURL('catalogs/products/check-exists'), {
+ params: { field, value, exclude_id: excludeId },
+ onSuccess: (response) => resolve(response.exists || false),
+ onError: (error) => reject(error)
+ });
+ });
+ }
+}
diff --git a/src/router/Index.js b/src/router/Index.js
index cc5f7f2..a8fa196 100644
--- a/src/router/Index.js
+++ b/src/router/Index.js
@@ -542,15 +542,16 @@ const router = createRouter({
name: 'admin.products.index',
component: () => import('@Pages/Products/Index.vue'),
},
- /* {
+ {
path: 'create',
name: 'admin.products.create',
component: () => import('@Pages/Products/Create.vue'),
meta: {
title: 'Crear',
icon: 'add',
- },
- },
+ }
+ }
+ /*
{
path: ':id/edit',
name: 'admin.products.edit',
@@ -562,6 +563,22 @@ const router = createRouter({
} */
]
},
+ {
+ path: 'pos',
+ name: 'admin.pos',
+ meta: {
+ title: 'Punto de Venta',
+ icon: 'point_of_sale',
+ },
+ redirect: '/admin/pos',
+ children: [
+ {
+ path: '',
+ name: 'admin.pos.index',
+ component: () => import('@Pages/Pos/Index.vue'),
+ },
+ ]
+ },
{
path: 'roles',
name: 'admin.roles',