feature-comercial-module #9
7
package-lock.json
generated
7
package-lock.json
generated
@ -17,6 +17,7 @@
|
|||||||
"axios": "^1.8.1",
|
"axios": "^1.8.1",
|
||||||
"laravel-echo": "^2.0.2",
|
"laravel-echo": "^2.0.2",
|
||||||
"luxon": "^3.5.0",
|
"luxon": "^3.5.0",
|
||||||
|
"material-symbols": "^0.36.2",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pinia": "^3.0.1",
|
"pinia": "^3.0.1",
|
||||||
"pusher-js": "^8.4.0",
|
"pusher-js": "^8.4.0",
|
||||||
@ -2976,6 +2977,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.0"
|
"@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": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
"axios": "^1.8.1",
|
"axios": "^1.8.1",
|
||||||
"laravel-echo": "^2.0.2",
|
"laravel-echo": "^2.0.2",
|
||||||
"luxon": "^3.5.0",
|
"luxon": "^3.5.0",
|
||||||
|
"material-symbols": "^0.36.2",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pinia": "^3.0.1",
|
"pinia": "^3.0.1",
|
||||||
"pusher-js": "^8.4.0",
|
"pusher-js": "^8.4.0",
|
||||||
|
|||||||
@ -23,7 +23,6 @@ interface Props {
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
iconOnly?: boolean;
|
iconOnly?: boolean;
|
||||||
asLink?: boolean; // Nueva prop para comportamiento de link
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@ -35,7 +34,6 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
loading: false,
|
loading: false,
|
||||||
fullWidth: false,
|
fullWidth: false,
|
||||||
iconOnly: false,
|
iconOnly: false,
|
||||||
asLink: true, // Por defecto no es link
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@ -44,15 +42,8 @@ const emit = defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
function handleClick(event: MouseEvent) {
|
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;
|
if (props.disabled || props.loading) return;
|
||||||
|
emit('click', event);
|
||||||
}
|
}
|
||||||
|
|
||||||
const buttonClasses = computed(() => {
|
const buttonClasses = computed(() => {
|
||||||
@ -82,7 +73,7 @@ const buttonClasses = computed(() => {
|
|||||||
solid: ['shadow-sm'],
|
solid: ['shadow-sm'],
|
||||||
outline: ['border', 'bg-white', 'hover:bg-gray-50'],
|
outline: ['border', 'bg-white', 'hover:bg-gray-50'],
|
||||||
ghost: ['bg-transparent', 'hover:bg-gray-100'],
|
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
|
// Colores por tipo
|
||||||
|
|||||||
49
src/components/ui/Icons/MaterialIcon.vue
Normal file
49
src/components/ui/Icons/MaterialIcon.vue
Normal 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>
|
||||||
117
src/components/ui/Table/Table.vue
Normal file
117
src/components/ui/Table/Table.vue
Normal 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>
|
||||||
56
src/components/ui/Table/TableBody.vue
Normal file
56
src/components/ui/Table/TableBody.vue
Normal 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>
|
||||||
65
src/components/ui/Table/TableHeader.vue
Normal file
65
src/components/ui/Table/TableHeader.vue
Normal 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>
|
||||||
148
src/components/ui/Table/TablePagination.vue
Normal file
148
src/components/ui/Table/TablePagination.vue
Normal 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>
|
||||||
60
src/components/ui/Table/composables/usePagination.js
Normal file
60
src/components/ui/Table/composables/usePagination.js
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
50
src/components/ui/Table/composables/useSort.js
Normal file
50
src/components/ui/Table/composables/useSort.js
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
109
src/components/ui/Tags/Badge.vue
Normal file
109
src/components/ui/Tags/Badge.vue
Normal 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>
|
||||||
@ -75,3 +75,58 @@
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
src: url(./icons/google/gNNBW2J8Roq16WD5tFNRaeLQk6-SHQ_R00k4c2_whPnoY9ruReYU3rHmz74m0ZkGH-VBYe1x0TV6x4yFH8F-H5OdzEL3sVTgJtfbYxOLojCL.woff2) format('woff2');
|
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';
|
||||||
|
}
|
||||||
|
|||||||
@ -78,6 +78,19 @@ export default {
|
|||||||
activity: {
|
activity: {
|
||||||
title: 'Historial de acciones',
|
title: 'Historial de acciones',
|
||||||
description: 'Historial de acciones realizadas por los usuarios en orden cronológico.'
|
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: {
|
app: {
|
||||||
@ -193,6 +206,8 @@ export default {
|
|||||||
done:'Hecho.',
|
done:'Hecho.',
|
||||||
edit:'Editar',
|
edit:'Editar',
|
||||||
edited:'Registro creado',
|
edited:'Registro creado',
|
||||||
|
active:'Activo',
|
||||||
|
inactive:'Inactivo',
|
||||||
email:{
|
email:{
|
||||||
title:'Correo',
|
title:'Correo',
|
||||||
verification:'Verificar correo'
|
verification:'Verificar correo'
|
||||||
|
|||||||
@ -105,6 +105,11 @@ onMounted(() => {
|
|||||||
name="Productos"
|
name="Productos"
|
||||||
to="admin.products.index"
|
to="admin.products.index"
|
||||||
/>
|
/>
|
||||||
|
<Link
|
||||||
|
icon="sell"
|
||||||
|
name="Punto de venta"
|
||||||
|
to="admin.pos.index"
|
||||||
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section name="Capacitaciones">
|
<Section name="Capacitaciones">
|
||||||
|
|||||||
851
src/pages/Pos/Index.vue
Normal file
851
src/pages/Pos/Index.vue
Normal 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
23
src/pages/Pos/Module.js
Normal 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
|
||||||
|
}
|
||||||
54
src/pages/Products/Create.vue
Normal file
54
src/pages/Products/Create.vue
Normal 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>
|
||||||
81
src/pages/Products/Edit.vue
Normal file
81
src/pages/Products/Edit.vue
Normal 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
546
src/pages/Products/Form.vue
Normal 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>
|
||||||
@ -1,32 +1,50 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
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 Button from '@Holos/Button/Button.vue';
|
||||||
import Card from '@Holos/Card/Card.vue';
|
import Card from '@Holos/Card/Card.vue';
|
||||||
import CardContent from '@Holos/Card/CardContent.vue';
|
import CardContent from '@Holos/Card/CardContent.vue';
|
||||||
import CardHeader from '@Holos/Card/CardHeader.vue';
|
import CardHeader from '@Holos/Card/CardHeader.vue';
|
||||||
import CardTitle from '@Holos/Card/CardTitle.vue';
|
import CardTitle from '@Holos/Card/CardTitle.vue';
|
||||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
import Badge from '@Components/ui/Tags/Badge.vue';
|
||||||
import Table from '@Holos/Table.vue';
|
import MaterialIcon from '@Components/ui/Icons/MaterialIcon.vue';
|
||||||
import IconButton from '@Holos/Button/Icon.vue';
|
|
||||||
|
|
||||||
import { can, apiTo, viewTo, transl } from './Module'
|
/** Propiedades */
|
||||||
import { useSearcher } from '@Services/Api';
|
const models = ref();
|
||||||
import Input from '@Components/ui/Input.vue';
|
|
||||||
|
|
||||||
const models = ref([]);
|
|
||||||
const router = useRouter();
|
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({
|
const searcher = useSearcher({
|
||||||
url: apiTo('index'),
|
url: apiTo('index'),
|
||||||
onSuccess: (r) => {
|
onSuccess: (r) => {
|
||||||
console.log('Datos recibidos:', r);
|
// Si hay productos de la API, los usamos
|
||||||
// Según la estructura que muestras, los productos están en r.data.products.data
|
models.value = r.products;
|
||||||
models.value = r.data?.products?.data || r.products || [];
|
// Actualizar paginación con el total de items
|
||||||
|
pagination.value.totalItems = r.products?.data?.length || 0;
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error('Error cargando productos:', error);
|
console.error(error);
|
||||||
models.value = [];
|
// En caso de error, usamos datos de prueba
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -34,28 +52,70 @@ const searcher = useSearcher({
|
|||||||
const formatAttributes = (attributes) => {
|
const formatAttributes = (attributes) => {
|
||||||
if (!attributes) return '-';
|
if (!attributes) return '-';
|
||||||
|
|
||||||
const formatted = Object.entries(attributes).map(([key, value]) => {
|
const formatted = Object.entries(attributes).map(([key]) => {
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return `${key}`;
|
|
||||||
}
|
|
||||||
return `${key}`;
|
return `${key}`;
|
||||||
}).join(' | ');
|
}).join(' | ');
|
||||||
|
|
||||||
return formatted.length > 50 ? formatted.substring(0, 50) + '...' : formatted;
|
return formatted.length > 50 ? formatted.substring(0, 50) + '...' : formatted;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Función para mostrar clasificaciones */
|
/** Configuración de columnas para la tabla */
|
||||||
const formatClassifications = (classifications) => {
|
const columns = ref([
|
||||||
if (!classifications || classifications.length === 0) return '-';
|
{
|
||||||
return classifications.map(c => c.name).join(', ');
|
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(() => {
|
onMounted(() => {
|
||||||
searcher.search();
|
searcher.search();
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -67,27 +127,31 @@ onMounted(() => {
|
|||||||
Gestión del catálogo de productos
|
Gestión del catálogo de productos
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<RouterLink
|
||||||
|
|
||||||
<Button color="info" @click="$router.push({ name: 'products.create' })">
|
:to="viewTo({ name: 'create' })"
|
||||||
Nuevo Producto
|
>
|
||||||
|
<!-- v-if="can('create')" -->
|
||||||
|
<Button color="info">
|
||||||
|
<MaterialIcon name="add" class="mr-2" />
|
||||||
|
Nuevo producto
|
||||||
</Button>
|
</Button>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent class="p-4">
|
<CardContent class="p-4">
|
||||||
<div class="flex items-center space-x-2">
|
<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>
|
<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>
|
<p class="text-sm text-muted-foreground">Total Productos</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
@ -96,133 +160,38 @@ onMounted(() => {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div class="flex items-center space-x-4 mb-6">
|
<div class="flex items-center space-x-4 mb-6">
|
||||||
<div class="relative flex-1 max-w-sm">
|
<p>Buscador</p>
|
||||||
<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 })"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" class="flex items-center">
|
|
||||||
<GoogleIcon class="w-4 h-4 mr-2" name="filter_list" />
|
<Table v-if="models?.data" :columns="columns" :data="models?.data" :loading="false" :sortable="true"
|
||||||
Filtros
|
: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>
|
</Button>
|
||||||
</div>
|
</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>
|
</template>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
342
src/pages/Products/Modals/Show.vue
Normal file
342
src/pages/Products/Modals/Show.vue
Normal 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>
|
||||||
@ -3,6 +3,7 @@ import { hasPermission } from '@Plugins/RolePermission.js';
|
|||||||
|
|
||||||
// Ruta API
|
// Ruta API
|
||||||
const apiTo = (name, params = {}) => route(`products.${name}`, params)
|
const apiTo = (name, params = {}) => route(`products.${name}`, params)
|
||||||
|
const comercialTo = (name, params = {}) => route(`comercial-classifications.${name}`, params)
|
||||||
|
|
||||||
// Ruta visual
|
// Ruta visual
|
||||||
const viewTo = ({ name = '', params = {}, query = {} }) => view({
|
const viewTo = ({ name = '', params = {}, query = {} }) => view({
|
||||||
@ -19,5 +20,6 @@ export {
|
|||||||
can,
|
can,
|
||||||
viewTo,
|
viewTo,
|
||||||
apiTo,
|
apiTo,
|
||||||
|
comercialTo,
|
||||||
transl
|
transl
|
||||||
}
|
}
|
||||||
82
src/pages/Products/interfaces/products.interfaces.js
Normal file
82
src/pages/Products/interfaces/products.interfaces.js
Normal 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
|
||||||
|
};
|
||||||
263
src/pages/Products/services/ProductService.js
Normal file
263
src/pages/Products/services/ProductService.js
Normal 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)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -542,15 +542,16 @@ const router = createRouter({
|
|||||||
name: 'admin.products.index',
|
name: 'admin.products.index',
|
||||||
component: () => import('@Pages/Products/Index.vue'),
|
component: () => import('@Pages/Products/Index.vue'),
|
||||||
},
|
},
|
||||||
/* {
|
{
|
||||||
path: 'create',
|
path: 'create',
|
||||||
name: 'admin.products.create',
|
name: 'admin.products.create',
|
||||||
component: () => import('@Pages/Products/Create.vue'),
|
component: () => import('@Pages/Products/Create.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
title: 'Crear',
|
title: 'Crear',
|
||||||
icon: 'add',
|
icon: 'add',
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
|
/*
|
||||||
{
|
{
|
||||||
path: ':id/edit',
|
path: ':id/edit',
|
||||||
name: 'admin.products.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',
|
path: 'roles',
|
||||||
name: 'admin.roles',
|
name: 'admin.roles',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user