Add: Punto de venta y UX para productos

This commit is contained in:
Edgar Méndez Mendoza 2025-10-08 12:45:48 -06:00
parent 83f0abff13
commit c222b66cef
25 changed files with 3128 additions and 170 deletions

7
package-lock.json generated
View File

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

View File

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

View File

@ -23,7 +23,6 @@ interface Props {
loading?: boolean;
fullWidth?: boolean;
iconOnly?: boolean;
asLink?: boolean; // Nueva prop para comportamiento de link
}
const props = withDefaults(defineProps<Props>(), {
@ -35,7 +34,6 @@ const props = withDefaults(defineProps<Props>(), {
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

View File

@ -0,0 +1,49 @@
<template>
<span
:class="`material-symbols-${variant}`"
:style="{
fontVariationSettings: variationSettings,
fontSize: size + 'px',
color
}"
aria-hidden="true"
>
{{ name }}
</span>
</template>
<script>
export default {
name: "MaterialIcon",
props: {
name: { type: String, required: true }, // Ej: "search", "home", "face"
variant: {
type: String,
default: "outlined", // outlined, rounded, sharp
validator: (v) => ["outlined", "rounded", "sharp"].includes(v)
},
fill: { type: Number, default: 0 }, // 0: borde, 1: relleno
weight: { type: Number, default: 400 }, // 100 a 700
grade: { type: Number, default: 0 }, // -25, 0, 200
opticalSize: { type: Number, default: 48 }, // tamaño óptico
size: { type: Number, default: 24 }, // tamaño visual en px
color: { type: String, default: "inherit" }
},
computed: {
variationSettings() {
return `"FILL" ${this.fill}, "wght" ${this.weight}, "GRAD" ${this.grade}, "opsz" ${this.opticalSize}`;
}
}
};
</script>
<style scoped>
.material-symbols-outlined,
.material-symbols-rounded,
.material-symbols-sharp {
vertical-align: middle;
user-select: none;
display: inline-block;
line-height: 1;
}
</style>

View File

@ -0,0 +1,117 @@
<template>
<div class="flex flex-col">
<div class="overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 rounded-lg">
<div v-if="loading" class="relative">
<div class="absolute inset-0 bg-white bg-opacity-75 flex items-center justify-center z-10">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
</div>
</div>
<table class="min-w-full divide-y divide-gray-300">
<TableHeader
:columns="columns"
:sortable="sortable"
:sort-direction="sortDirection"
@sort="handleSort"
/>
<TableBody
:columns="columns"
:data="paginatedData"
:empty-message="emptyMessage"
>
<!-- Pasar todos los slots al TableBody -->
<template v-for="(_, name) in $slots" #[name]="slotData">
<slot :name="name" v-bind="slotData" />
</template>
</TableBody>
</table>
<TablePagination
v-if="pagination && paginationState"
:current-page="paginationState.currentPage.value"
:total-pages="paginationState.totalPages.value"
:total-items="paginationState.totalItems.value"
:start-index="paginationState.startIndex.value"
:end-index="paginationState.endIndex.value"
:has-next-page="paginationState.hasNextPage.value"
:has-previous-page="paginationState.hasPreviousPage.value"
@next="paginationState.nextPage"
@previous="paginationState.previousPage"
@go-to-page="paginationState.goToPage"
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, watch } from 'vue';
import { usePagination } from './composables/usePagination';
import { useSort } from './composables/useSort';
import TableHeader from './TableHeader.vue';
import TableBody from './TableBody.vue';
import TablePagination from './TablePagination.vue';
const props = defineProps({
columns: {
type: Array,
required: true
},
data: {
type: Array,
default: () => []
},
sortable: {
type: Boolean,
default: true
},
loading: {
type: Boolean,
default: false
},
emptyMessage: {
type: String,
default: 'No data available'
},
pagination: {
type: Object,
default: null
}
});
const { sortedData, toggleSort } = useSort(props.data);
const paginationState = props.pagination
? usePagination(props.pagination)
: null;
const paginatedData = computed(() => {
if (!paginationState) {
return sortedData.value;
}
const start = paginationState.startIndex.value;
const end = paginationState.endIndex.value;
return sortedData.value.slice(start, end);
});
const sortDirection = computed(() => {
return null;
});
const handleSort = (key) => {
toggleSort(key);
};
watch(
() => props.data,
(newData) => {
if (paginationState) {
paginationState.updateTotalItems(newData.length);
}
}
);
</script>

View File

@ -0,0 +1,56 @@
<template>
<tbody class="bg-white divide-y divide-gray-200">
<tr
v-for="(row, rowIndex) in data"
:key="rowIndex"
class="hover:bg-gray-50 transition-colors"
>
<td
v-for="column in columns"
:key="column.key"
class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"
>
<slot
:name="`cell-${column.key}`"
:value="row[column.key]"
:row="row"
:column="column"
>
{{ formatCell(row[column.key], row, column) }}
</slot>
</td>
</tr>
<tr v-if="!data || data.length === 0">
<td
:colspan="columns.length"
class="px-6 py-8 text-center text-sm text-gray-500"
>
{{ emptyMessage }}
</td>
</tr>
</tbody>
</template>
<script setup>
const props = defineProps({
columns: {
type: Array,
required: true
},
data: {
type: Array,
required: true
},
emptyMessage: {
type: String,
default: 'No data available'
}
});
const formatCell = (value, row, column) => {
if (column.formatter) {
return column.formatter(value, row);
}
return value ?? '-';
};
</script>

View File

@ -0,0 +1,65 @@
<template>
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th
v-for="column in columns"
:key="column.key"
:style="column.width ? { width: column.width } : undefined"
:class="[
'px-6 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider',
column.sortable && sortable ? 'cursor-pointer select-none hover:bg-gray-100' : ''
]"
@click="column.sortable && sortable ? handleSort(column.key) : undefined"
>
<div class="flex items-center gap-2">
<span>{{ column.label }}</span>
<span v-if="column.sortable && sortable" class="flex flex-col">
<svg
:class="[
'w-3 h-3 transition-colors',
sortDirection === 'asc' ? 'text-primary-600' : 'text-gray-400'
]"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M5 10l5-5 5 5H5z" />
</svg>
<svg
:class="[
'w-3 h-3 -mt-1 transition-colors',
sortDirection === 'desc' ? 'text-primary-600' : 'text-gray-400'
]"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M15 10l-5 5-5-5h10z" />
</svg>
</span>
</div>
</th>
</tr>
</thead>
</template>
<script setup>
const props = defineProps({
columns: {
type: Array,
required: true
},
sortable: {
type: Boolean,
default: true
},
sortDirection: {
type: String,
default: null
}
});
const emit = defineEmits(['sort']);
const handleSort = (key) => {
emit('sort', key);
};
</script>

View File

@ -0,0 +1,148 @@
<template>
<div class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div class="flex-1 flex justify-between sm:hidden">
<button
@click="emit('previous')"
:disabled="!hasPreviousPage"
:class="[
'relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md',
hasPreviousPage
? 'text-gray-700 bg-white hover:bg-gray-50'
: 'text-gray-400 bg-gray-100 cursor-not-allowed'
]"
>
Previous
</button>
<button
@click="emit('next')"
:disabled="!hasNextPage"
:class="[
'ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md',
hasNextPage
? 'text-gray-700 bg-white hover:bg-gray-50'
: 'text-gray-400 bg-gray-100 cursor-not-allowed'
]"
>
Next
</button>
</div>
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700">
Mostrando
<span class="font-medium">{{ startIndex + 1 }}</span>
a
<span class="font-medium">{{ endIndex }}</span>
de
<span class="font-medium">{{ totalItems }}</span>
resultados
</p>
</div>
<div>
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
<button
@click="emit('previous')"
:disabled="!hasPreviousPage"
:class="[
'relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 text-sm font-medium',
hasPreviousPage
? 'text-gray-500 bg-white hover:bg-gray-50'
: 'text-gray-300 bg-gray-100 cursor-not-allowed'
]"
>
<span class="sr-only">Previous</span>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</button>
<button
v-for="page in visiblePages"
:key="page"
@click="emit('goToPage', page)"
:class="[
'relative inline-flex items-center px-4 py-2 border text-sm font-medium',
page === currentPage
? 'z-10 bg-primary-50 border-primary-500 text-primary-600'
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
]"
>
{{ page }}
</button>
<button
@click="emit('next')"
:disabled="!hasNextPage"
:class="[
'relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 text-sm font-medium',
hasNextPage
? 'text-gray-500 bg-white hover:bg-gray-50'
: 'text-gray-300 bg-gray-100 cursor-not-allowed'
]"
>
<span class="sr-only">Next</span>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
</button>
</nav>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
currentPage: {
type: Number,
required: true
},
totalPages: {
type: Number,
required: true
},
totalItems: {
type: Number,
required: true
},
startIndex: {
type: Number,
required: true
},
endIndex: {
type: Number,
required: true
},
hasNextPage: {
type: Boolean,
required: true
},
hasPreviousPage: {
type: Boolean,
required: true
}
});
const emit = defineEmits(['next', 'previous', 'goToPage']);
const visiblePages = computed(() => {
const pages = [];
const maxVisible = 5;
const halfVisible = Math.floor(maxVisible / 2);
let startPage = Math.max(1, props.currentPage - halfVisible);
let endPage = Math.min(props.totalPages, startPage + maxVisible - 1);
if (endPage - startPage < maxVisible - 1) {
startPage = Math.max(1, endPage - maxVisible + 1);
}
for (let i = startPage; i <= endPage; i++) {
pages.push(i);
}
return pages;
});
</script>

