feature-comercial-module #9
0
install.sh
Executable file → Normal file
0
install.sh
Executable file → Normal file
93
package-lock.json
generated
93
package-lock.json
generated
@ -9,6 +9,7 @@
|
||||
"version": "0.9.12",
|
||||
"dependencies": {
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@primeuix/themes": "^1.2.5",
|
||||
"@tailwindcss/postcss": "^4.0.9",
|
||||
"@tailwindcss/vite": "^4.0.9",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
@ -17,8 +18,10 @@
|
||||
"axios": "^1.8.1",
|
||||
"laravel-echo": "^2.0.2",
|
||||
"luxon": "^3.5.0",
|
||||
"material-symbols": "^0.36.2",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pinia": "^3.0.1",
|
||||
"primevue": "^4.4.1",
|
||||
"pusher-js": "^8.4.0",
|
||||
"tailwindcss": "^4.0",
|
||||
"toastr": "^2.1.4",
|
||||
@ -701,6 +704,74 @@
|
||||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@primeuix/styled": {
|
||||
"version": "0.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@primeuix/styled/-/styled-0.7.4.tgz",
|
||||
"integrity": "sha512-QSO/NpOQg8e9BONWRBx9y8VGMCMYz0J/uKfNJEya/RGEu7ARx0oYW0ugI1N3/KB1AAvyGxzKBzGImbwg0KUiOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@primeuix/utils": "^0.6.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@primeuix/styles": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@primeuix/styles/-/styles-1.2.5.tgz",
|
||||
"integrity": "sha512-nypFRct/oaaBZqP4jinT0puW8ZIfs4u+l/vqUFmJEPU332fl5ePj6DoOpQgTLzo3OfmvSmz5a5/5b4OJJmmi7Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@primeuix/styled": "^0.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@primeuix/themes": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@primeuix/themes/-/themes-1.2.5.tgz",
|
||||
"integrity": "sha512-n3YkwJrHQaEESc/D/A/iD815sxp8cKnmzscA6a8Tm8YvMtYU32eCahwLLe6h5rywghVwxASWuG36XBgISYOIjQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@primeuix/styled": "^0.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@primeuix/utils": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@primeuix/utils/-/utils-0.6.1.tgz",
|
||||
"integrity": "sha512-tQL/ZOPgCdD+NTimlUmhyD0ey8J1XmpZE4hDHM+/fnuBicVVmlKOd5HpS748LcOVRUKbWjmEPdHX4hi5XZoC1Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@primevue/core": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@primevue/core/-/core-4.4.1.tgz",
|
||||
"integrity": "sha512-RG56iDKIJT//EtntjQzOiWOHZZJczw/qWWtdL5vFvw8/QDS9DPKn8HLpXK7N5Le6KK1MLXUsxoiGTZK+poUFUg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@primeuix/styled": "^0.7.4",
|
||||
"@primeuix/utils": "^0.6.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.11.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@primevue/icons": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@primevue/icons/-/icons-4.4.1.tgz",
|
||||
"integrity": "sha512-UfDimrIjVdY6EziwieyV4zPKzW6mnKHKhy4Dgyjv2oI6pNeuim+onbJo1ce22PEGXW78vfblG/3/JIzVHFweqQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@primeuix/utils": "^0.6.1",
|
||||
"@primevue/core": "4.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/pluginutils": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz",
|
||||
@ -2976,6 +3047,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/material-symbols": {
|
||||
"version": "0.36.2",
|
||||
"resolved": "https://registry.npmjs.org/material-symbols/-/material-symbols-0.36.2.tgz",
|
||||
"integrity": "sha512-FbxzGgQSmAb53Kajv+jyqcZ3Ck0ebfTBSMwHkMoyThsbrINiJb5mzheoiFXA/9MGc3cIl9XbhW8JxPM5vEP6iA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@ -3308,6 +3385,22 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/primevue": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/primevue/-/primevue-4.4.1.tgz",
|
||||
"integrity": "sha512-JbHBa5k30pZ7mn/z4vYBOnyt5GrR15eM3X0wa3VanonxnFLYkTEx8OMh33aU6ndWeOfi7Ef57dOL3bTH+3f4hQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@primeuix/styled": "^0.7.4",
|
||||
"@primeuix/styles": "^1.2.5",
|
||||
"@primeuix/utils": "^0.6.1",
|
||||
"@primevue/core": "4.4.1",
|
||||
"@primevue/icons": "4.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@primeuix/themes": "^1.2.5",
|
||||
"@tailwindcss/postcss": "^4.0.9",
|
||||
"@tailwindcss/vite": "^4.0.9",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
@ -19,8 +20,10 @@
|
||||
"axios": "^1.8.1",
|
||||
"laravel-echo": "^2.0.2",
|
||||
"luxon": "^3.5.0",
|
||||
"material-symbols": "^0.36.2",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pinia": "^3.0.1",
|
||||
"primevue": "^4.4.1",
|
||||
"pusher-js": "^8.4.0",
|
||||
"tailwindcss": "^4.0",
|
||||
"toastr": "^2.1.4",
|
||||
|
||||
@ -23,7 +23,6 @@ interface Props {
|
||||
loading?: boolean;
|
||||
fullWidth?: boolean;
|
||||
iconOnly?: boolean;
|
||||
asLink?: boolean; // Nueva prop para comportamiento de link
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@ -35,7 +34,6 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
fullWidth: false,
|
||||
iconOnly: false,
|
||||
asLink: true, // Por defecto no es link
|
||||
});
|
||||
|
||||
|
||||
@ -44,15 +42,8 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
function handleClick(event: MouseEvent) {
|
||||
// Si es usado como link, no bloquear la navegación
|
||||
if (props.asLink) {
|
||||
emit('click', event);
|
||||
return;
|
||||
}
|
||||
|
||||
// Para botones normales, validar estados
|
||||
if (props.disabled || props.loading) return;
|
||||
|
||||
emit('click', event);
|
||||
}
|
||||
|
||||
const buttonClasses = computed(() => {
|
||||
@ -82,7 +73,7 @@ const buttonClasses = computed(() => {
|
||||
solid: ['shadow-sm'],
|
||||
outline: ['border', 'bg-white', 'hover:bg-gray-50'],
|
||||
ghost: ['bg-transparent', 'hover:bg-gray-100'],
|
||||
smooth: ['bg-opacity-20', 'font-bold', 'shadow-none'],
|
||||
smooth: ['bg-opacity-20', 'font-bold', 'uppercase', 'shadow-none'],
|
||||
};
|
||||
|
||||
// Colores por tipo
|
||||
|
||||
0
src/components/Holos/Inbox.vue
Executable file → Normal file
0
src/components/Holos/Inbox.vue
Executable file → Normal file
0
src/components/Holos/Inbox/Item.vue
Executable file → Normal file
0
src/components/Holos/Inbox/Item.vue
Executable file → Normal file
0
src/components/Holos/Inbox/ItemTitle.vue
Executable file → Normal file
0
src/components/Holos/Inbox/ItemTitle.vue
Executable file → Normal file
0
src/components/Holos/Inbox/Menu/Item.vue
Executable file → Normal file
0
src/components/Holos/Inbox/Menu/Item.vue
Executable file → Normal file
0
src/components/Holos/Inbox/Menu/Static.vue
Executable file → Normal file
0
src/components/Holos/Inbox/Menu/Static.vue
Executable file → Normal file
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>
|
||||
114
src/components/ui/Input.vue
Normal file
114
src/components/ui/Input.vue
Normal file
@ -0,0 +1,114 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
/** Props */
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: ''
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'text'
|
||||
},
|
||||
class: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
id: {
|
||||
type: String,
|
||||
default: undefined
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: undefined
|
||||
}
|
||||
});
|
||||
|
||||
/** Emits */
|
||||
const emit = defineEmits(['update:modelValue', 'input', 'change', 'focus', 'blur', 'keydown', 'keyup']);
|
||||
|
||||
/** Función para concatenar clases (equivalente a cn() de React) */
|
||||
const cn = (...classes) => {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
};
|
||||
|
||||
/** Clases computadas */
|
||||
const inputClasses = computed(() => {
|
||||
return cn(
|
||||
"flex h-10 w-full rounded-md border border-input px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
props.class
|
||||
);
|
||||
});
|
||||
|
||||
/** Manejadores de eventos */
|
||||
const handleInput = (event) => {
|
||||
emit('update:modelValue', event.target.value);
|
||||
emit('input', event);
|
||||
};
|
||||
|
||||
const handleChange = (event) => {
|
||||
emit('change', event);
|
||||
};
|
||||
|
||||
const handleFocus = (event) => {
|
||||
emit('focus', event);
|
||||
};
|
||||
|
||||
const handleBlur = (event) => {
|
||||
emit('blur', event);
|
||||
};
|
||||
|
||||
const handleKeydown = (event) => {
|
||||
emit('keydown', event);
|
||||
};
|
||||
|
||||
const handleKeyup = (event) => {
|
||||
emit('keyup', event);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
:id="id"
|
||||
:name="name"
|
||||
:type="type"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:required="required"
|
||||
:class="inputClasses"
|
||||
@input="handleInput"
|
||||
@change="handleChange"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
@keydown="handleKeydown"
|
||||
@keyup="handleKeyup"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/*
|
||||
Nota: Las clases CSS están definidas en Tailwind CSS.
|
||||
Si necesitas definir estilos personalizados para las clases como
|
||||
'border-input', 'bg-background', 'ring-ring', etc.,
|
||||
puedes agregarlas aquí o en tu archivo de configuración de Tailwind.
|
||||
*/
|
||||
</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>
|
||||
0
src/controllers/PrintController.js
Executable file → Normal file
0
src/controllers/PrintController.js
Executable file → Normal file
@ -75,3 +75,58 @@
|
||||
font-weight: 400;
|
||||
src: url(./icons/google/gNNBW2J8Roq16WD5tFNRaeLQk6-SHQ_R00k4c2_whPnoY9ruReYU3rHmz74m0ZkGH-VBYe1x0TV6x4yFH8F-H5OdzEL3sVTgJtfbYxOLojCL.woff2) format('woff2');
|
||||
}
|
||||
|
||||
/* Clases para MaterialIcon component */
|
||||
.material-symbols-outlined {
|
||||
font-family: 'Material Symbols Outlined';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-size: 24px;
|
||||
display: inline-block;
|
||||
line-height: 1;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
word-wrap: normal;
|
||||
white-space: nowrap;
|
||||
direction: ltr;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
font-feature-settings: 'liga';
|
||||
}
|
||||
|
||||
.material-symbols-rounded {
|
||||
font-family: 'Material Symbols Rounded';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-size: 24px;
|
||||
display: inline-block;
|
||||
line-height: 1;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
word-wrap: normal;
|
||||
white-space: nowrap;
|
||||
direction: ltr;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
font-feature-settings: 'liga';
|
||||
}
|
||||
|
||||
.material-symbols-sharp {
|
||||
font-family: 'Material Symbols Sharp';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-size: 24px;
|
||||
display: inline-block;
|
||||
line-height: 1;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
word-wrap: normal;
|
||||
white-space: nowrap;
|
||||
direction: ltr;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
font-feature-settings: 'liga';
|
||||
}
|
||||
|
||||
38
src/index.js
38
src/index.js
@ -1,3 +1,4 @@
|
||||
import Aura from '@primeuix/themes/aura';
|
||||
import './css/base.css'
|
||||
|
||||
import axios from 'axios';
|
||||
@ -13,9 +14,15 @@ import { pagePlugin } from '@Services/Page';
|
||||
import { defineApp, reloadApp, view } from '@Services/Page';
|
||||
import { apiURL } from '@Services/Api';
|
||||
import VueApexCharts from "vue3-apexcharts";
|
||||
import VCalendar from 'v-calendar'
|
||||
import VCalendar from 'v-calendar';
|
||||
import PrimeVue from 'primevue/config';
|
||||
import 'v-calendar/style.css';
|
||||
|
||||
import { definePreset } from '@primeuix/themes';
|
||||
import Button from 'primevue/button';
|
||||
|
||||
|
||||
|
||||
import App from '@Components/App.vue'
|
||||
import Error503 from '@Pages/Errors/503.vue'
|
||||
import { hasToken } from './services/Api';
|
||||
@ -46,20 +53,38 @@ async function boot() {
|
||||
window.Notify.error(window.Lang('server.api.noAvailable'));
|
||||
}
|
||||
|
||||
if(initRoutes) {
|
||||
if (initRoutes) {
|
||||
// Iniciar permisos
|
||||
if(hasToken()) {
|
||||
if (hasToken()) {
|
||||
await bootPermissions();
|
||||
await bootRoles();
|
||||
|
||||
// Iniciar broadcast
|
||||
if(import.meta.env.VITE_REVERB_ACTIVE === 'true') {
|
||||
if (import.meta.env.VITE_REVERB_ACTIVE === 'true') {
|
||||
await import('@Services/Broadcast')
|
||||
}
|
||||
}
|
||||
|
||||
reloadApp();
|
||||
|
||||
const MyPreset = definePreset(Aura, {
|
||||
semantic: {
|
||||
primary: {
|
||||
50: '{neutral.50}',
|
||||
100: '{neutral.100}',
|
||||
200: '{neutral.200}',
|
||||
300: '{neutral.300}',
|
||||
400: '{neutral.400}',
|
||||
500: '{neutral.500}',
|
||||
600: '{neutral.600}',
|
||||
700: '{neutral.700}',
|
||||
800: '{neutral.800}',
|
||||
900: '{neutral.900}',
|
||||
950: '{neutral.950}'
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
createApp(App)
|
||||
.use(createPinia())
|
||||
.use(i18n)
|
||||
@ -68,6 +93,11 @@ async function boot() {
|
||||
.use(ZiggyVue)
|
||||
.use(VCalendar, {})
|
||||
.use(VueApexCharts)
|
||||
.use(PrimeVue, {
|
||||
theme: {
|
||||
preset: MyPreset
|
||||
}
|
||||
})
|
||||
.mount('#app');
|
||||
} else {
|
||||
createApp(Error503)
|
||||
|
||||
@ -78,6 +78,19 @@ export default {
|
||||
activity: {
|
||||
title: 'Historial de acciones',
|
||||
description: 'Historial de acciones realizadas por los usuarios en orden cronológico.'
|
||||
},
|
||||
products: {
|
||||
name: 'Productos',
|
||||
title: 'Productos',
|
||||
description: 'Gestión del catálogo de productos',
|
||||
create: {
|
||||
title: 'Crear producto',
|
||||
description: 'Permite crear un nuevo producto en el catálogo con sus clasificaciones y atributos personalizados.'
|
||||
},
|
||||
edit: {
|
||||
title: 'Editar producto',
|
||||
description: 'Actualiza la información del producto, sus clasificaciones y atributos.'
|
||||
}
|
||||
}
|
||||
},
|
||||
app: {
|
||||
@ -193,6 +206,8 @@ export default {
|
||||
done:'Hecho.',
|
||||
edit:'Editar',
|
||||
edited:'Registro creado',
|
||||
active:'Activo',
|
||||
inactive:'Inactivo',
|
||||
email:{
|
||||
title:'Correo',
|
||||
verification:'Verificar correo'
|
||||
|
||||
@ -94,6 +94,24 @@ onMounted(() => {
|
||||
to="admin.units-measure.index"
|
||||
/>
|
||||
</Section>
|
||||
<Section name="Comercial">
|
||||
<Link
|
||||
icon="bookmarks"
|
||||
name="Clasificaciones Comerciales"
|
||||
to="admin.comercial-classifications.index"
|
||||
/>
|
||||
<Link
|
||||
icon="inventory"
|
||||
name="Productos"
|
||||
to="admin.products.index"
|
||||
/>
|
||||
<Link
|
||||
icon="sell"
|
||||
name="Punto de venta"
|
||||
to="admin.pos.index"
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section name="Capacitaciones">
|
||||
<DropDown
|
||||
icon="grid_view"
|
||||
|
||||
89
src/pages/ComercialClassifications/Create.vue
Normal file
89
src/pages/ComercialClassifications/Create.vue
Normal file
@ -0,0 +1,89 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter, useRoute, RouterLink } 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 GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||
import Form from './Form.vue'
|
||||
|
||||
/** Definidores */
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
/** Propiedades */
|
||||
const form = useForm({
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
parent_id: null
|
||||
});
|
||||
|
||||
const parentInfo = ref(null);
|
||||
|
||||
/** Métodos */
|
||||
function submit() {
|
||||
form.transform(data => ({
|
||||
...data,
|
||||
parent_id: data.parent_id // Usar el parent_id del formulario
|
||||
})).post(apiTo('store'), {
|
||||
onSuccess: () => {
|
||||
Notify.success(Lang('register.create.onSuccess'))
|
||||
router.push(viewTo({ name: 'index' }));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Ciclos */
|
||||
onMounted(() => {
|
||||
// Verificar si se están pasando parámetros para crear subcategoría
|
||||
if (route.query.parent_id) {
|
||||
parentInfo.value = {
|
||||
id: parseInt(route.query.parent_id),
|
||||
name: route.query.parent_name,
|
||||
code: route.query.parent_code
|
||||
};
|
||||
// Pre-llenar el parent_id en el formulario
|
||||
form.parent_id = parseInt(route.query.parent_id);
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeader
|
||||
:title="parentInfo ? `${transl('create.title')} - Subcategoría` : transl('create.title')"
|
||||
>
|
||||
<RouterLink :to="viewTo({ name: 'index' })">
|
||||
<IconButton
|
||||
class="text-white"
|
||||
icon="arrow_back"
|
||||
:title="$t('return')"
|
||||
filled
|
||||
/>
|
||||
</RouterLink>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Mostrar información del padre si se está creando una subcategoría -->
|
||||
<div v-if="parentInfo" class="mb-4 p-4 bg-blue-50 dark:bg-blue-900 rounded-lg border border-blue-200 dark:border-blue-700">
|
||||
<div class="flex items-center">
|
||||
<GoogleIcon class="text-blue-600 text-xl mr-2" name="account_tree" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-blue-800 dark:text-blue-200">
|
||||
Creando subcategoría para:
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-blue-900 dark:text-blue-100">
|
||||
<code class="bg-blue-100 dark:bg-blue-800 px-2 py-1 rounded text-sm mr-2">{{ parentInfo.code }}</code>
|
||||
{{ parentInfo.name }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
action="create"
|
||||
:form="form"
|
||||
@submit="submit"
|
||||
/>
|
||||
</template>
|
||||
139
src/pages/ComercialClassifications/Edit.vue
Normal file
139
src/pages/ComercialClassifications/Edit.vue
Normal file
@ -0,0 +1,139 @@
|
||||
<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 GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||
import Form from './Form.vue'
|
||||
|
||||
/** Definiciones */
|
||||
const vroute = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
/** Propiedades */
|
||||
const form = useForm({
|
||||
id: null,
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
});
|
||||
|
||||
const parentOptions = ref([]);
|
||||
|
||||
/** Métodos */
|
||||
function submit() {
|
||||
form.transform(data => ({
|
||||
...data,
|
||||
})).put(apiTo('update', { comercial_classification: form.id }), {
|
||||
onSuccess: () => {
|
||||
Notify.success(Lang('register.edit.onSuccess'))
|
||||
router.push(viewTo({ name: 'index' }));
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Función para obtener clasificaciones padre (excluyendo la actual y sus hijos) */
|
||||
function loadParentOptions() {
|
||||
api.get(apiTo('index'), {
|
||||
onSuccess: (r) => {
|
||||
// Función para excluir la clasificación actual y sus descendientes
|
||||
const excludeCurrentAndChildren = (items, excludeId) => {
|
||||
return items.filter(item => {
|
||||
if (item.id === excludeId) return false;
|
||||
if (hasDescendant(item, excludeId)) return false;
|
||||
return true;
|
||||
}).map(item => ({
|
||||
...item,
|
||||
children: excludeCurrentAndChildren(item.children || [], excludeId)
|
||||
}));
|
||||
};
|
||||
|
||||
// Función para verificar si un item tiene como descendiente la clasificación actual
|
||||
const hasDescendant = (item, targetId) => {
|
||||
if (!item.children) return false;
|
||||
return item.children.some(child =>
|
||||
child.id === targetId || hasDescendant(child, targetId)
|
||||
);
|
||||
};
|
||||
|
||||
// Aplanar la estructura jerárquica para el selector
|
||||
const flattenOptions = (items, level = 0) => {
|
||||
let options = [];
|
||||
items.forEach(item => {
|
||||
options.push({
|
||||
...item,
|
||||
name: ' '.repeat(level) + item.name, // Indentación visual
|
||||
level
|
||||
});
|
||||
if (item.children && item.children.length > 0) {
|
||||
options = options.concat(flattenOptions(item.children, level + 1));
|
||||
}
|
||||
});
|
||||
return options;
|
||||
};
|
||||
|
||||
const dataSource = r.data?.comercial_classifications?.data || r.comercial_classifications || [];
|
||||
const filteredData = excludeCurrentAndChildren(dataSource, form.id);
|
||||
parentOptions.value = flattenOptions(filteredData);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
api.get(apiTo('show', { comercial_classification: vroute.params.id }), {
|
||||
onSuccess: (r) => {
|
||||
const classification = r.data || r.comercial_classification || r;
|
||||
form.fill({
|
||||
id: classification.id,
|
||||
code: classification.code,
|
||||
name: classification.name,
|
||||
description: classification.description,
|
||||
is_active: classification.is_active,
|
||||
parent: classification.parent || null
|
||||
});
|
||||
|
||||
// Cargar opciones padre después de obtener los datos actuales
|
||||
loadParentOptions();
|
||||
}
|
||||
});
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeader :title="`${transl('edit.title')}${form.parent ? ' - Subcategoría' : ''}`">
|
||||
<RouterLink :to="viewTo({ name: 'index' })">
|
||||
<IconButton
|
||||
class="text-white"
|
||||
icon="arrow_back"
|
||||
:title="$t('return')"
|
||||
filled
|
||||
/>
|
||||
</RouterLink>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Mostrar información del padre si es una subcategoría -->
|
||||
<div v-if="form.parent" class="mb-4 p-4 bg-yellow-50 dark:bg-yellow-900 rounded-lg border border-yellow-200 dark:border-yellow-700">
|
||||
<div class="flex items-center">
|
||||
<GoogleIcon class="text-yellow-600 text-xl mr-2" name="account_tree" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||
Editando subcategoría de:
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-yellow-900 dark:text-yellow-100">
|
||||
<code class="bg-yellow-100 dark:bg-yellow-800 px-2 py-1 rounded text-sm mr-2">{{ form.parent.code }}</code>
|
||||
{{ form.parent.name }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
action="update"
|
||||
:form="form"
|
||||
:parent-options="parentOptions"
|
||||
@submit="submit"
|
||||
/>
|
||||
</template>
|
||||
68
src/pages/ComercialClassifications/Form.vue
Normal file
68
src/pages/ComercialClassifications/Form.vue
Normal file
@ -0,0 +1,68 @@
|
||||
<script setup>
|
||||
import { transl } from './Module';
|
||||
|
||||
import PrimaryButton from '@Holos/Button/Primary.vue';
|
||||
import Input from '@Holos/Form/Input.vue';
|
||||
import Selectable from '@Holos/Form/Selectable.vue';
|
||||
import Checkbox from '@Holos/Checkbox.vue';
|
||||
|
||||
/** Eventos */
|
||||
const emit = defineEmits([
|
||||
'submit'
|
||||
])
|
||||
|
||||
/** Propiedades */
|
||||
defineProps({
|
||||
action: {
|
||||
default: 'create',
|
||||
type: String
|
||||
},
|
||||
form: Object,
|
||||
parentOptions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
/** Métodos */
|
||||
function submit() {
|
||||
emit('submit')
|
||||
}
|
||||
</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="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<Input
|
||||
v-model="form.code"
|
||||
id="code"
|
||||
:onError="form.errors.code"
|
||||
autofocus
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
v-model="form.name"
|
||||
id="name"
|
||||
:onError="form.errors.name"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
v-model="form.description"
|
||||
id="description"
|
||||
:onError="form.errors.description"
|
||||
class="md:col-span-2"
|
||||
/>
|
||||
<slot />
|
||||
<div class="col-span-1 md:col-span-2 lg:col-span-3 xl:col-span-4 flex flex-col items-center justify-end space-y-4 mt-4">
|
||||
<PrimaryButton
|
||||
v-text="$t(action)"
|
||||
:class="{ 'opacity-25': form.processing }"
|
||||
:disabled="form.processing"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
204
src/pages/ComercialClassifications/Index.vue
Normal file
204
src/pages/ComercialClassifications/Index.vue
Normal file
@ -0,0 +1,204 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter, RouterLink } from 'vue-router';
|
||||
import { useSearcher } from '@Services/Api';
|
||||
import { hasPermission } from '@Plugins/RolePermission';
|
||||
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 '@Holos/Table.vue';
|
||||
import ShowView from './Modals/Show.vue';
|
||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||
import Button from '@Holos/Button/Button.vue';
|
||||
|
||||
/** Propiedades */
|
||||
const models = ref([]);
|
||||
const router = useRouter();
|
||||
|
||||
/** Referencias */
|
||||
const showModal = ref(null);
|
||||
const destroyModal = ref(null);
|
||||
|
||||
/** Métodos */
|
||||
const searcher = useSearcher({
|
||||
url: apiTo('index'),
|
||||
onSuccess: (r) => {
|
||||
console.log('Datos recibidos:', r);
|
||||
// La API retorna los datos en r.data.comercial_classifications.data
|
||||
const classificationsData = r.data?.comercial_classifications?.data || r.comercial_classifications || [];
|
||||
models.value = classificationsData;
|
||||
},
|
||||
onError: () => {
|
||||
models.value = [];
|
||||
}
|
||||
});
|
||||
|
||||
/** Función para renderizar estructura jerárquica */
|
||||
const renderHierarchicalRows = (items, level = 0) => {
|
||||
const rows = [];
|
||||
items.forEach(item => {
|
||||
rows.push({ ...item, level });
|
||||
if (item.children && item.children.length > 0) {
|
||||
rows.push(...renderHierarchicalRows(item.children, level + 1));
|
||||
}
|
||||
});
|
||||
return rows;
|
||||
};
|
||||
|
||||
/** Función para crear subcategoría */
|
||||
const createSubcategory = (parentCategory) => {
|
||||
// Redirigir a la vista de creación con el parent_id en query params
|
||||
router.push(viewTo({
|
||||
name: 'create',
|
||||
query: {
|
||||
parent_id: parentCategory.id,
|
||||
parent_name: parentCategory.name,
|
||||
parent_code: parentCategory.code
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
/** Función para eliminar con información contextual */
|
||||
const deleteItem = (item) => {
|
||||
// Agregar información contextual al modal
|
||||
const itemWithContext = {
|
||||
...item,
|
||||
contextInfo: item.parent ? `Subcategoría de: ${item.parent.code} - ${item.parent.name}` : 'Categoría principal'
|
||||
};
|
||||
destroyModal.value.open(itemWithContext);
|
||||
};
|
||||
|
||||
/** Función para mostrar detalles */
|
||||
const showItem = (item) => {
|
||||
showModal.value.open(item);
|
||||
};
|
||||
|
||||
/** Ciclos */
|
||||
onMounted(() => {
|
||||
searcher.search();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Clasificaciones Comerciales</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Gestión de categorías y subcategorías de productos
|
||||
</p>
|
||||
</div>
|
||||
<RouterLink :to="viewTo({ name: 'create' })">
|
||||
<Button color="info">Nueva Clasificación</Button>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<!-- <SearcherHead :title="transl('name')" @search="(x) => searcher.search(x)">
|
||||
<RouterLink :to="viewTo({ name: 'create' })">
|
||||
|
||||
<IconButton class="text-white" icon="add" :title="$t('crud.create')" filled />
|
||||
</RouterLink>
|
||||
<IconButton icon="refresh" :title="$t('refresh')" @click="searcher.search()" />
|
||||
</SearcherHead> -->
|
||||
<div class="pt-2 w-full">
|
||||
<Table :items="models" :processing="searcher.processing"
|
||||
@send-pagination="(page) => searcher.pagination(page)">
|
||||
<template #head>
|
||||
<th v-text="$t('code')" />
|
||||
<th v-text="$t('name')" />
|
||||
<th v-text="$t('description')" />
|
||||
<th v-text="$t('status')" class="w-20 text-center" />
|
||||
<th v-text="$t('actions')" class="w-32 text-center" />
|
||||
</template>
|
||||
<template #body="{ items }">
|
||||
<tr v-for="model in items" :key="model.id" class="table-row"
|
||||
:class="{ 'bg-gray-50 dark:bg-gray-800': model.level > 0 }">
|
||||
<td class="table-cell">
|
||||
<div :style="{ paddingLeft: `${model.level * 24}px` }" class="flex items-center">
|
||||
<span v-if="model.level > 0" class="text-gray-400 mr-2">└─</span>
|
||||
<code class="font-mono text-sm"
|
||||
:class="model.level > 0 ? 'text-blue-600 dark:text-blue-400' : 'text-gray-900 dark:text-gray-100'">
|
||||
{{ model.code }}
|
||||
</code>
|
||||
<span v-if="model.level > 0" class="ml-2 text-xs bg-blue-100 text-blue-800 dark:bg-blue-800 dark:text-blue-100 px-2 py-1 rounded-full">
|
||||
Subcategoría
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="table-cell">
|
||||
<span class="font-semibold">{{ model.name }}</span>
|
||||
</td>
|
||||
<td class="table-cell">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ model.description || '-' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="table-cell text-center">
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium"
|
||||
:class="model.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'"
|
||||
>
|
||||
{{ model.is_active ? $t('active') : $t('inactive') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="table-cell">
|
||||
<div class="table-actions">
|
||||
<IconButton
|
||||
icon="add_circle"
|
||||
:title="$t('Agregar subcategoría')"
|
||||
@click="createSubcategory(model)"
|
||||
outline
|
||||
/>
|
||||
<IconButton
|
||||
icon="visibility"
|
||||
:title="$t('crud.show')"
|
||||
@click="showItem(model)"
|
||||
outline
|
||||
/>
|
||||
<RouterLink
|
||||
class="h-fit"
|
||||
:to="viewTo({ name: 'edit', params: { id: model.id } })"
|
||||
>
|
||||
<IconButton
|
||||
icon="edit"
|
||||
:title="$t('crud.edit')"
|
||||
outline
|
||||
/>
|
||||
</RouterLink>
|
||||
<IconButton
|
||||
icon="delete"
|
||||
:title="$t('crud.destroy')"
|
||||
@click="deleteItem(model)"
|
||||
outline
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template #empty>
|
||||
<td class="table-cell">
|
||||
<div class="flex items-center text-sm">
|
||||
<GoogleIcon name="folder_open" class="mr-2 text-gray-400" />
|
||||
<p class="font-semibold">
|
||||
{{ $t('registers.empty') }}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="table-cell">-</td>
|
||||
<td class="table-cell">-</td>
|
||||
<td class="table-cell">-</td>
|
||||
<td class="table-cell">-</td>
|
||||
<td class="table-cell">-</td>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<ShowView ref="showModal" @reload="searcher.search()" />
|
||||
|
||||
<DestroyView ref="destroyModal" subtitle="name" :to="(id) => apiTo('destroy', { comercial_classification: id })"
|
||||
@update="searcher.search()" />
|
||||
</div>
|
||||
</template>
|
||||
362
src/pages/ComercialClassifications/Modals/Show.vue
Normal file
362
src/pages/ComercialClassifications/Modals/Show.vue
Normal file
@ -0,0 +1,362 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { getDateTime } from '@Controllers/DateController';
|
||||
import { viewTo, apiTo } from '../Module';
|
||||
import ComercialClassificationsService from '../services/ComercialClassificationsService';
|
||||
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';
|
||||
import DestroyView from '@Holos/Modal/Template/Destroy.vue';
|
||||
|
||||
/** Eventos */
|
||||
const emit = defineEmits([
|
||||
'close',
|
||||
'reload'
|
||||
]);
|
||||
|
||||
/** Servicios */
|
||||
const classificationsService = new ComercialClassificationsService();
|
||||
const router = useRouter();
|
||||
|
||||
/** Propiedades */
|
||||
const model = ref(null);
|
||||
const loading = ref(false);
|
||||
const deletingChildId = ref(null); // Para mostrar loading específico en subcategoría que se está eliminando
|
||||
|
||||
/** Referencias */
|
||||
const modalRef = ref(null);
|
||||
const destroyModal = ref(null);
|
||||
|
||||
/** Métodos */
|
||||
function close() {
|
||||
model.value = null;
|
||||
emit('close');
|
||||
}
|
||||
|
||||
/** Función para actualizar el estado de la clasificación */
|
||||
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 classificationsService.updateStatus(item.id, newStatus);
|
||||
|
||||
// Actualizar el modelo local
|
||||
item.is_active = newStatus;
|
||||
|
||||
// Notificación de éxito
|
||||
const statusText = newStatus ? 'activada' : 'desactivada';
|
||||
Notify.success(
|
||||
`Clasificación "${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 de la clasificación';
|
||||
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 subcategoría */
|
||||
function editSubcategory(child) {
|
||||
// Navegar a la vista de edición de la subcategoría
|
||||
const editUrl = viewTo({ name: 'edit', params: { id: child.id } });
|
||||
router.push(editUrl);
|
||||
// Cerrar el modal actual
|
||||
close();
|
||||
}
|
||||
|
||||
/** Función para eliminar subcategoría */
|
||||
function deleteSubcategory(child) {
|
||||
// Marcar cuál subcategoría se va a eliminar para mostrar loading
|
||||
deletingChildId.value = child.id;
|
||||
destroyModal.value.open(child);
|
||||
}
|
||||
|
||||
/** Función para recargar después de eliminar - Mejorada */
|
||||
async function onSubcategoryDeleted() {
|
||||
try {
|
||||
// Mostrar que se está procesando
|
||||
const deletedId = deletingChildId.value;
|
||||
|
||||
// Recargar los datos de la clasificación actual para obtener subcategorías actualizadas
|
||||
const response = await classificationsService.getById(model.value.id);
|
||||
|
||||
// Actualizar el modelo local con los datos frescos
|
||||
if (response && response.comercial_classification) {
|
||||
model.value = response.comercial_classification;
|
||||
|
||||
// Notificación de éxito con información específica
|
||||
const deletedCount = model.value.children ? model.value.children.length : 0;
|
||||
Notify.success(
|
||||
`Subcategoría eliminada exitosamente. ${deletedCount} subcategorías restantes.`,
|
||||
'Eliminación exitosa'
|
||||
);
|
||||
}
|
||||
|
||||
// Emitir evento para recargar la lista principal
|
||||
emit('reload');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error recargando datos después de eliminar:', error);
|
||||
|
||||
// En caso de error, cerrar modal y recargar lista principal
|
||||
close();
|
||||
emit('reload');
|
||||
|
||||
// Notificación de error
|
||||
Notify.error(
|
||||
'Error al actualizar la vista. Los datos se han actualizado correctamente.',
|
||||
'Error de actualización'
|
||||
);
|
||||
} finally {
|
||||
// Limpiar el estado de eliminación
|
||||
deletingChildId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Función para cancelar eliminación */
|
||||
function onDeleteCancelled() {
|
||||
// Limpiar el estado de eliminación si se cancela
|
||||
deletingChildId.value = null;
|
||||
}
|
||||
|
||||
/** Función para renderizar la jerarquía de hijos */
|
||||
const renderChildren = (children, level = 1) => {
|
||||
if (!children || children.length === 0) return null;
|
||||
return children.map(child => ({
|
||||
...child,
|
||||
level,
|
||||
children: renderChildren(child.children, level + 1)
|
||||
}));
|
||||
};
|
||||
|
||||
/** 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-purple-500 to-indigo-600 flex items-center justify-center">
|
||||
<GoogleIcon
|
||||
class="text-white text-3xl"
|
||||
name="category"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Header>
|
||||
<div class="flex w-full p-4 space-y-6">
|
||||
<!-- Información básica -->
|
||||
<div class="w-full">
|
||||
<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>{{ $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>
|
||||
|
||||
<!-- Clasificaciones hijas -->
|
||||
<div v-if="model.children && model.children.length > 0" class="mt-6 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="account_tree"
|
||||
/>
|
||||
<div class="pl-3 w-full">
|
||||
<p class="font-bold text-lg leading-none pb-3">
|
||||
{{ $t('Subclasificaciones') }}
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="child in model.children"
|
||||
:key="child.id"
|
||||
class="flex items-center p-3 bg-gray-50 dark:bg-gray-800 rounded-lg transition-all duration-300"
|
||||
:class="{
|
||||
'opacity-50 pointer-events-none': deletingChildId === child.id,
|
||||
'bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700': deletingChildId === child.id
|
||||
}"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center">
|
||||
<code class="font-mono text-sm bg-white dark:bg-gray-700 px-2 py-1 rounded mr-3">
|
||||
{{ child.code }}
|
||||
</code>
|
||||
<span class="font-semibold">{{ child.name }}</span>
|
||||
<!-- Indicador de eliminación -->
|
||||
<span v-if="deletingChildId === child.id" class="ml-2 text-xs bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100 px-2 py-1 rounded-full animate-pulse">
|
||||
Eliminando...
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="child.description" class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{{ child.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Botones de acción para subcategorías -->
|
||||
<div class="flex items-center space-x-2 ml-4">
|
||||
<!-- Botón de estado -->
|
||||
<Button
|
||||
:variant="'smooth'"
|
||||
:color="child.is_active ? 'success' : 'danger'"
|
||||
:size="'sm'"
|
||||
:loading="loading && deletingChildId !== child.id"
|
||||
:disabled="deletingChildId === child.id"
|
||||
@click="toggleStatus(child)"
|
||||
>
|
||||
{{ child.is_active ? $t('Activo') : $t('Inactivo') }}
|
||||
</Button>
|
||||
|
||||
<!-- Botón editar -->
|
||||
<IconButton
|
||||
icon="edit"
|
||||
:title="$t('crud.edit')"
|
||||
:disabled="deletingChildId === child.id"
|
||||
@click="editSubcategory(child)"
|
||||
outline
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<!-- Botón eliminar -->
|
||||
<IconButton
|
||||
icon="delete"
|
||||
:title="$t('crud.destroy')"
|
||||
:loading="deletingChildId === child.id"
|
||||
:disabled="deletingChildId && deletingChildId !== child.id"
|
||||
@click="deleteSubcategory(child)"
|
||||
outline
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mensaje cuando no hay subcategorías (después de eliminar todas) -->
|
||||
<div v-if="!model.children || model.children.length === 0"
|
||||
class="text-center p-4 text-gray-500 dark:text-gray-400">
|
||||
<GoogleIcon class="text-2xl mb-2" name="folder_open" />
|
||||
<p class="text-sm">No hay subcategorías</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de eliminación para subcategorías -->
|
||||
<DestroyView
|
||||
ref="destroyModal"
|
||||
subtitle="name"
|
||||
:to="(id) => apiTo('destroy', { comercial_classification: id })"
|
||||
@update="onSubcategoryDeleted"
|
||||
/>
|
||||
</ShowModal>
|
||||
</template>
|
||||
23
src/pages/ComercialClassifications/Module.js
Normal file
23
src/pages/ComercialClassifications/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(`comercial-classifications.${name}`, params)
|
||||
|
||||
// Ruta visual
|
||||
const viewTo = ({ name = '', params = {}, query = {} }) => view({
|
||||
name: `admin.comercial-classifications.${name}`, params, query
|
||||
})
|
||||
|
||||
// Obtener traducción del componente
|
||||
const transl = (str) => lang(`admin.comercial_classifications.${str}`)
|
||||
|
||||
// Control de permisos
|
||||
const can = (permission) => hasPermission(`admin.comercial-classifications.${permission}`)
|
||||
|
||||
export {
|
||||
can,
|
||||
viewTo,
|
||||
apiTo,
|
||||
transl
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Interfaces para Comercial Classifications
|
||||
*
|
||||
* @author Sistema
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ComercialClassification
|
||||
* @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|null} description - Descripción de la clasificación
|
||||
* @property {boolean} is_active - Estado activo/inactivo
|
||||
* @property {number|null} parent_id - ID del padre
|
||||
* @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 {ComercialClassification|null} parent - Clasificación padre
|
||||
* @property {ComercialClassification[]} children - Clasificaciones hijas
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ComercialClassificationResponse
|
||||
* @property {string} status - Estado de la respuesta
|
||||
* @property {Object} data - Datos de la respuesta
|
||||
* @property {string} data.message - Mensaje de la respuesta
|
||||
* @property {ComercialClassification} data.comercial_classification - Clasificación comercial
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ComercialClassificationsListResponse
|
||||
* @property {string} status - Estado de la respuesta
|
||||
* @property {Object} data - Datos de la respuesta
|
||||
* @property {ComercialClassification[]} data.comercial_classifications - Lista de clasificaciones
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} CreateComercialClassificationData
|
||||
* @property {string} code - Código de la clasificación
|
||||
* @property {string} name - Nombre de la clasificación
|
||||
* @property {string|null} description - Descripción de la clasificación
|
||||
* @property {boolean} is_active - Estado activo/inactivo
|
||||
* @property {number|null} parent_id - ID del padre
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} UpdateComercialClassificationData
|
||||
* @property {string} [code] - Código de la clasificación
|
||||
* @property {string} [name] - Nombre de la clasificación
|
||||
* @property {string|null} [description] - Descripción de la clasificación
|
||||
* @property {boolean} [is_active] - Estado activo/inactivo
|
||||
* @property {number|null} [parent_id] - ID del padre
|
||||
*/
|
||||
|
||||
export {
|
||||
ComercialClassification,
|
||||
ComercialClassificationResponse,
|
||||
ComercialClassificationsListResponse,
|
||||
CreateComercialClassificationData,
|
||||
UpdateComercialClassificationData
|
||||
};
|
||||
@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Servicio para Comercial Classifications
|
||||
*
|
||||
* @author Sistema
|
||||
* @version 2.0.0
|
||||
*/
|
||||
|
||||
import { api, apiURL } from '@Services/Api';
|
||||
|
||||
export default class ComercialClassificationsService {
|
||||
|
||||
/**
|
||||
* Obtener todas las clasificaciones comerciales
|
||||
* @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('comercial-classifications'), {
|
||||
params,
|
||||
onSuccess: (response) => resolve(response),
|
||||
onError: (error) => reject(error)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener una clasificación comercial por ID
|
||||
* @param {number} id - ID de la clasificación
|
||||
* @returns {Promise} Promesa con la respuesta
|
||||
*/
|
||||
async getById(id) {
|
||||
return new Promise((resolve, reject) => {
|
||||
api.get(apiURL(`comercial-classifications/${id}`), {
|
||||
onSuccess: (response) => resolve(response),
|
||||
onError: (error) => reject(error)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Crear una nueva clasificación comercial
|
||||
* @param {Object} data - Datos de la clasificación
|
||||
* @param {string} data.code - Código de la clasificación
|
||||
* @param {string} data.name - Nombre de la clasificación
|
||||
* @param {string} data.description - Descripción de la clasificación
|
||||
* @param {number|null} data.parent_id - ID de la clasificación padre (null para raíz)
|
||||
* @param {boolean} data.is_active - Estado activo/inactivo
|
||||
* @returns {Promise} Promesa con la respuesta
|
||||
*/
|
||||
async create(data) {
|
||||
return new Promise((resolve, reject) => {
|
||||
api.post(apiURL('comercial-classifications'), {
|
||||
data,
|
||||
onSuccess: (response) => resolve(response),
|
||||
onError: (error) => reject(error)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar una clasificación comercial existente
|
||||
* @param {number} id - ID de la clasificación
|
||||
* @param {Object} data - Datos actualizados
|
||||
* @returns {Promise} Promesa con la respuesta
|
||||
*/
|
||||
async update(id, data) {
|
||||
return new Promise((resolve, reject) => {
|
||||
api.put(apiURL(`comercial-classifications/${id}`), {
|
||||
data,
|
||||
onSuccess: (response) => resolve(response),
|
||||
onError: (error) => reject(error)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Eliminar una clasificación comercial
|
||||
* @param {number} id - ID de la clasificación
|
||||
* @returns {Promise} Promesa con la respuesta
|
||||
*/
|
||||
async delete(id) {
|
||||
return new Promise((resolve, reject) => {
|
||||
api.delete(apiURL(`comercial-classifications/${id}`), {
|
||||
onSuccess: (response) => resolve(response),
|
||||
onError: (error) => reject(error)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar solo el estado de una clasificación comercial
|
||||
* @param {number} id - ID de la clasificación
|
||||
* @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(`comercial-classifications/${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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener clasificaciones padre disponibles (para selects)
|
||||
* @param {number|null} excludeId - ID a excluir de la lista (para evitar loops)
|
||||
* @returns {Promise} Promesa con la respuesta
|
||||
*/
|
||||
async getParentOptions(excludeId = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
api.get(apiURL('comercial-classifications'), {
|
||||
params: { exclude_children_of: excludeId },
|
||||
onSuccess: (response) => {
|
||||
// Aplanar la estructura jerárquica para el selector
|
||||
const flattenOptions = (items, level = 0) => {
|
||||
let options = [];
|
||||
items.forEach(item => {
|
||||
if (excludeId && (item.id === excludeId || this.hasDescendant(item, excludeId))) {
|
||||
return; // Excluir item actual y sus descendientes
|
||||
}
|
||||
|
||||
options.push({
|
||||
...item,
|
||||
name: ' '.repeat(level) + item.name, // Indentación visual
|
||||
level
|
||||
});
|
||||
|
||||
if (item.children && item.children.length > 0) {
|
||||
options = options.concat(flattenOptions(item.children, level + 1));
|
||||
}
|
||||
});
|
||||
return options;
|
||||
};
|
||||
|
||||
const flatOptions = flattenOptions(response.comercial_classifications?.data || response.data || []);
|
||||
resolve(flatOptions);
|
||||
},
|
||||
onError: (error) => reject(error)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Función auxiliar para verificar si un item tiene como descendiente un ID específico
|
||||
* @param {Object} item - Item a verificar
|
||||
* @param {number} targetId - ID objetivo
|
||||
* @returns {boolean} True si es descendiente
|
||||
*/
|
||||
hasDescendant(item, targetId) {
|
||||
if (!item.children) return false;
|
||||
return item.children.some(child =>
|
||||
child.id === targetId || this.hasDescendant(child, targetId)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alternar el estado de una clasificación
|
||||
* @param {Object} item - Objeto con la clasificación
|
||||
* @returns {Promise} Promesa con la respuesta
|
||||
*/
|
||||
async toggleStatus(item) {
|
||||
const newStatus = !item.is_active;
|
||||
return this.updateStatus(item.id, newStatus);
|
||||
}
|
||||
}
|
||||
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>
|
||||
198
src/pages/Products/Index.vue
Normal file
198
src/pages/Products/Index.vue
Normal file
@ -0,0 +1,198 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useSearcher } from '@Services/Api';
|
||||
import { can, apiTo, viewTo, transl } from './Module'
|
||||
|
||||
import IconButton from '@Holos/Button/Icon.vue'
|
||||
import DestroyView from '@Holos/Modal/Template/Destroy.vue';
|
||||
import SearcherHead from '@Holos/Searcher.vue';
|
||||
import Table from '@Components/ui/Table/Table.vue';
|
||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||
import ShowView from './Modals/Show.vue';
|
||||
|
||||
import Button from '@Holos/Button/Button.vue';
|
||||
import Card from '@Holos/Card/Card.vue';
|
||||
import CardContent from '@Holos/Card/CardContent.vue';
|
||||
import CardHeader from '@Holos/Card/CardHeader.vue';
|
||||
import CardTitle from '@Holos/Card/CardTitle.vue';
|
||||
import Badge from '@Components/ui/Tags/Badge.vue';
|
||||
import MaterialIcon from '@Components/ui/Icons/MaterialIcon.vue';
|
||||
|
||||
/** Propiedades */
|
||||
const models = ref();
|
||||
const router = useRouter();
|
||||
/** Configuración de paginación */
|
||||
const pagination = ref({
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
totalItems: 0
|
||||
});
|
||||
|
||||
/** Referencias */
|
||||
const showModal = ref(false);
|
||||
const destroyModal = ref(false);
|
||||
|
||||
/** Métodos */
|
||||
const searcher = useSearcher({
|
||||
url: apiTo('index'),
|
||||
onSuccess: (r) => {
|
||||
// Si hay productos de la API, los usamos
|
||||
models.value = r.products;
|
||||
// Actualizar paginación con el total de items
|
||||
pagination.value.totalItems = r.products?.data?.length || 0;
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
// En caso de error, usamos datos de prueba
|
||||
}
|
||||
});
|
||||
|
||||
/** Función para formatear atributos */
|
||||
const formatAttributes = (attributes) => {
|
||||
if (!attributes) return '-';
|
||||
|
||||
const formatted = Object.entries(attributes).map(([key]) => {
|
||||
return `${key}`;
|
||||
}).join(' | ');
|
||||
|
||||
return formatted.length > 50 ? formatted.substring(0, 50) + '...' : formatted;
|
||||
};
|
||||
|
||||
/** Configuración de columnas para la tabla */
|
||||
const columns = ref([
|
||||
{
|
||||
key: 'code',
|
||||
label: 'Código',
|
||||
sortable: true,
|
||||
width: '120px'
|
||||
},
|
||||
{
|
||||
key: 'sku',
|
||||
label: 'SKU',
|
||||
sortable: true,
|
||||
width: '120px'
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Nombre',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
label: 'Descripción',
|
||||
sortable: false
|
||||
},
|
||||
{
|
||||
key: 'attributes',
|
||||
label: 'Atributos',
|
||||
sortable: false,
|
||||
formatter: (value) => formatAttributes(value)
|
||||
},
|
||||
{
|
||||
key: 'is_active',
|
||||
label: 'Estado',
|
||||
sortable: true,
|
||||
width: '100px'
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
label: 'Fecha Creación',
|
||||
sortable: true,
|
||||
width: '140px'
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: 'Acciones',
|
||||
sortable: false,
|
||||
width: '140px'
|
||||
}
|
||||
]);
|
||||
|
||||
|
||||
|
||||
/** Ciclos */
|
||||
onMounted(() => {
|
||||
searcher.search();
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Productos</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Gestión del catálogo de productos
|
||||
</p>
|
||||
</div>
|
||||
<RouterLink
|
||||
|
||||
:to="viewTo({ name: 'create' })"
|
||||
>
|
||||
<!-- v-if="can('create')" -->
|
||||
<Button color="info">
|
||||
<MaterialIcon name="add" class="mr-2" />
|
||||
Nuevo producto
|
||||
</Button>
|
||||
</RouterLink>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent class="p-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<MaterialIcon name="inventory_2" class="h-8 w-8" />
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">3</p>
|
||||
<p class="text-sm text-muted-foreground">Total Productos</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Catálogo de Productos</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex items-center space-x-4 mb-6">
|
||||
<p>Buscador</p>
|
||||
</div>
|
||||
|
||||
<Table v-if="models?.data" :columns="columns" :data="models?.data" :loading="false" :sortable="true"
|
||||
:pagination="pagination" emptyMessage="No hay productos disponibles">
|
||||
<template #cell-attributes="{ value }">
|
||||
<div class="flex gap-1 flex-wrap">
|
||||
<Badge v-for="(val, key) in value" :key="key" :dot="false">
|
||||
{{ key }}
|
||||
</Badge>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Slot personalizado para la columna is_active -->
|
||||
<template #cell-is_active="{ value, row }">
|
||||
<Badge :color="value ? 'green' : 'red'">
|
||||
{{ value ? 'Activo' : 'Inactivo' }}
|
||||
</Badge>
|
||||
</template>
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex space-x-2">
|
||||
<Button size="sm" color="info" variant="smooth" @click="showModal = true" :iconOnly="true">
|
||||
<MaterialIcon name="visibility" :size="16"/>
|
||||
</Button>
|
||||
<Button size="sm" color="warning" variant="smooth" @click="showModal = true" :iconOnly="true">
|
||||
<MaterialIcon name="edit" :size="16"/>
|
||||
</Button>
|
||||
<Button size="sm" color="danger" variant="smooth" @click="showModal = true" :iconOnly="true">
|
||||
<MaterialIcon name="delete" :size="16"/>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
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>
|
||||
25
src/pages/Products/Module.js
Normal file
25
src/pages/Products/Module.js
Normal file
@ -0,0 +1,25 @@
|
||||
import { lang } from '@Lang/i18n';
|
||||
import { hasPermission } from '@Plugins/RolePermission.js';
|
||||
|
||||
// Ruta API
|
||||
const apiTo = (name, params = {}) => route(`products.${name}`, params)
|
||||
const comercialTo = (name, params = {}) => route(`comercial-classifications.${name}`, params)
|
||||
|
||||
// Ruta visual
|
||||
const viewTo = ({ name = '', params = {}, query = {} }) => view({
|
||||
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,
|
||||
comercialTo,
|
||||
transl
|
||||
}
|
||||
300
src/pages/Products/a.jsx
Normal file
300
src/pages/Products/a.jsx
Normal file
@ -0,0 +1,300 @@
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
Edit,
|
||||
Trash2,
|
||||
Package,
|
||||
MoreHorizontal
|
||||
} from "lucide-react"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
|
||||
// Mock data
|
||||
const products = [
|
||||
{
|
||||
id: 1,
|
||||
code: "PROD-001",
|
||||
name: "Laptop Dell Inspiron 15",
|
||||
description: "Laptop empresarial de alto rendimiento",
|
||||
category: "Electrónicos > Computadoras",
|
||||
brand: "Dell",
|
||||
price: 15999.00,
|
||||
stock: 45,
|
||||
minStock: 10,
|
||||
status: "active",
|
||||
lastUpdated: "2024-01-15"
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
code: "PROD-002",
|
||||
name: "Mouse Inalámbrico Logitech",
|
||||
description: "Mouse óptico inalámbrico con receptor USB",
|
||||
category: "Electrónicos > Accesorios",
|
||||
brand: "Logitech",
|
||||
price: 599.00,
|
||||
stock: 120,
|
||||
minStock: 25,
|
||||
status: "active",
|
||||
lastUpdated: "2024-01-14"
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
code: "PROD-003",
|
||||
name: "Monitor LG 24 pulgadas",
|
||||
description: "Monitor LED Full HD con conexión HDMI",
|
||||
category: "Electrónicos > Monitores",
|
||||
brand: "LG",
|
||||
price: 3299.00,
|
||||
stock: 8,
|
||||
minStock: 15,
|
||||
status: "low-stock",
|
||||
lastUpdated: "2024-01-13"
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
code: "PROD-004",
|
||||
name: "Teclado Mecánico RGB",
|
||||
description: "Teclado mecánico para gaming con iluminación RGB",
|
||||
category: "Electrónicos > Accesorios",
|
||||
brand: "Corsair",
|
||||
price: 2199.00,
|
||||
stock: 0,
|
||||
minStock: 5,
|
||||
status: "out-of-stock",
|
||||
lastUpdated: "2024-01-12"
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
code: "PROD-005",
|
||||
name: "Webcam HD Logitech",
|
||||
description: "Cámara web HD para videoconferencias",
|
||||
category: "Electrónicos > Accesorios",
|
||||
brand: "Logitech",
|
||||
price: 899.00,
|
||||
stock: 32,
|
||||
minStock: 10,
|
||||
status: "active",
|
||||
lastUpdated: "2024-01-11"
|
||||
}
|
||||
]
|
||||
|
||||
const getStatusBadge = (status: string, stock: number, minStock: number) => {
|
||||
if (stock === 0) {
|
||||
return <Badge variant="destructive">Sin Stock</Badge>
|
||||
}
|
||||
if (stock <= minStock) {
|
||||
return <Badge className="bg-warning text-warning-foreground">Stock Bajo</Badge>
|
||||
}
|
||||
return <Badge className="bg-success text-success-foreground">Activo</Badge>
|
||||
}
|
||||
|
||||
export default function Products() {
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [filteredProducts, setFilteredProducts] = useState(products)
|
||||
|
||||
const handleSearch = (term: string) => {
|
||||
setSearchTerm(term)
|
||||
const filtered = products.filter(product =>
|
||||
product.name.toLowerCase().includes(term.toLowerCase()) ||
|
||||
product.code.toLowerCase().includes(term.toLowerCase()) ||
|
||||
product.brand.toLowerCase().includes(term.toLowerCase())
|
||||
)
|
||||
setFilteredProducts(filtered)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Productos</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Gestión del catálogo de productos
|
||||
</p>
|
||||
</div>
|
||||
<Button className="bg-primary hover:bg-primary-hover">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nuevo Producto
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Package className="w-8 h-8 text-primary" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-metric-value">{products.length}</p>
|
||||
<p className="text-sm text-muted-foreground">Total Productos</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-8 h-8 rounded-full bg-success flex items-center justify-center">
|
||||
<span className="text-success-foreground font-bold text-sm">✓</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-success">
|
||||
{products.filter(p => p.status === "active").length}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Activos</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-8 h-8 rounded-full bg-warning flex items-center justify-center">
|
||||
<span className="text-warning-foreground font-bold text-sm">!</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-warning">
|
||||
{products.filter(p => p.status === "low-stock").length}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Stock Bajo</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-8 h-8 rounded-full bg-destructive flex items-center justify-center">
|
||||
<span className="text-destructive-foreground font-bold text-sm">×</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-destructive">
|
||||
{products.filter(p => p.status === "out-of-stock").length}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Sin Stock</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Catálogo de Productos</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center space-x-4 mb-6">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Buscar productos..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
Filtros
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Products Table */}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Código</TableHead>
|
||||
<TableHead>Producto</TableHead>
|
||||
<TableHead>Categoría</TableHead>
|
||||
<TableHead>Marca</TableHead>
|
||||
<TableHead>Precio</TableHead>
|
||||
<TableHead>Stock</TableHead>
|
||||
<TableHead>Estado</TableHead>
|
||||
<TableHead className="w-10"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredProducts.map((product) => (
|
||||
<TableRow key={product.id}>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{product.code}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium">{product.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{product.description}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{product.category}
|
||||
</TableCell>
|
||||
<TableCell>{product.brand}</TableCell>
|
||||
<TableCell className="font-mono">
|
||||
${product.price.toLocaleString('es-MX')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-right">
|
||||
<p className="font-medium">{product.stock}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Min: {product.minStock}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{getStatusBadge(product.status, product.stock, product.minStock)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
Editar
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Package className="w-4 h-4 mr-2" />
|
||||
Ver Stock
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-destructive">
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Eliminar
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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)
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -15,6 +15,7 @@ import IconButton from '@Holos/Button/Icon.vue';
|
||||
import Button from '@Holos/Button/Button.vue';
|
||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||
|
||||
import Badge from '@Components/ui/Tags/Badge.vue';
|
||||
/** Definiciones */
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@ -76,6 +77,7 @@ async function loadWarehouse() {
|
||||
const response = await api.get(apiTo('show', { warehouse: route.params.id }), {
|
||||
onSuccess: (r) => {
|
||||
warehouse.value = r.warehouse;
|
||||
console.log('Warehouse data:', r.warehouse);
|
||||
},
|
||||
onError: (err) => {
|
||||
error.value = 'Error cargando el almacén';
|
||||
@ -247,12 +249,12 @@ onMounted(() => {
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Ubicación</p>
|
||||
<p class="text-base text-gray-900 dark:text-gray-100">
|
||||
{{ warehouse.address || 'Guadalajara, Zona Industrial' }}
|
||||
{{ warehouse.address || 'Sin dirección' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Categoría -->
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-2" v-if="warehouse.classifications && warehouse.classifications.length">
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Clasificaciones</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span v-for="classification in warehouse.classifications" :key="classification.id"
|
||||
@ -262,7 +264,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-2" v-if="warehouse.classifications && warehouse.classifications.length">
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Subclasificaciones</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span v-for="child in getAllChildren(warehouse.classifications)" :key="child.id"
|
||||
@ -275,12 +277,9 @@ onMounted(() => {
|
||||
<!-- Estado -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Estado</p>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium"
|
||||
:class="warehouse.is_active
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
||||
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'">
|
||||
<Badge :color="warehouse.is_active ? 'green' : 'red'">
|
||||
{{ warehouse.is_active ? 'Activo' : 'Inactivo' }}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -323,6 +322,55 @@ onMounted(() => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Stock by Products -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center space-x-2">
|
||||
<GoogleIcon class="w-6 h-6 text-primary" name="inventory" />
|
||||
<span>Stock por Productos</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Inventario actual de productos en este almacén
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div v-for="warehouseStock in stockByWarehouse" :key="warehouseStock.warehouse" class="space-y-4">
|
||||
<h4
|
||||
class="font-semibold text-gray-900 dark:text-gray-100 border-b border-gray-200 dark:border-gray-700 pb-2">
|
||||
{{ warehouseStock.warehouse }}
|
||||
</h4>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div v-for="product in warehouseStock.products" :key="product.code"
|
||||
class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span
|
||||
class="text-xs font-mono text-gray-500 bg-white dark:bg-gray-900 px-2 py-1 rounded">
|
||||
{{ product.code }}
|
||||
</span>
|
||||
<span class="text-sm font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ product.stock }} unidades
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h5 class="font-medium text-gray-900 dark:text-gray-100 mb-2 text-sm">
|
||||
{{ product.name }}
|
||||
</h5>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">Valor total:</span>
|
||||
<span class="text-sm font-semibold text-green-600 dark:text-green-400">
|
||||
{{ formatCurrency(product.value) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Recent Movements -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@ -389,68 +437,7 @@ onMounted(() => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Stock by Products -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center space-x-2">
|
||||
<GoogleIcon class="w-6 h-6 text-primary" name="inventory" />
|
||||
<span>Stock por Productos</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Inventario actual de productos en este almacén
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div v-for="warehouseStock in stockByWarehouse" :key="warehouseStock.warehouse" class="space-y-4">
|
||||
<h4
|
||||
class="font-semibold text-gray-900 dark:text-gray-100 border-b border-gray-200 dark:border-gray-700 pb-2">
|
||||
{{ warehouseStock.warehouse }}
|
||||
</h4>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div v-for="product in warehouseStock.products" :key="product.code"
|
||||
class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span
|
||||
class="text-xs font-mono text-gray-500 bg-white dark:bg-gray-900 px-2 py-1 rounded">
|
||||
{{ product.code }}
|
||||
</span>
|
||||
<span class="text-sm font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ product.stock }} unidades
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h5 class="font-medium text-gray-900 dark:text-gray-100 mb-2 text-sm">
|
||||
{{ product.name }}
|
||||
</h5>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">Valor total:</span>
|
||||
<span class="text-sm font-semibold text-green-600 dark:text-green-400">
|
||||
{{ formatCurrency(product.value) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar for stock level -->
|
||||
<div class="mt-3">
|
||||
<div
|
||||
class="flex items-center justify-between text-xs text-gray-600 dark:text-gray-400 mb-1">
|
||||
<span>Stock</span>
|
||||
<span>{{ Math.round((product.stock / 100) * 100) }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div class="h-2 rounded-full transition-all duration-300" :class="product.stock > 50 ? 'bg-green-500' :
|
||||
product.stock > 20 ? 'bg-yellow-500' : 'bg-red-500'"
|
||||
:style="`width: ${Math.min(100, (product.stock / 100) * 100)}%`">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -16,7 +16,7 @@ import CardHeader from '@Holos/Card/CardHeader.vue';
|
||||
import CardTitle from '@Holos/Card/CardTitle.vue';
|
||||
import CardDescription from '@Holos/Card/CardDescription.vue';
|
||||
import CardContent from '@Holos/Card/CardContent.vue';
|
||||
import Button from '@Holos/Button/Button.vue';
|
||||
//import Button from '@Holos/Button/Button.vue';
|
||||
|
||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||
|
||||
@ -58,6 +58,8 @@ const debugNavigation = (warehouse) => {
|
||||
onMounted(() => {
|
||||
searcher.search();
|
||||
});
|
||||
|
||||
import Button from "primevue/button"
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -67,6 +69,7 @@ onMounted(() => {
|
||||
<h1 className="text-3xl font-bold text-foreground">Inventario</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Control de stock y movimientos por almacén
|
||||
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -129,21 +132,18 @@ onMounted(() => {
|
||||
<div class="flex justify-end mt-3 pt-2 border-t border-border gap-3">
|
||||
|
||||
<RouterLink class="h-fit" :to="viewTo({ name: 'details', params: { id: warehouse.id } })">
|
||||
<Button size="sm" variant="solid" color="info" asLink>
|
||||
Ver Detalles
|
||||
</Button>
|
||||
<Button severity="info" label="Ver detalles" />
|
||||
</RouterLink>
|
||||
|
||||
<Button severity="primary" label="Ver detalles" />
|
||||
|
||||
<!-- Opción 1: RouterLink + asLink -->
|
||||
<RouterLink class="h-fit" :to="viewTo({ name: 'edit', params: { id: warehouse.id } })">
|
||||
<Button size="sm" variant="solid" color="warning" asLink>
|
||||
Editar
|
||||
</Button>
|
||||
<Button severity="warn" label="Editar" />
|
||||
</RouterLink>
|
||||
|
||||
<Button size="sm" variant="solid" @click="deleteItem(warehouse)" color="danger">
|
||||
Eliminar
|
||||
</Button>
|
||||
<Button severity="danger" label="Eliminar" @click="deleteItem(warehouse)"/>
|
||||
|
||||
</div>
|
||||
|
||||
</CardContent>
|
||||
|
||||
@ -494,6 +494,91 @@ const router = createRouter({
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'comercial-classifications',
|
||||
name: 'admin.comercial-classifications',
|
||||
meta: {
|
||||
title: 'Clasificaciones Comerciales',
|
||||
icon: 'category',
|
||||
},
|
||||
redirect: '/admin/comercial-classifications',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'admin.comercial-classifications.index',
|
||||
component: () => import('@Pages/ComercialClassifications/Index.vue'),
|
||||
},
|
||||
{
|
||||
path: 'create',
|
||||
name: 'admin.comercial-classifications.create',
|
||||
component: () => import('@Pages/ComercialClassifications/Create.vue'),
|
||||
meta: {
|
||||
title: 'Crear',
|
||||
icon: 'add',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: ':id/edit',
|
||||
name: 'admin.comercial-classifications.edit',
|
||||
component: () => import('@Pages/ComercialClassifications/Edit.vue'),
|
||||
meta: {
|
||||
title: 'Editar',
|
||||
icon: 'edit',
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'products',
|
||||
name: 'admin.products',
|
||||
meta: {
|
||||
title: 'Productos',
|
||||
icon: 'inventory',
|
||||
},
|
||||
redirect: '/admin/products',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'admin.products.index',
|
||||
component: () => import('@Pages/Products/Index.vue'),
|
||||
},
|
||||
{
|
||||
path: 'create',
|
||||
name: 'admin.products.create',
|
||||
component: () => import('@Pages/Products/Create.vue'),
|
||||
meta: {
|
||||
title: 'Crear',
|
||||
icon: 'add',
|
||||
}
|
||||
}
|
||||
/*
|
||||
{
|
||||
path: ':id/edit',
|
||||
name: 'admin.products.edit',
|
||||
component: () => import('@Pages/Products/Edit.vue'),
|
||||
meta: {
|
||||
title: 'Editar',
|
||||
icon: 'edit',
|
||||
},
|
||||
} */
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'pos',
|
||||
name: 'admin.pos',
|
||||
meta: {
|
||||
title: 'Punto de Venta',
|
||||
icon: 'point_of_sale',
|
||||
},
|
||||
redirect: '/admin/pos',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'admin.pos.index',
|
||||
component: () => import('@Pages/Pos/Index.vue'),
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'roles',
|
||||
name: 'admin.roles',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user