View File

@ -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,
};
}

View File

@ -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,
};
}

View File

@ -0,0 +1,109 @@
<template>
<span :class="tagClasses">
<template v-if="props.dot">
<span :class="dotClasses"></span>
</template>
<template v-else>
<slot name="icon" />
</template>
<slot />
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
color?: 'blue' | 'purple' | 'green' | 'orange' | 'red' | 'gray' | string; // string para hex
dot?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
color: 'blue',
dot: true,
});
const isHexColor = (color: string) => /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(color);
const colorMap = {
blue: {
bg: 'bg-blue-100',
text: 'text-blue-700',
dot: 'bg-blue-500',
},
purple: {
bg: 'bg-purple-100',
text: 'text-purple-700',
dot: 'bg-purple-500',
},
green: {
bg: 'bg-green-100',
text: 'text-green-700',
dot: 'bg-green-500',
},
orange: {
bg: 'bg-orange-100',
text: 'text-orange-700',
dot: 'bg-orange-500',
},
red: {
bg: 'bg-red-100',
text: 'text-red-700',
dot: 'bg-red-500',
},
gray: {
bg: 'bg-gray-700',
text: 'text-gray-700',
dot: 'bg-gray-700',
},
};
const tagClasses = computed(() => {
if (isHexColor(props.color)) {
return [
'inline-flex',
'items-center',
'px-3',
'py-1',
'rounded-md',
'text-sm',
'font-medium',
{ backgroundColor: props.color, color: '#fff' },
];
}
const allowedColors = Object.keys(colorMap);
const isPreset = allowedColors.includes(props.color as string);
return [
'inline-flex',
'items-center',
'px-3',
'py-1',
'rounded-md',
'text-sm',
'font-medium',
isPreset ? colorMap[props.color as keyof typeof colorMap].bg : 'bg-gray-100',
isPreset ? colorMap[props.color as keyof typeof colorMap].text : 'text-gray-700',
];
});
const dotClasses = computed(() => {
if (isHexColor(props.color)) {
return [
'w-2',
'h-2',
'rounded-full',
'mr-2',
{ backgroundColor: props.color },
];
}
const allowedColors = Object.keys(colorMap);
const isPreset = allowedColors.includes(props.color as string);
return [
'w-2',
'h-2',
'rounded-full',
'mr-2',
isPreset ? colorMap[props.color as keyof typeof colorMap].dot : 'bg-gray-500',
];
});
</script>

View File

@ -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';
}

View File

@ -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'

View File

@ -105,6 +105,11 @@ onMounted(() => {
name="Productos"
to="admin.products.index"
/>
<Link
icon="sell"
name="Punto de venta"
to="admin.pos.index"
/>
</Section>
<Section name="Capacitaciones">

851
src/pages/Pos/Index.vue Normal file
View File

@ -0,0 +1,851 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import Button from '@Holos/Button/Button.vue';
import MaterialIcon from '@Components/ui/Icons/MaterialIcon.vue';
import Card from '@Holos/Card/Card.vue';
import CardContent from '@Holos/Card/CardContent.vue';
import CardHeader from '@Holos/Card/CardHeader.vue';
import CardTitle from '@Holos/Card/CardTitle.vue';
import Badge from '@Components/ui/Tags/Badge.vue';
import ModalShow from '@Holos/Modal/Show.vue';
import Input from '@Components/ui/Input.vue';
import { useSearcher } from '@Services/Api';
import { can, apiTo, viewTo, transl } from './Module'
// Estado reactivo
const cart = ref([]);
const searchTerm = ref('');
const models = ref([]);
const paymentModalRef = ref(null);
const paymentMethod = ref('cash'); // 'cash' | 'credit_card'
const amountReceived = ref('');
// Ventas y egresos
const sales = ref([]);
const expenses = ref([]);
// Modal de corte de caja
const cashCloseModalRef = ref(null);
const cashCounted = ref('');
// Modal de egresos
const expenseModalRef = ref(null);
const expenseDescription = ref('');
const expenseAmount = ref('');
// Productos filtrados desde el backend
const filteredProducts = computed(() => {
if (!models.value || !models.value.data) return [];
return models.value.data.map(product => ({
id: product.id,
name: product.name,
sku: product.sku,
code: product.code,
barcode: product.barcode,
description: product.description,
price: 16, // TODO: Obtener precio desde inventory_items o price field
stock: 10, // TODO: Obtener stock desde inventory_items
category: product.classifications?.[0]?.name || 'Sin categoría',
attributes: product.attributes,
is_active: product.is_active
}));
});
// Computeds del carrito
const cartItemsCount = computed(() => cart.value.length);
const subtotal = computed(() =>
cart.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
const tax = computed(() => subtotal.value * 0.16); // 16% IVA
const total = computed(() => subtotal.value + tax.value);
// Computeds para corte de caja
const totalSalesAmount = computed(() =>
sales.value.reduce((sum, sale) => sum + sale.total, 0)
);
const totalSalesCount = computed(() => sales.value.length);
const totalCashSales = computed(() =>
sales.value.filter(s => s.paymentMethod === 'cash').reduce((sum, sale) => sum + sale.total, 0)
);
const totalCardSales = computed(() =>
sales.value.filter(s => s.paymentMethod === 'credit_card').reduce((sum, sale) => sum + sale.total, 0)
);
const totalExpenses = computed(() =>
expenses.value.reduce((sum, expense) => sum + expense.amount, 0)
);
const expectedCash = computed(() => totalCashSales.value - totalExpenses.value);
const cashDifference = computed(() => {
if (!cashCounted.value) return 0;
return parseFloat(cashCounted.value) - expectedCash.value;
});
const searcher = useSearcher({
url: apiTo('index'),
onSuccess: (r) => {
models.value = r.products;
console.log('Productos cargados:', r.products);
},
onError: (error) => {
console.error('Error al cargar productos:', error);
}
});
// Métodos
const addToCart = (product) => {
if (!product.is_active) {
console.warn('Producto inactivo');
return;
}
const existingItem = cart.value.find(item => item.id === product.id);
if (existingItem) {
if (existingItem.quantity >= product.stock) {
console.warn('Stock insuficiente');
return;
}
existingItem.quantity++;
} else {
cart.value.push({
id: product.id,
name: product.name,
sku: product.sku,
code: product.code,
price: product.price,
quantity: 1,
maxStock: product.stock,
});
}
};
const updateQuantity = (id, delta) => {
const item = cart.value.find(item => item.id === id);
if (!item) return;
const newQuantity = item.quantity + delta;
if (newQuantity > item.maxStock) {
console.warn('Stock insuficiente');
return;
}
if (newQuantity <= 0) {
removeFromCart(id);
} else {
item.quantity = newQuantity;
}
};
const removeFromCart = (id) => {
cart.value = cart.value.filter(item => item.id !== id);
};
const clearCart = () => {
cart.value = [];
};
// Abrir modal de pago
const openPaymentModal = () => {
if (cart.value.length === 0) {
console.warn('El carrito está vacío');
return;
}
// Resetear valores del modal
paymentMethod.value = 'cash';
amountReceived.value = '';
paymentModalRef.value.open();
};
// Seleccionar método de pago
const selectPaymentMethod = (method) => {
paymentMethod.value = method;
// Limpiar monto recibido al cambiar de método
if (method === 'credit_card') {
amountReceived.value = '';
}
};
// Calcular cambio
const change = computed(() => {
if (paymentMethod.value === 'cash' && amountReceived.value) {
const received = parseFloat(amountReceived.value);
if (!isNaN(received) && received >= total.value) {
return received - total.value;
}
}
return 0;
});
// Procesar venta
const processSale = () => {
// Validar pago en efectivo
if (paymentMethod.value === 'cash') {
const received = parseFloat(amountReceived.value);
if (!amountReceived.value || isNaN(received)) {
console.error('Ingresa el monto recibido');
// TODO: Agregar toast notification
return;
}
if (received < total.value) {
console.error(`Monto insuficiente. Faltan $${(total.value - received).toFixed(2)}`);
// TODO: Agregar toast notification
return;
}
}
// Crear registro de venta
const saleData = {
id: `SALE-${Date.now()}`,
timestamp: new Date(),
items: [...cart.value],
subtotal: subtotal.value,
tax: tax.value,
total: total.value,
paymentMethod: paymentMethod.value,
amountReceived: paymentMethod.value === 'cash' ? parseFloat(amountReceived.value) : undefined,
change: paymentMethod.value === 'cash' ? change.value : undefined,
};
// Guardar venta
sales.value.push(saleData);
console.log('Venta procesada:', saleData);
console.log('Total de ventas:', sales.value.length);
// TODO: Enviar a la API
// TODO: Agregar toast de éxito
// Limpiar y cerrar
clearCart();
paymentModalRef.value.close();
paymentMethod.value = 'cash';
amountReceived.value = '';
};
// Abrir modal de corte de caja
const openCashCloseModal = () => {
cashCounted.value = '';
cashCloseModalRef.value.open();
};
// Procesar corte de caja
const handleCashClose = () => {
if (!cashCounted.value) {
console.error('Ingresa el efectivo contado');
// TODO: Agregar toast notification
return;
}
console.log('Corte de caja realizado');
console.log('Total ventas:', totalSalesAmount.value);
console.log('Diferencia:', cashDifference.value);
// TODO: Enviar a la API
// TODO: Agregar toast de éxito
// Reiniciar ventas y egresos después del corte
sales.value = [];
expenses.value = [];
cashCounted.value = '';
cashCloseModalRef.value.close();
};
// Abrir modal de egresos
const openExpenseModal = () => {
expenseDescription.value = '';
expenseAmount.value = '';
expenseModalRef.value.open();
};
// Agregar egreso
const addExpense = () => {
// Validar descripción
if (!expenseDescription.value.trim()) {
console.error('Ingresa una descripción del egreso');
// TODO: Agregar toast notification
return;
}
// Validar monto
const amount = parseFloat(expenseAmount.value);
if (!expenseAmount.value || isNaN(amount) || amount <= 0) {
console.error('Ingresa un monto válido');
// TODO: Agregar toast notification
return;
}
// Crear registro de egreso
const newExpense = {
id: `EXP-${Date.now()}`,
timestamp: new Date(),
description: expenseDescription.value.trim(),
amount: amount,
};
// Guardar egreso
expenses.value.push(newExpense);
console.log('Egreso registrado:', newExpense);
console.log('Total de egresos:', expenses.value.length);
// TODO: Enviar a la API
// TODO: Agregar toast de éxito
// Limpiar y cerrar
expenseDescription.value = '';
expenseAmount.value = '';
expenseModalRef.value.close();
};
// Obtener color del badge según categoría
const getCategoryColor = (category) => {
return 'blue';
};
// Formatear atributos para mostrar
const formatAttributes = (attributes) => {
if (!attributes || typeof attributes !== 'object') return '';
return Object.entries(attributes)
.map(([key, value]) => {
if (Array.isArray(value)) {
return `${key}: ${value.join(', ')}`;
}
return `${key}: ${value}`;
})
.join(' | ');
};
onMounted(() => {
searcher.search();
})
</script>
<template>
<div class="space-y-6">
<div class="flex justify-between items-start mt-4">
<div>
<h1 class="text-3xl font-bold tracking-tight">Punto de Venta</h1>
<p class="text-muted-foreground">Gestiona las ventas de productos</p>
</div>
<div class="flex gap-2">
<Button variant="outline" color="danger" @click="openExpenseModal">
<MaterialIcon name="trending_down" class="mr-2" />
Egreso
</Button>
<Button variant="outline" color="info" @click="openCashCloseModal">
<MaterialIcon name="calculate" class="mr-2" />
Corte de Caja
</Button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Sección de Productos -->
<Card class="lg:col-span-2">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<MaterialIcon name="search" class="mr-2" />
Productos
</CardTitle>
</CardHeader>
<CardContent>
<!-- Loading state -->
<div v-if="searcher.loading" class="text-center py-8">
<p class="text-muted-foreground">Cargando productos...</p>
</div>
<!-- Empty state -->
<div v-else-if="filteredProducts.length === 0" class="text-center py-8">
<MaterialIcon name="inventory_2" class="mx-auto mb-2 opacity-50" />
<p class="text-muted-foreground">No hay productos disponibles</p>
</div>
<!-- Products grid -->
<div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Card v-for="product in filteredProducts" :key="product.id"
class="hover:border-primary transition-colors"
:class="{ 'opacity-50': !product.is_active }">
<CardContent class="p-4 m-4">
<div class="space-y-3">
<div class="flex justify-between items-start">
<div class="space-y-1 flex-1">
<h3 class="font-semibold">{{ product.name }}</h3>
<p class="text-sm text-muted-foreground">{{ product.sku }}</p>
</div>
<div class="flex flex-col gap-1 ml-2">
<Badge :color="getCategoryColor(product.category)">
{{ product.category }}
</Badge>
</div>
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-2xl font-bold text-primary">${{ product.price.toFixed(2) }}
</p>
<p class="text-xs text-muted-foreground">
Stock: {{ product.stock }} unidades
</p>
</div>
<Button variant="solid" size="sm" @click="addToCart(product)"
:disabled="product.stock === 0 || !product.is_active" color="info">
<MaterialIcon name="add_shopping_cart" class="mr-1" />
Agregar
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
</CardContent>
</Card>
<!-- Sección del Carrito -->
<Card class="lg:sticky lg:top-6 h-fit">
<CardHeader>
<CardTitle class="flex items-center justify-between">
<div class="flex items-center gap-2">
<MaterialIcon name="shopping_cart" class="mr-2" />
Carrito
</div>
<Badge variant="secondary">{{ cartItemsCount }} productos</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div class="space-y-4">
<!-- Carrito vacío -->
<div v-if="cart.length === 0" class="text-center py-8 text-muted-foreground">
<MaterialIcon name="shopping_cart" class="mx-auto mb-2 opacity-50" />
<p>El carrito está vacío</p>
</div>
<!-- Items del carrito -->
<template v-else>
<div class="space-y-3 max-h-[400px] overflow-y-auto pr-2">
<Card v-for="item in cart" :key="item.id">
<CardContent class="p-3 m-3">
<div class="space-y-2">
<div class="flex justify-between items-start">
<div class="flex-1">
<h4 class="font-medium text-sm">{{ item.name }}</h4>
<p class="text-xs text-muted-foreground">{{ item.sku }}</p>
</div>
<Button variant="smooth" size="sm" color="danger" :iconOnly="true"
@click="removeFromCart(item.id)">
<MaterialIcon name="delete" />
</Button>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" :iconOnly="true"
@click="updateQuantity(item.id, -1)">
<MaterialIcon name="remove" />
</Button>
<span class="text-sm font-medium w-8 text-center">
{{ item.quantity }}
</span>
<Button variant="outline" size="sm" :iconOnly="true"
@click="updateQuantity(item.id, 1)"
:disabled="item.quantity >= item.maxStock">
<MaterialIcon name="add" />
</Button>
</div>
<p class="font-semibold">
${{ (item.price * item.quantity).toFixed(2) }}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
<!-- Separador -->
<div class="border-t pt-4"></div>
<!-- Totales -->
<div class="space-y-2">
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">Subtotal</span>
<span class="font-medium">${{ subtotal.toFixed(2) }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">IVA (16%)</span>
<span class="font-medium">${{ tax.toFixed(2) }}</span>
</div>
<div class="border-t pt-2"></div>
<div class="flex justify-between text-lg font-bold">
<span>Total</span>
<span class="text-primary">${{ total.toFixed(2) }}</span>
</div>
</div>
<!-- Botones de acción -->
<div class="space-y-2 pt-4">
<Button class="w-full" size="lg" color="success" :disabled="cart.length === 0"
@click="openPaymentModal">
<MaterialIcon name="attach_money" class="mr-2" />
Procesar Venta
</Button>
<Button variant="outline" class="w-full" @click="clearCart">
Limpiar Carrito
</Button>
</div>
</template>
</div>
</CardContent>
</Card>
</div>
<!-- Modal de Pago -->
<ModalShow ref="paymentModalRef" title="Procesar Pago">
<div class="space-y-6 p-6">
<!-- Total a Pagar -->
<Card>
<CardContent class="p-4 m-4">
<div class="flex justify-between items-center">
<span class="text-lg font-semibold">Total a Pagar</span>
<span class="text-3xl font-bold text-primary">${{ total.toFixed(2) }}</span>
</div>
</CardContent>
</Card>
<!-- Método de Pago -->
<div class="space-y-3">
<label class="text-sm font-medium">Método de Pago</label>
<div class="grid grid-cols-2 gap-3">
<!-- Efectivo -->
<div
class="border rounded-lg p-4 cursor-pointer transition-all"
:class="paymentMethod === 'cash' ? 'border-green-500 bg-green-50 dark:bg-green-950' : 'border-gray-200 hover:bg-gray-50'"
@click="selectPaymentMethod('cash')"
>
<div class="flex items-center gap-3">
<div
class="w-5 h-5 rounded-full border-2 flex items-center justify-center"
:class="paymentMethod === 'cash' ? 'border-green-600' : 'border-gray-300'"
>
<div
v-if="paymentMethod === 'cash'"
class="w-3 h-3 rounded-full bg-green-600"
></div>
</div>
<MaterialIcon name="payments" class="text-green-600" />
<span class="font-medium">Efectivo</span>
</div>
</div>
<!-- Tarjeta -->
<div
class="border rounded-lg p-4 cursor-pointer transition-all"
:class="paymentMethod === 'credit_card' ? 'border-blue-500 bg-blue-50 dark:bg-blue-950' : 'border-gray-200 hover:bg-gray-50'"
@click="selectPaymentMethod('credit_card')"
>
<div class="flex items-center gap-3">
<div
class="w-5 h-5 rounded-full border-2 flex items-center justify-center"
:class="paymentMethod === 'credit_card' ? 'border-blue-600' : 'border-gray-300'"
>
<div
v-if="paymentMethod === 'credit_card'"
class="w-3 h-3 rounded-full bg-blue-600"
></div>
</div>
<MaterialIcon name="credit_card" class="text-blue-600" />
<span class="font-medium">Tarjeta</span>
</div>
</div>
</div>
</div>
<!-- Monto Recibido (Solo para Efectivo) -->
<div v-if="paymentMethod === 'cash'" class="space-y-2">
<label for="amount-received" class="text-sm font-medium">Monto Recibido</label>
<Input
id="amount-received"
v-model="amountReceived"
type="number"
step="0.01"
placeholder="0.00"
class="w-full"
/>
</div>
<!-- Cálculo de Cambio -->
<Card
v-if="paymentMethod === 'cash' && amountReceived && parseFloat(amountReceived) >= total"
class="bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-800"
>
<CardContent class="p-4 m-4">
<div class="flex justify-between items-center">
<span class="font-semibold text-green-900 dark:text-green-100">Cambio</span>
<span class="text-2xl font-bold text-green-700 dark:text-green-300">
${{ change.toFixed(2) }}
</span>
</div>
</CardContent>
</Card>
</div>
<!-- Botones del Footer -->
<template #buttons>
<Button @click="processSale" color="success">
<MaterialIcon name="check_circle" class="mr-2" />
Confirmar Venta
</Button>
</template>
</ModalShow>
<!-- Modal de Corte de Caja -->
<ModalShow ref="cashCloseModalRef" title="Corte de Caja">
<div class="space-y-6 p-6">
<!-- Resumen de Ventas -->
<div class="space-y-4">
<h3 class="font-semibold text-lg">Resumen de Ventas</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card>
<CardContent class="p-4 m-4">
<p class="text-sm text-muted-foreground">Total Ventas</p>
<p class="text-2xl font-bold">{{ totalSalesCount }}</p>
</CardContent>
</Card>
<Card>
<CardContent class="p-4 m-4">
<p class="text-sm text-muted-foreground">Ventas Efectivo</p>
<p class="text-2xl font-bold text-green-600">${{ totalCashSales.toFixed(2) }}</p>
</CardContent>
</Card>
<Card>
<CardContent class="p-4 m-4">
<p class="text-sm text-muted-foreground">Ventas Tarjeta</p>
<p class="text-2xl font-bold text-blue-600">${{ totalCardSales.toFixed(2) }}</p>
</CardContent>
</Card>
<Card>
<CardContent class="p-4 m-4">
<p class="text-sm text-muted-foreground">Total Egresos</p>
<p class="text-2xl font-bold text-red-600">${{ totalExpenses.toFixed(2) }}</p>
</CardContent>
</Card>
</div>
<Card class="bg-blue-50 dark:bg-blue-950 border-blue-200">
<CardContent class="p-4 m-4">
<p class="text-sm text-muted-foreground">Efectivo Esperado en Caja</p>
<p class="text-3xl font-bold text-blue-600">${{ expectedCash.toFixed(2) }}</p>
<p class="text-xs text-muted-foreground mt-1">
(Ventas en efectivo - Egresos)
</p>
</CardContent>
</Card>
</div>
<!-- Detalle de Ventas -->
<div v-if="sales.length > 0" class="space-y-2">
<h3 class="font-semibold text-lg">Detalle de Ventas</h3>
<div class="border rounded-md p-4 max-h-[200px] overflow-y-auto">
<div class="space-y-2">
<div
v-for="sale in sales"
:key="sale.id"
class="flex justify-between items-center p-2 border-b last:border-b-0"
>
<div>
<p class="font-medium">{{ sale.id }}</p>
<p class="text-xs text-muted-foreground">
{{ sale.timestamp.toLocaleTimeString() }} -
{{ sale.items.length }} items -
{{ sale.paymentMethod === 'cash' ? 'Efectivo' : 'Tarjeta' }}
</p>
</div>
<p class="font-semibold">${{ sale.total.toFixed(2) }}</p>
</div>
</div>
</div>
</div>
<!-- Detalle de Egresos -->
<div v-if="expenses.length > 0" class="space-y-2">
<h3 class="font-semibold text-lg">Detalle de Egresos</h3>
<div class="border rounded-md p-4 max-h-[150px] overflow-y-auto">
<div class="space-y-2">
<div
v-for="expense in expenses"
:key="expense.id"
class="flex justify-between items-center p-2 border-b last:border-b-0"
>
<div>
<p class="font-medium">{{ expense.description }}</p>
<p class="text-xs text-muted-foreground">
{{ expense.timestamp.toLocaleTimeString() }}
</p>
</div>
<p class="font-semibold text-red-600">-${{ expense.amount.toFixed(2) }}</p>
</div>
</div>
</div>
</div>
<!-- Efectivo Contado -->
<div class="space-y-2">
<label for="cash-counted" class="text-sm font-medium">Efectivo Contado</label>
<Input
id="cash-counted"
v-model="cashCounted"
type="number"
step="0.01"
placeholder="0.00"
class="w-full"
/>
</div>
<!-- Diferencia -->
<Card
v-if="cashCounted"
:class="{
'bg-green-50 dark:bg-green-950 border-green-200': cashDifference === 0,
'bg-red-50 dark:bg-red-950 border-red-200': cashDifference < 0,
'bg-blue-50 dark:bg-blue-950 border-blue-200': cashDifference > 0
}"
>
<CardContent class="p-4 m-4">
<div class="flex justify-between items-center">
<span class="font-semibold">
{{ cashDifference === 0 ? 'Cuadrado ✓' : cashDifference < 0 ? 'Faltante' : 'Sobrante' }}
</span>
<span class="text-xl font-bold">
${{ Math.abs(cashDifference).toFixed(2) }}
</span>
</div>
</CardContent>
</Card>
</div>
<!-- Botones del Footer -->
<template #buttons>
<Button @click="handleCashClose" color="success">
<MaterialIcon name="check_circle" class="mr-2" />
Confirmar Corte
</Button>
</template>
</ModalShow>
<!-- Modal de Egresos -->
<ModalShow ref="expenseModalRef" title="Registrar Egreso">
<div class="space-y-6 p-6">
<div class="space-y-4">
<!-- Descripción -->
<div class="space-y-2">
<label for="expense-description" class="text-sm font-medium">
Descripción del Egreso
</label>
<Input
id="expense-description"
v-model="expenseDescription"
type="text"
placeholder="Ej: Compra de suministros, pago a proveedor, servicios..."
class="w-full"
/>
<p class="text-xs text-muted-foreground">
Describe brevemente el motivo del egreso
</p>
</div>
<!-- Monto -->
<div class="space-y-2">
<label for="expense-amount" class="text-sm font-medium">
Monto
</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
$
</span>
<Input
id="expense-amount"
v-model="expenseAmount"
type="number"
step="0.01"
placeholder="0.00"
class="w-full pl-7"
/>
</div>
<p class="text-xs text-muted-foreground">
Ingresa el monto exacto del egreso
</p>
</div>
<!-- Información adicional -->
<Card class="bg-yellow-50 dark:bg-yellow-950 border-yellow-200">
<CardContent class="p-4 m-4">
<div class="flex items-start gap-3">
<MaterialIcon name="info" class="text-yellow-600 mt-0.5" />
<div class="text-sm">
<p class="font-semibold text-yellow-900 dark:text-yellow-100 mb-1">
Información Importante
</p>
<p class="text-yellow-800 dark:text-yellow-200">
Los egresos se descontarán del efectivo esperado en el corte de caja.
Asegúrate de ingresar la información correcta.
</p>
</div>
</div>
</CardContent>
</Card>
<!-- Resumen actual -->
<div v-if="expenses.length > 0" class="space-y-2">
<h4 class="text-sm font-semibold">Egresos Registrados Hoy</h4>
<div class="border rounded-md p-3 max-h-[150px] overflow-y-auto bg-gray-50 dark:bg-gray-900">
<div class="space-y-2">
<div
v-for="expense in expenses"
:key="expense.id"
class="flex justify-between items-start text-sm"
>
<div class="flex-1">
<p class="font-medium">{{ expense.description }}</p>
<p class="text-xs text-muted-foreground">
{{ expense.timestamp.toLocaleTimeString() }}
</p>
</div>
<p class="font-semibold text-red-600 ml-2">
-${{ expense.amount.toFixed(2) }}
</p>
</div>
</div>
<div class="border-t mt-2 pt-2 flex justify-between items-center font-bold">
<span>Total Egresos:</span>
<span class="text-red-600">-${{ totalExpenses.toFixed(2) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Botones del Footer -->
<template #buttons>
<Button
@click="addExpense"
color="danger"
:disabled="!expenseDescription.trim() || !expenseAmount || parseFloat(expenseAmount) <= 0"
>
<MaterialIcon name="receipt_long" class="mr-2" />
Registrar Egreso
</Button>
</template>
</ModalShow>
</div>
</template>

23
src/pages/Pos/Module.js Normal file
View File

@ -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
}

View File

@ -0,0 +1,54 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { api, useForm } from '@Services/Api';
import { apiTo, transl, viewTo } from './Module';
import IconButton from '@Holos/Button/Icon.vue'
import PageHeader from '@Holos/PageHeader.vue';
import Form from './Form.vue'
/** Definidores */
const router = useRouter();
/** Propiedades */
const form = useForm({
code: '',
sku: '',
name: '',
description: '',
attributes: {},
is_active: true,
warehouse_classification_ids: [],
comercial_classification_ids: []
});
/** Métodos */
function submit() {
form.post(apiTo('store'), {
onSuccess: () => {
Notify.success(Lang('register.create.onSuccess'));
router.push(viewTo({ name: 'index' }));
}
});
}
</script>
<template>
<PageHeader :title="transl('create.title')">
<RouterLink :to="viewTo({ name: 'index' })">
<IconButton
class="text-white"
icon="arrow_back"
:title="$t('return')"
filled
/>
</RouterLink>
</PageHeader>
<Form
action="create"
:form="form"
@submit="submit"
/>
</template>

View File

@ -0,0 +1,81 @@
<script setup>
import { onMounted, ref } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import { api, useForm } from '@Services/Api';
import { viewTo, apiTo, transl } from './Module';
import IconButton from '@Holos/Button/Icon.vue'
import PageHeader from '@Holos/PageHeader.vue';
import Form from './Form.vue'
/** Definiciones */
const vroute = useRoute();
const router = useRouter();
/** Propiedades */
const form = useForm({
id: null,
code: '',
sku: '',
name: '',
description: '',
attributes: {},
is_active: true,
warehouse_classification_ids: [],
comercial_classification_ids: []
});
/** Métodos */
function submit() {
form.put(apiTo('update', { product: form.id }), {
onSuccess: () => {
Notify.success(Lang('register.edit.onSuccess'));
router.push(viewTo({ name: 'index' }));
},
});
}
/** Ciclos */
onMounted(() => {
api.get(apiTo('show', { product: vroute.params.id }), {
onSuccess: (r) => {
const product = r.product || r.data?.product;
// Extraer IDs de clasificaciones
const warehouseIds = product.warehouse_classifications?.map(c => c.id) || [];
const comercialIds = product.comercial_classifications?.map(c => c.id) || [];
form.fill({
id: product.id,
code: product.code,
sku: product.sku,
name: product.name,
description: product.description,
attributes: product.attributes || {},
is_active: product.is_active,
warehouse_classification_ids: warehouseIds,
comercial_classification_ids: comercialIds
});
}
});
});
</script>
<template>
<PageHeader :title="transl('edit.title')">
<RouterLink :to="viewTo({ name: 'index' })">
<IconButton
class="text-white"
icon="arrow_back"
:title="$t('return')"
filled
/>
</RouterLink>
</PageHeader>
<Form
action="update"
:form="form"
@submit="submit"
/>
</template>

546
src/pages/Products/Form.vue Normal file
View File

@ -0,0 +1,546 @@
<script setup>
import { ref, onMounted } from 'vue';
import { comercialTo, transl } from './Module';
import ProductService from './services/ProductService';
import { useSearcher } from '@Services/Api';
import PrimaryButton from '@Holos/Button/Primary.vue';
import Input from '@Holos/Form/Input.vue';
import Textarea from '@Holos/Form/Textarea.vue';
import Selectable from '@Holos/Form/Selectable.vue';
import Switch from '@Holos/Form/Switch.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Button from '@Holos/Button/Button.vue';
import Badge from '@Components/ui/Tags/Badge.vue';
import Label from "@Holos/Form/Elements/Label.vue";
/** Eventos */
const emit = defineEmits(['submit']);
/** Propiedades */
const props = defineProps({
action: {
default: 'create',
type: String
},
form: Object
});
/** Referencias */
const productService = new ProductService();
const warehouseClassifications = ref([]);
const comercialClassifications = ref([]);
const loadingClassifications = ref(false);
const newAttrKey = ref('');
const newAttrValue = ref('');
const availableClassifications = ref([]);
const newClassification = ref('');
const newSubclassification = ref('');
const selectedParentForNew = ref(null);
/** Métodos */
function submit() {
emit('submit');
}
/** Agregar valor a un atributo */
function addAttributeValue() {
const key = newAttrKey.value.trim();
const value = newAttrValue.value.trim();
if (!key || !value) {
window.Notify?.warning('El nombre del atributo y el valor son requeridos');
return;
}
if (!props.form.attributes) {
props.form.attributes = {};
}
// Si el atributo no existe, crear array vacío
if (!props.form.attributes[key]) {
props.form.attributes[key] = [];
}
// Evitar valores duplicados en el mismo atributo
if (props.form.attributes[key].includes(value)) {
window.Notify?.warning(`El valor "${value}" ya existe en "${key}"`);
return;
}
// Agregar el valor al atributo
props.form.attributes[key].push(value);
// Solo limpiar el valor, mantener el nombre para seguir agregando
newAttrValue.value = '';
window.Notify?.success(`"${value}" agregado a "${key}"`);
}
/** Remover un atributo completo */
function removeAttributeKey(attrName) {
if (props.form.attributes && props.form.attributes[attrName]) {
delete props.form.attributes[attrName];
window.Notify?.success(`Atributo "${attrName}" eliminado`);
}
}
/** Remover un valor específico de un atributo */
function removeAttributeValue(attrName, value) {
if (props.form.attributes && props.form.attributes[attrName]) {
props.form.attributes[attrName] = props.form.attributes[attrName].filter(v => v !== value);
// Si no quedan valores, eliminar el atributo completo
if (props.form.attributes[attrName].length === 0) {
delete props.form.attributes[attrName];
}
}
}
/** Métodos para clasificaciones comerciales */
const handleAddClassification = (classificationId) => {
if (!props.form.comercial_classification_ids) {
props.form.comercial_classification_ids = [];
}
if (!props.form.comercial_classification_ids.includes(classificationId)) {
props.form.comercial_classification_ids.push(classificationId);
window.Notify?.success('Clasificación agregada correctamente');
}
};
const handleRemoveClassification = (classificationId) => {
if (!props.form.comercial_classification_ids) return;
const index = props.form.comercial_classification_ids.indexOf(classificationId);
if (index > -1) {
props.form.comercial_classification_ids.splice(index, 1);
}
};
const handleCreateNewClassification = () => {
if (!newClassification.value.trim()) {
window.Notify?.error('El nombre de la clasificación es requerido');
return;
}
const newId = Math.max(...availableClassifications.value.map(c => c.id)) + 1;
const newClass = {
id: newId,
name: newClassification.value,
children: []
};
availableClassifications.value.push(newClass);
window.Notify?.success(`"${newClassification.value}" agregada al sistema`);
newClassification.value = '';
};
const handleCreateNewSubclassification = (parentId) => {
if (!newSubclassification.value.trim()) {
window.Notify?.error('El nombre de la subclasificación es requerido');
return;
}
availableClassifications.value = availableClassifications.value.map(parent => {
if (parent.id === parentId) {
const newId = parent.children && parent.children.length > 0
? Math.max(...parent.children.map(c => c.id)) + 1
: parentId * 10 + 1;
const newChild = {
id: newId,
name: newSubclassification.value,
parent_id: parentId
};
return {
...parent,
children: [...(parent.children || []), newChild]
};
}
return parent;
});
window.Notify?.success(`"${newSubclassification.value}" agregada`);
newSubclassification.value = '';
selectedParentForNew.value = null;
};
const getClassificationName = (id) => {
for (const parent of availableClassifications.value) {
if (parent.id === id) return parent.name;
if (parent.children) {
const child = parent.children.find(c => c.id === id);
if (child) return `${parent.name} > ${child.name}`;
}
}
return "Desconocida";
};
/** Cargar clasificaciones de almacén */
async function loadClassifications() {
loadingClassifications.value = true;
try {
const warehouse = await productService.getWarehouseClassifications();
warehouseClassifications.value = warehouse || [];
} catch (error) {
console.error('Error cargando clasificaciones de almacén:', error);
window.Notify?.error('Error al cargar las clasificaciones de almacén');
} finally {
loadingClassifications.value = false;
}
}
/** Cargar atributos existentes al editar */
function loadExistingAttributes() {
console.log('Atributos cargados:', props.form.attributes);
}
const searcherComercial = useSearcher({
url: comercialTo('index'),
onSuccess: (r) => {
console.log('🔍 Respuesta completa (r):', r);
console.log('🔍 r.comercial_classifications:', r.comercial_classifications);
console.log('🔍 r.comercial_classifications.data:', r.comercial_classifications?.data);
// La respuesta viene en r.comercial_classifications.data
const classificationsData = r?.comercial_classifications?.data || [];
console.log('✅ classificationsData extraído:', classificationsData);
console.log('✅ Es array?:', Array.isArray(classificationsData));
console.log('✅ Length:', classificationsData.length);
comercialClassifications.value = classificationsData;
availableClassifications.value = classificationsData;
console.log('✅ availableClassifications.value asignado:', availableClassifications.value);
console.log('✅ Total de clasificaciones:', availableClassifications.value.length);
},
onError: (error) => {
console.error('❌ Error cargando clasificaciones:', error);
comercialClassifications.value = [];
availableClassifications.value = [];
}
});
/** Ciclos */
onMounted(() => {
loadClassifications();
searcherComercial.search(); // Cargar clasificaciones comerciales
if (props.action === 'update') {
loadExistingAttributes();
}
// Inicializar array de clasificaciones comerciales si no existe
if (!props.form.comercial_classification_ids) {
props.form.comercial_classification_ids = [];
}
});
</script>
<template>
<div class="w-full pb-2">
<p class="text-justify text-sm" v-text="transl(`${action}.description`)" />
</div>
<div class="w-full">
<form @submit.prevent="submit" class="space-y-6">
<!-- Información básica -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div class="flex items-center mb-4">
<GoogleIcon name="info" class="text-blue-500 text-2xl mr-2" />
<h3 class="text-lg font-semibold">{{ $t('Información Básica') }}</h3>
</div>
<div class="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
<Input v-model="form.code" id="code" :label="$t('codes')" :onError="form.errors.code" autofocus
required />
<Input v-model="form.sku" id="SKU" label="SKU" :onError="form.errors.sku" required />
<Input v-model="form.name" id="name" :label="$t('name')" :onError="form.errors.name" required />
<Input v-model="form.barcode" id="CÓDIGO DE BARRAS" :label="$t('CÓDIGO DE BARRAS')"
:onError="form.errors.barcode" required />
<div class="md:col-span-2 lg:col-span-3">
<Textarea v-model="form.description" id="description" :label="$t('description')"
:onError="form.errors.description" rows="3" />
</div>
<div class="flex items-center justify-between p-4 border rounded-lg">
<div>
<label for="">Estado del producto</label>
<p class="text-sm text-muted-foreground">
Activar o desactivar el producto en el catálogo
</p>
</div>
<Switch v-model:checked="form.is_active" />
</div>
</div>
</div>
<!-- Atributos dinámicos -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div class="flex items-center mb-4">
<GoogleIcon name="tune" class="text-green-500 text-2xl mr-2" />
<h3 class="text-lg font-semibold">{{ $t('Atributos del Producto') }}</h3>
</div>
<div class="space-y-3 p-4 border rounded-lg bg-muted/20">
<div class="flex items-center justify-between">
<div>
<Label class="text-base">Atributos del Producto</Label>
<p class="text-sm text-muted-foreground">
Define atributos (ej: Color) y agrega múltiples valores (Negro, Azul, Rojo)
</p>
</div>
</div>
<div class="space-y-2">
<label class="text-sm font-medium">Agregar Atributo y Valores</label>
<div class="flex gap-2">
<div class="flex-1">
<Input v-model="newAttrKey"
placeholder="Nombre del atributo (ej: Color, Procesador, RAM)" />
</div>
<div class="flex-1 flex gap-2">
<Input v-model="newAttrValue"
placeholder="Valor (ej: Negro) - presiona Enter o + para agregar"
@keypress.enter.prevent="addAttributeValue" />
<Button color="info" variant="solid" @click="addAttributeValue">
<GoogleIcon name="add" class="mr-1" />
{{ $t('Agregar') }}
</Button>
</div>
</div>
<p v-if="newAttrKey" class="text-xs text-muted-foreground ml-1">
💡 Agregando valores al atributo: <span class="font-semibold">{{ newAttrKey }}</span>
</p>
</div>
<!-- Mostrar atributos configurados -->
<div v-if="form.attributes && Object.keys(form.attributes).length > 0"
class="space-y-3 mt-4 pt-4 border-t">
<label class="text-sm font-medium">Atributos Configurados</label>
<div v-for="(values, attrName) in form.attributes" :key="attrName"
class="space-y-2 p-3 border rounded-lg bg-background">
<div class="flex items-center justify-between">
<label class="text-sm font-semibold text-primary">{{ attrName }}</label>
<button type="button" @click="removeAttributeKey(attrName)"
class="px-2 py-1 text-xs text-red-600 hover:text-red-700 hover:bg-red-50 rounded transition-colors flex items-center gap-1">
<GoogleIcon name="delete" class="text-sm" />
Eliminar atributo
</button>
</div>
<div class="flex flex-wrap gap-2">
<Badge v-for="value in values" :key="value" color="default"
class="flex items-center gap-1">
{{ value }}
<GoogleIcon name="close" class="text-xs cursor-pointer hover:text-red-600"
@click="removeAttributeValue(attrName, value)" />
</Badge>
</div>
</div>
</div>
<!-- Estado vacío -->
<div v-else class="text-center py-8 text-gray-500">
<GoogleIcon name="settings" class="text-4xl mb-2" />
<p class="text-sm">No hay atributos personalizados</p>
<p class="text-xs">Haz clic en "Agregar" para crear uno</p>
</div>
</div>
</div>
<!-- Clasificaciones -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div class="grid gap-4 grid-cols-1">
<!-- Clasificaciones Comerciales con jerarquía -->
<div class="space-y-4 p-4 border rounded-lg bg-muted/20">
<div>
<Label class="text-base">Clasificaciones Comerciales</Label>
<p class="text-sm text-muted-foreground">
Selecciona clasificaciones y subclasificaciones, o crea nuevas
</p>
</div>
<!-- Estado de carga -->
<div v-if="searcherComercial.loading" class="text-center py-8">
<GoogleIcon name="sync" class="text-4xl text-blue-500 animate-spin mb-2" />
<p class="text-sm text-muted-foreground">Cargando clasificaciones...</p>
</div>
<!-- Lista de clasificaciones disponibles con jerarquía -->
<div v-else-if="availableClassifications.length > 0" class="space-y-3">
<div v-for="parent in availableClassifications" :key="parent.id"
class="space-y-2 p-3 border rounded-lg bg-background">
<!-- Clasificación Principal -->
<div class="flex items-center justify-between">
<Button
type="button"
:color="form.comercial_classification_ids?.includes(parent.id) ? 'success' :'info'"
:variant="form.comercial_classification_ids?.includes(parent.id) ? 'solid' : 'outline'"
size="sm"
@click="form.comercial_classification_ids?.includes(parent.id)
? handleRemoveClassification(parent.id)
: handleAddClassification(parent.id)"
class="font-semibold"
>
{{ parent.name }}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
@click="selectedParentForNew = selectedParentForNew === parent.id ? null : parent.id"
>
<GoogleIcon name="add" class="mr-1" />
Agregar subclasificación
</Button>
</div>
<!-- Subclasificaciones -->
<div v-if="parent.children && parent.children.length > 0"
class="flex flex-wrap gap-2 ml-4 pl-4 border-l-2">
<Button
v-for="child in parent.children"
:key="child.id"
type="button"
:color="form.comercial_classification_ids?.includes(child.id) ? 'success' :'warning'"
:variant="form.comercial_classification_ids?.includes(child.id) ? 'solid' : 'outline'"
size="sm"
@click="form.comercial_classification_ids?.includes(child.id)
? handleRemoveClassification(child.id)
: handleAddClassification(child.id)"
>
{{ child.name }}
<GoogleIcon v-if="form.comercial_classification_ids?.includes(child.id)"
name="close" class="ml-2 text-xs" />
</Button>
</div>
<!-- Formulario para agregar subclasificación -->
<div v-if="selectedParentForNew === parent.id" class="flex gap-2 ml-4 mt-2">
<Input
v-model="newSubclassification"
placeholder="Nombre de la subclasificación"
@keypress.enter.prevent="handleCreateNewSubclassification(parent.id)"
class="flex-1"
/>
<Button
type="button"
@click="handleCreateNewSubclassification(parent.id)"
size="sm"
color="primary"
>
<GoogleIcon name="add" />
</Button>
<Button
type="button"
@click="() => { selectedParentForNew = null; newSubclassification = ''; }"
size="sm"
variant="ghost"
>
<GoogleIcon name="close" />
</Button>
</div>
</div>
</div>
<!-- Estado vacío -->
<div v-else class="text-center py-8 text-muted-foreground">
<GoogleIcon name="category" class="text-4xl mb-2" />
<p class="text-sm">No hay clasificaciones disponibles</p>
<p class="text-xs mt-1">Crea una nueva clasificación para comenzar</p>
<p class="text-xs mt-2 text-red-500">Debug: {{ availableClassifications.length }} items</p>
</div>
<!-- Crear nueva clasificación principal -->
<div v-if="!searcherComercial.loading" class="flex gap-2 pt-2 border-t">
<Input
v-model="newClassification"
placeholder="Nueva clasificación principal"
@keypress.enter.prevent="handleCreateNewClassification"
/>
<Button
type="button"
@click="handleCreateNewClassification"
size="sm"
color="info"
>
<GoogleIcon name="add" class="mr-2" />
Crear
</Button>
</div>
<!-- Clasificaciones seleccionadas -->
<div v-if="form.comercial_classification_ids && form.comercial_classification_ids.length > 0" class="pt-3 border-t">
<Label class="text-sm">Seleccionadas ({{ form.comercial_classification_ids.length }}):</Label>
<div class="flex flex-wrap gap-2 mt-2">
<Badge
v-for="classId in form.comercial_classification_ids"
:key="classId"
color="primary"
class="flex items-center gap-1"
>
{{ getClassificationName(classId) }}
<GoogleIcon
name="close"
class="text-xs cursor-pointer hover:text-red-600"
@click="handleRemoveClassification(classId)"
/>
</Badge>
</div>
</div>
<!-- Mensaje cuando no hay seleccionadas -->
<div v-else class="text-center py-6 text-muted-foreground text-sm">
<GoogleIcon name="category" class="text-4xl mb-2" />
<p>No hay clasificaciones seleccionadas</p>
<p class="text-xs mt-1">Selecciona clasificaciones para organizar tu producto</p>
</div>
</div>
</div>
</div>
<!-- Slot para campos adicionales -->
<slot />
<!-- Botón de submit -->
<div class="flex justify-center">
<PrimaryButton v-text="$t(action)" :class="{ 'opacity-25': form.processing }"
:disabled="form.processing" class="px-8 py-3" />
</div>
</form>
</div>
</template>
<style scoped>
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>

View File

@ -1,32 +1,50 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useSearcher } from '@Services/Api';
import { can, apiTo, viewTo, transl } from './Module'
import IconButton from '@Holos/Button/Icon.vue'
import DestroyView from '@Holos/Modal/Template/Destroy.vue';
import SearcherHead from '@Holos/Searcher.vue';
import Table from '@Components/ui/Table/Table.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import ShowView from './Modals/Show.vue';
import Button from '@Holos/Button/Button.vue';
import Card from '@Holos/Card/Card.vue';
import CardContent from '@Holos/Card/CardContent.vue';
import CardHeader from '@Holos/Card/CardHeader.vue';
import CardTitle from '@Holos/Card/CardTitle.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Table from '@Holos/Table.vue';
import IconButton from '@Holos/Button/Icon.vue';
import Badge from '@Components/ui/Tags/Badge.vue';
import MaterialIcon from '@Components/ui/Icons/MaterialIcon.vue';
import { can, apiTo, viewTo, transl } from './Module'
import { useSearcher } from '@Services/Api';
import Input from '@Components/ui/Input.vue';
const models = ref([]);
/** Propiedades */
const models = ref();
const router = useRouter();
/** Configuración de paginación */
const pagination = ref({
currentPage: 1,
pageSize: 10,
totalItems: 0
});
/** Referencias */
const showModal = ref(false);
const destroyModal = ref(false);
/** Métodos */
const searcher = useSearcher({
url: apiTo('index'),
onSuccess: (r) => {
console.log('Datos recibidos:', r);
// Según la estructura que muestras, los productos están en r.data.products.data
models.value = r.data?.products?.data || r.products || [];
// Si hay productos de la API, los usamos
models.value = r.products;
// Actualizar paginación con el total de items
pagination.value.totalItems = r.products?.data?.length || 0;
},
onError: (error) => {
console.error('Error cargando productos:', error);
models.value = [];
console.error(error);
// En caso de error, usamos datos de prueba
}
});
@ -34,28 +52,70 @@ const searcher = useSearcher({
const formatAttributes = (attributes) => {
if (!attributes) return '-';
const formatted = Object.entries(attributes).map(([key, value]) => {
if (Array.isArray(value)) {
return `${key}`;
}
const formatted = Object.entries(attributes).map(([key]) => {
return `${key}`;
}).join(' | ');
return formatted.length > 50 ? formatted.substring(0, 50) + '...' : formatted;
};
/** Función para mostrar clasificaciones */
const formatClassifications = (classifications) => {
if (!classifications || classifications.length === 0) return '-';
return classifications.map(c => c.name).join(', ');
};
/** Configuración de columnas para la tabla */
const columns = ref([
{
key: 'code',
label: 'Código',
sortable: true,
width: '120px'
},
{
key: 'sku',
label: 'SKU',
sortable: true,
width: '120px'
},
{
key: 'name',
label: 'Nombre',
sortable: true
},
{
key: 'description',
label: 'Descripción',
sortable: false
},
{
key: 'attributes',
label: 'Atributos',
sortable: false,
formatter: (value) => formatAttributes(value)
},
{
key: 'is_active',
label: 'Estado',
sortable: true,
width: '100px'
},
{
key: 'created_at',
label: 'Fecha Creación',
sortable: true,
width: '140px'
},
{
key: 'actions',
label: 'Acciones',
sortable: false,
width: '140px'
}
]);
/** Ciclos */
onMounted(() => {
searcher.search();
})
</script>
<template>
@ -67,27 +127,31 @@ onMounted(() => {
Gestión del catálogo de productos
</p>
</div>
<RouterLink
<Button color="info" @click="$router.push({ name: 'products.create' })">
Nuevo Producto
:to="viewTo({ name: 'create' })"
>
<!-- v-if="can('create')" -->
<Button color="info">
<MaterialIcon name="add" class="mr-2" />
Nuevo producto
</Button>
</RouterLink>
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardContent class="p-4">
<div class="flex items-center space-x-2">
<GoogleIcon class="w-8 h-8 text-primary" name="inventory_2" />
<MaterialIcon name="inventory_2" class="h-8 w-8" />
<div>
<p class="text-2xl font-bold text-metric-value">150</p>
<p class="text-sm text-muted-foreground">3</p>
<p class="text-sm text-muted-foreground">Total Productos</p>
</div>
</div>
</CardContent>
</Card>
</div>
<Card>
@ -96,133 +160,38 @@ onMounted(() => {
</CardHeader>
<CardContent>
<div class="flex items-center space-x-4 mb-6">
<div class="relative flex-1 max-w-sm">
<GoogleIcon class="absolute left-3 top-2 h-4 w-4 text-muted-foreground" name="search" />
<Input
placeholder="Buscar productos..."
class="pl-10"
@input="(e) => searcher.search({ search: e.target.value })"
/>
<p>Buscador</p>
</div>
<Button variant="outline" class="flex items-center">
<GoogleIcon class="w-4 h-4 mr-2" name="filter_list" />
Filtros
<Table v-if="models?.data" :columns="columns" :data="models?.data" :loading="false" :sortable="true"
:pagination="pagination" emptyMessage="No hay productos disponibles">
<template #cell-attributes="{ value }">
<div class="flex gap-1 flex-wrap">
<Badge v-for="(val, key) in value" :key="key" :dot="false">
{{ key }}
</Badge>
</div>
</template>
<!-- Slot personalizado para la columna is_active -->
<template #cell-is_active="{ value, row }">
<Badge :color="value ? 'green' : 'red'">
{{ value ? 'Activo' : 'Inactivo' }}
</Badge>
</template>
<template #cell-actions="{ row }">
<div class="flex space-x-2">
<Button size="sm" color="info" variant="smooth" @click="showModal = true" :iconOnly="true">
<MaterialIcon name="visibility" :size="16"/>
</Button>
<Button size="sm" color="warning" variant="smooth" @click="showModal = true" :iconOnly="true">
<MaterialIcon name="edit" :size="16"/>
</Button>
<Button size="sm" color="danger" variant="smooth" @click="showModal = true" :iconOnly="true">
<MaterialIcon name="delete" :size="16"/>
</Button>
</div>
<Table
:items="models"
:processing="searcher.processing"
@send-pagination="(page) => searcher.pagination(page)"
>
<template #head>
<th>{{ $t('code') }}</th>
<th>SKU</th>
<th>{{ $t('name') }}</th>
<th>{{ $t('description') }}</th>
<th>Atributos</th>
<th>Clasificaciones</th>
<th class="w-20 text-center">{{ $t('status') }}</th>
<th class="w-32 text-center">{{ $t('actions') }}</th>
</template>
<template #body="{ items }">
<tr v-for="product in items" :key="product.id" class="table-row">
<td class="table-cell">
<div class="flex items-center">
<GoogleIcon name="inventory" class="text-blue-500 mr-2" />
<code class="font-mono text-sm bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded">
{{ product.code }}
</code>
</div>
</td>
<td class="table-cell">
<span class="font-mono text-sm text-gray-600 dark:text-gray-400">
{{ product.sku }}
</span>
</td>
<td class="table-cell">
<span class="font-semibold">{{ product.name }}</span>
</td>
<td class="table-cell">
<span class="text-sm text-gray-600 dark:text-gray-400">
{{ product.description || '-' }}
</span>
</td>
<td class="table-cell">
<span class="text-xs text-gray-500 dark:text-gray-400" :title="JSON.stringify(product.attributes)">
{{ formatAttributes(product.attributes) }}
</span>
</td>
<td class="table-cell">
<div class="flex flex-wrap gap-1">
<span
v-for="classification in product.classifications"
:key="classification.id"
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-800 dark:text-purple-100"
>
{{ classification.name }}
</span>
<span v-if="!product.classifications || product.classifications.length === 0" class="text-gray-400 text-xs">
Sin clasificar
</span>
</div>
</td>
<td class="table-cell text-center">
<span
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium"
:class="product.is_active
? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100'
: 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100'"
>
{{ product.is_active ? $t('active') : $t('inactive') }}
</span>
</td>
<td class="table-cell">
<div class="table-actions">
<IconButton
icon="visibility"
:title="$t('crud.show')"
outline
/>
<IconButton
icon="edit"
:title="$t('crud.edit')"
outline
@click="$router.push({ name: 'products.edit', params: { id: product.id } })"
/>
<IconButton
icon="delete"
:title="$t('crud.destroy')"
outline
/>
</div>
</td>
</tr>
</template>
<template #empty>
<td class="table-cell" colspan="8">
<div class="flex flex-col items-center text-center py-8">
<GoogleIcon name="inventory" class="text-4xl text-gray-400 mb-2" />
<p class="font-semibold text-gray-600 dark:text-gray-400">
{{ $t('registers.empty') }}
</p>
<p class="text-sm text-gray-500 dark:text-gray-500">
No se encontraron productos
</p>
</div>
</td>
</template>
</Table>
</CardContent>
</Card>
</div>

View File

@ -0,0 +1,342 @@
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { getDateTime } from '@Controllers/DateController';
import { viewTo, apiTo } from '../Module';
import ProductService from '../services/ProductService';
import Notify from '@Plugins/Notify';
import Header from '@Holos/Modal/Elements/Header.vue';
import ShowModal from '@Holos/Modal/Show.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Button from '@Holos/Button/Button.vue';
import IconButton from '@Holos/Button/Icon.vue';
/** Eventos */
const emit = defineEmits([
'close',
'reload'
]);
/** Servicios */
const productService = new ProductService();
const router = useRouter();
/** Propiedades */
const model = ref(null);
const loading = ref(false);
/** Referencias */
const modalRef = ref(null);
/** Métodos */
function close() {
model.value = null;
emit('close');
}
/** Función para actualizar el estado del producto */
async function toggleStatus(item) {
if (loading.value) return;
const newStatus = !item.is_active;
try {
loading.value = true;
// Usar el servicio para actualizar el estado
await productService.updateStatus(item.id, newStatus);
// Actualizar el modelo local
item.is_active = newStatus;
// Notificación de éxito
const statusText = newStatus ? 'activado' : 'desactivado';
Notify.success(
`Producto "${item.code}" ${statusText} exitosamente`,
'Estado actualizado'
);
// Emitir evento para recargar la lista principal si es necesario
emit('reload');
} catch (error) {
console.error('Error actualizando estado:', error);
// Manejo específico de errores según la estructura de tu API
let errorMessage = 'Error al actualizar el estado del producto';
let errorTitle = 'Error';
if (error?.response?.data) {
const errorData = error.response.data;
// Caso 1: Error con estructura específica de tu API
if (errorData.status === 'error') {
if (errorData.errors) {
// Errores de validación - extraer el primer error
const firstField = Object.keys(errorData.errors)[0];
const firstError = errorData.errors[firstField];
errorMessage = Array.isArray(firstError) ? firstError[0] : firstError;
errorTitle = 'Error de validación';
} else if (errorData.message) {
// Mensaje general del error
errorMessage = errorData.message;
errorTitle = 'Error del servidor';
}
}
// Caso 2: Otros formatos de error
else if (errorData.message) {
errorMessage = errorData.message;
}
} else if (error?.message) {
// Error genérico de la petición (red, timeout, etc.)
errorMessage = `Error de conexión: ${error.message}`;
errorTitle = 'Error de red';
}
// Notificación de error
Notify.error(errorMessage, errorTitle);
} finally {
loading.value = false;
}
}
/** Función para editar producto */
function editProduct() {
const editUrl = viewTo({ name: 'edit', params: { id: model.value.id } });
router.push(editUrl);
close();
}
/** Función para duplicar producto */
async function duplicateProduct() {
if (loading.value) return;
try {
loading.value = true;
await productService.duplicate(model.value.id);
Notify.success(
`Producto "${model.value.code}" duplicado exitosamente`,
'Producto duplicado'
);
emit('reload');
close();
} catch (error) {
console.error('Error duplicando producto:', error);
Notify.error('Error al duplicar el producto');
} finally {
loading.value = false;
}
}
/** Función para formatear atributos */
const formatAttributesDisplay = (attributes) => {
if (!attributes || typeof attributes !== 'object') return [];
return Object.entries(attributes).map(([key, value]) => ({ key, value }));
};
/** Exposiciones */
defineExpose({
open: (data) => {
model.value = data;
modalRef.value.open();
}
});
</script>
<template>
<ShowModal
ref="modalRef"
@close="close"
>
<div v-if="model">
<Header
:title="model.code"
:subtitle="model.name"
>
<div class="flex w-full flex-col">
<div class="flex w-full justify-center items-center">
<div class="w-24 h-24 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center">
<GoogleIcon
class="text-white text-3xl"
name="inventory_2"
/>
</div>
</div>
</div>
</Header>
<div class="flex w-full p-4 space-y-6">
<!-- Información básica -->
<div class="w-full space-y-6">
<div class="flex items-start">
<GoogleIcon
class="text-xl text-success mt-1"
name="info"
/>
<div class="pl-3 w-full">
<p class="font-bold text-lg leading-none pb-3">
{{ $t('details') }}
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p>
<b>{{ $t('code') }}: </b>
<code class="font-mono text-sm bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded">
{{ model.code }}
</code>
</p>
<p class="mt-2">
<b>SKU: </b>
<code class="font-mono text-sm bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded">
{{ model.sku }}
</code>
</p>
<p class="mt-2">
<b>{{ $t('name') }}: </b>
{{ model.name }}
</p>
<p class="mt-2" v-if="model.description">
<b>{{ $t('description') }}: </b>
{{ model.description }}
</p>
</div>
<div>
<p>
<b>{{ $t('status') }}: </b>
<Button
:variant="'smooth'"
:color="model.is_active ? 'success' : 'danger'"
:size="'sm'"
:loading="loading"
@click="toggleStatus(model)"
>
{{ model.is_active ? $t('Activo') : $t('Inactivo') }}
</Button>
</p>
<p class="mt-2">
<b>{{ $t('created_at') }}: </b>
{{ getDateTime(model.created_at) }}
</p>
<p class="mt-2">
<b>{{ $t('updated_at') }}: </b>
{{ getDateTime(model.updated_at) }}
</p>
</div>
</div>
</div>
</div>
<!-- Atributos personalizados -->
<div v-if="model.attributes && Object.keys(model.attributes).length > 0" class="pt-6 border-t border-gray-200 dark:border-gray-700">
<div class="flex items-start">
<GoogleIcon
class="text-xl text-warning mt-1"
name="tune"
/>
<div class="pl-3 w-full">
<p class="font-bold text-lg leading-none pb-3">
{{ $t('Atributos Personalizados') }}
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div
v-for="attr in formatAttributesDisplay(model.attributes)"
:key="attr.key"
class="flex items-center p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
>
<div class="flex-1">
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">
{{ attr.key }}
</p>
<p class="font-semibold text-sm">
{{ attr.value }}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Clasificaciones -->
<div v-if="(model.warehouse_classifications && model.warehouse_classifications.length > 0) || (model.comercial_classifications && model.comercial_classifications.length > 0)" class="pt-6 border-t border-gray-200 dark:border-gray-700">
<div class="flex items-start">
<GoogleIcon
class="text-xl text-primary mt-1"
name="category"
/>
<div class="pl-3 w-full">
<p class="font-bold text-lg leading-none pb-3">
{{ $t('Clasificaciones') }}
</p>
<!-- Clasificaciones de Almacén -->
<div v-if="model.warehouse_classifications && model.warehouse_classifications.length > 0" class="mb-4">
<p class="text-sm font-semibold text-gray-600 dark:text-gray-400 mb-2">
Clasificaciones de Almacén
</p>
<div class="flex flex-wrap gap-2">
<span
v-for="classification in model.warehouse_classifications"
:key="'w-' + classification.id"
class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800 dark:bg-blue-800 dark:text-blue-100"
>
<code class="mr-2 text-xs">{{ classification.code }}</code>
{{ classification.name }}
</span>
</div>
</div>
<!-- Clasificaciones Comerciales -->
<div v-if="model.comercial_classifications && model.comercial_classifications.length > 0">
<p class="text-sm font-semibold text-gray-600 dark:text-gray-400 mb-2">
Clasificaciones Comerciales
</p>
<div class="flex flex-wrap gap-2">
<span
v-for="classification in model.comercial_classifications"
:key="'c-' + classification.id"
class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-purple-100 text-purple-800 dark:bg-purple-800 dark:text-purple-100"
>
<code class="mr-2 text-xs">{{ classification.code }}</code>
{{ classification.name }}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- Acciones rápidas -->
<div class="pt-6 border-t border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-center gap-3">
<Button
:variant="'outline'"
:color="'primary'"
:loading="loading"
@click="editProduct"
>
<GoogleIcon name="edit" class="mr-2" />
{{ $t('crud.edit') }}
</Button>
<Button
:variant="'outline'"
:color="'info'"
:loading="loading"
@click="duplicateProduct"
>
<GoogleIcon name="content_copy" class="mr-2" />
{{ $t('Duplicar') }}
</Button>
</div>
</div>
</div>
</div>
</div>
</ShowModal>
</template>

View File

@ -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
}

View File

@ -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
};

View File

@ -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<boolean>} 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)
});
});
}
}

View File

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