feature-comercial-module #9

Merged
edgar.mendez merged 5 commits from feature-comercial-module into develop 2025-11-03 15:52:49 +00:00
45 changed files with 5041 additions and 104 deletions

0
install.sh Executable file → Normal file
View File

93
package-lock.json generated
View File

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

View File

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

View File

@ -23,7 +23,6 @@ interface Props {
loading?: boolean;
fullWidth?: boolean;
iconOnly?: boolean;
asLink?: boolean; // Nueva prop para comportamiento de link
}
const props = withDefaults(defineProps<Props>(), {
@ -35,7 +34,6 @@ const props = withDefaults(defineProps<Props>(), {
loading: false,
fullWidth: false,
iconOnly: false,
asLink: true, // Por defecto no es link
});
@ -44,15 +42,8 @@ const emit = defineEmits<{
}>();
function handleClick(event: MouseEvent) {
// Si es usado como link, no bloquear la navegación
if (props.asLink) {
emit('click', event);
return;
}
// Para botones normales, validar estados
if (props.disabled || props.loading) return;
emit('click', event);
}
const buttonClasses = computed(() => {
@ -82,7 +73,7 @@ const buttonClasses = computed(() => {
solid: ['shadow-sm'],
outline: ['border', 'bg-white', 'hover:bg-gray-50'],
ghost: ['bg-transparent', 'hover:bg-gray-100'],
smooth: ['bg-opacity-20', 'font-bold', 'shadow-none'],
smooth: ['bg-opacity-20', 'font-bold', 'uppercase', 'shadow-none'],
};
// Colores por tipo

0
src/components/Holos/Inbox.vue Executable file → Normal file
View File

0
src/components/Holos/Inbox/Item.vue Executable file → Normal file
View File

0
src/components/Holos/Inbox/ItemTitle.vue Executable file → Normal file
View File

0
src/components/Holos/Inbox/Menu/Item.vue Executable file → Normal file
View File

0
src/components/Holos/Inbox/Menu/Static.vue Executable file → Normal file
View File

View File

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

114
src/components/ui/Input.vue Normal file
View 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>

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,60 @@
import { computed, ref } from 'vue';
export function usePagination(initialConfig) {
const currentPage = ref(initialConfig.currentPage);
const pageSize = ref(initialConfig.pageSize);
const totalItems = ref(initialConfig.totalItems);
const totalPages = computed(() => Math.ceil(totalItems.value / pageSize.value));
const startIndex = computed(() => (currentPage.value - 1) * pageSize.value);
const endIndex = computed(() => Math.min(startIndex.value + pageSize.value, totalItems.value));
const hasNextPage = computed(() => currentPage.value < totalPages.value);
const hasPreviousPage = computed(() => currentPage.value > 1);
const goToPage = (page) => {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page;
}
};
const nextPage = () => {
if (hasNextPage.value) {
currentPage.value++;
}
};
const previousPage = () => {
if (hasPreviousPage.value) {
currentPage.value--;
}
};
const setPageSize = (size) => {
pageSize.value = size;
currentPage.value = 1;
};
const updateTotalItems = (total) => {
totalItems.value = total;
};
return {
currentPage,
pageSize,
totalItems,
totalPages,
startIndex,
endIndex,
hasNextPage,
hasPreviousPage,
goToPage,
nextPage,
previousPage,
setPageSize,
updateTotalItems,
};
}

View File

@ -0,0 +1,50 @@
import { ref, computed } from 'vue';
export function useSort(initialData) {
const sortConfig = ref(null);
const sortedData = computed(() => {
if (!sortConfig.value) {
return initialData;
}
const { key, direction } = sortConfig.value;
const sorted = [...initialData];
sorted.sort((a, b) => {
const aValue = a[key];
const bValue = b[key];
if (aValue === bValue) return 0;
const comparison = aValue > bValue ? 1 : -1;
return direction === 'asc' ? comparison : -comparison;
});
return sorted;
});
const toggleSort = (key) => {
if (!sortConfig.value || sortConfig.value.key !== key) {
sortConfig.value = { key, direction: 'asc' };
} else if (sortConfig.value.direction === 'asc') {
sortConfig.value = { key, direction: 'desc' };
} else {
sortConfig.value = null;
}
};
const getSortDirection = (key) => {
if (!sortConfig.value || sortConfig.value.key !== key) {
return null;
}
return sortConfig.value.direction;
};
return {
sortConfig,
sortedData,
toggleSort,
getSortDirection,
};
}

View File

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

0
src/controllers/PrintController.js Executable file → Normal file
View File

View File

@ -75,3 +75,58 @@
font-weight: 400;
src: url(./icons/google/gNNBW2J8Roq16WD5tFNRaeLQk6-SHQ_R00k4c2_whPnoY9ruReYU3rHmz74m0ZkGH-VBYe1x0TV6x4yFH8F-H5OdzEL3sVTgJtfbYxOLojCL.woff2) format('woff2');
}
/* Clases para MaterialIcon component */
.material-symbols-outlined {
font-family: 'Material Symbols Outlined';
font-weight: normal;
font-style: normal;
font-size: 24px;
display: inline-block;
line-height: 1;
text-transform: none;
letter-spacing: normal;
word-wrap: normal;
white-space: nowrap;
direction: ltr;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
font-feature-settings: 'liga';
}
.material-symbols-rounded {
font-family: 'Material Symbols Rounded';
font-weight: normal;
font-style: normal;
font-size: 24px;
display: inline-block;
line-height: 1;
text-transform: none;
letter-spacing: normal;
word-wrap: normal;
white-space: nowrap;
direction: ltr;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
font-feature-settings: 'liga';
}
.material-symbols-sharp {
font-family: 'Material Symbols Sharp';
font-weight: normal;
font-style: normal;
font-size: 24px;
display: inline-block;
line-height: 1;
text-transform: none;
letter-spacing: normal;
word-wrap: normal;
white-space: nowrap;
direction: ltr;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
font-feature-settings: 'liga';
}

View File

@ -1,3 +1,4 @@
import Aura from '@primeuix/themes/aura';
import './css/base.css'
import axios from 'axios';
@ -13,10 +14,16 @@ 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 App from '@Components/App.vue'
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';
@ -24,9 +31,9 @@ import { hasToken } from './services/Api';
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
// Elementos globales
window.axios = axios;
window.Lang = lang;
window.Notify = new Notify();
window.axios = axios;
window.Lang = lang;
window.Notify = new Notify();
window.TwScreen = new TailwindScreen();
async function boot() {
@ -40,26 +47,44 @@ async function boot() {
window.Ziggy = routes.data;
defineApp(appData.data);
window.route = useRoute();
window.view = view;
initRoutes = true;
window.view = view;
initRoutes = true;
} catch (error) {
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)

View File

@ -78,6 +78,19 @@ export default {
activity: {
title: 'Historial de acciones',
description: 'Historial de acciones realizadas por los usuarios en orden cronológico.'
},
products: {
name: 'Productos',
title: 'Productos',
description: 'Gestión del catálogo de productos',
create: {
title: 'Crear producto',
description: 'Permite crear un nuevo producto en el catálogo con sus clasificaciones y atributos personalizados.'
},
edit: {
title: 'Editar producto',
description: 'Actualiza la información del producto, sus clasificaciones y atributos.'
}
}
},
app: {
@ -193,6 +206,8 @@ export default {
done:'Hecho.',
edit:'Editar',
edited:'Registro creado',
active:'Activo',
inactive:'Inactivo',
email:{
title:'Correo',
verification:'Verificar correo'

View File

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

View 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>

View 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>

View 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>

View 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>

View 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>

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

View File

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

View File

@ -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
View File

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

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

@ -0,0 +1,23 @@
import { lang } from '@Lang/i18n';
import { hasPermission } from '@Plugins/RolePermission.js';
// Ruta API
const apiTo = (name, params = {}) => route(`products.${name}`, params)
// Ruta visual
const viewTo = ({ name = '', params = {}, query = {} }) => view({
name: `admin.products.${name}`, params, query
})
// Obtener traducción del componente
const transl = (str) => lang(`admin.products.${str}`)
// Control de permisos
const can = (permission) => hasPermission(`admin.products.${permission}`)
export {
can,
viewTo,
apiTo,
transl
}

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,82 @@
/**
* Interfaces para Products
*
* @author Sistema
* @version 1.0.0
*/
/**
* @typedef {Object} Product
* @property {number} id - ID del producto
* @property {string} code - Código del producto
* @property {string} sku - SKU del producto
* @property {string} name - Nombre del producto
* @property {string|null} description - Descripción del producto
* @property {Object|null} attributes - Atributos dinámicos del producto (JSON)
* @property {boolean} is_active - Estado activo/inactivo
* @property {string} created_at - Fecha de creación
* @property {string} updated_at - Fecha de actualización
* @property {string|null} deleted_at - Fecha de eliminación
* @property {ProductClassification[]} classifications - Clasificaciones asociadas
* @property {ProductClassification[]} warehouse_classifications - Clasificaciones de almacén
* @property {ProductClassification[]} comercial_classifications - Clasificaciones comerciales
*/
/**
* @typedef {Object} ProductClassification
* @property {number} id - ID de la clasificación
* @property {string} code - Código de la clasificación
* @property {string} name - Nombre de la clasificación
* @property {string} type - Tipo de clasificación (warehouse/comercial)
*/
/**
* @typedef {Object} ProductResponse
* @property {string} status - Estado de la respuesta
* @property {Object} data - Datos de la respuesta
* @property {string} data.message - Mensaje de la respuesta
* @property {Product} data.product - Producto
*/
/**
* @typedef {Object} ProductsListResponse
* @property {string} status - Estado de la respuesta
* @property {Object} data - Datos de la respuesta
* @property {Object} data.products - Lista de productos con paginación
* @property {Product[]} data.products.data - Array de productos
* @property {number} data.products.current_page - Página actual
* @property {number} data.products.total - Total de productos
*/
/**
* @typedef {Object} CreateProductData
* @property {string} code - Código del producto
* @property {string} sku - SKU del producto
* @property {string} name - Nombre del producto
* @property {string|null} description - Descripción del producto
* @property {Object|null} attributes - Atributos dinámicos (JSON)
* @property {boolean} is_active - Estado activo/inactivo
* @property {number[]} warehouse_classification_ids - IDs de clasificaciones de almacén
* @property {number[]} comercial_classification_ids - IDs de clasificaciones comerciales
*/
/**
* @typedef {Object} UpdateProductData
* @property {string} [code] - Código del producto
* @property {string} [sku] - SKU del producto
* @property {string} [name] - Nombre del producto
* @property {string|null} [description] - Descripción del producto
* @property {Object|null} [attributes] - Atributos dinámicos (JSON)
* @property {boolean} [is_active] - Estado activo/inactivo
* @property {number[]} [warehouse_classification_ids] - IDs de clasificaciones de almacén
* @property {number[]} [comercial_classification_ids] - IDs de clasificaciones comerciales
*/
export {
Product,
ProductClassification,
ProductResponse,
ProductsListResponse,
CreateProductData,
UpdateProductData
};

View File

@ -0,0 +1,263 @@
/**
* Servicio para Products
*
* @author Sistema
* @version 1.0.0
*/
import { api, apiURL } from '@Services/Api';
export default class ProductService {
/**
* Obtener todos los productos
* @param {Object} params - Parámetros de la consulta
* @returns {Promise} Promesa con la respuesta
*/
async getAll(params = {}) {
return new Promise((resolve, reject) => {
api.get(apiURL('catalogs/products'), {
params,
onSuccess: (response) => resolve(response),
onError: (error) => reject(error)
});
});
}
/**
* Obtener un producto por ID
* @param {number} id - ID del producto
* @returns {Promise} Promesa con la respuesta
*/
async getById(id) {
return new Promise((resolve, reject) => {
api.get(apiURL(`catalogs/products/${id}`), {
onSuccess: (response) => resolve(response),
onError: (error) => reject(error)
});
});
}
/**
* Crear un nuevo producto
* @param {Object} data - Datos del producto
* @param {string} data.code - Código del producto
* @param {string} data.sku - SKU del producto
* @param {string} data.name - Nombre del producto
* @param {string|null} data.description - Descripción del producto
* @param {Object|null} data.attributes - Atributos dinámicos
* @param {boolean} data.is_active - Estado activo/inactivo
* @param {number[]} data.warehouse_classification_ids - IDs de clasificaciones de almacén
* @param {number[]} data.comercial_classification_ids - IDs de clasificaciones comerciales
* @returns {Promise} Promesa con la respuesta
*/
async create(data) {
return new Promise((resolve, reject) => {
api.post(apiURL('catalogs/products'), {
data,
onSuccess: (response) => {
resolve(response);
},
onError: (error) => { reject(error); }
});
});
}
/**
* Actualizar un producto
* @param {number} id - ID del producto
* @param {Object} data - Datos a actualizar
* @param {string} [data.code] - Código del producto
* @param {string} [data.sku] - SKU del producto
* @param {string} [data.name] - Nombre del producto
* @param {string|null} [data.description] - Descripción del producto
* @param {Object|null} [data.attributes] - Atributos dinámicos
* @param {boolean} [data.is_active] - Estado activo/inactivo
* @param {number[]} [data.warehouse_classification_ids] - IDs de clasificaciones de almacén
* @param {number[]} [data.comercial_classification_ids] - IDs de clasificaciones comerciales
* @returns {Promise} Promesa con la respuesta
*/
async update(id, data) {
return new Promise((resolve, reject) => {
api.put(apiURL(`catalogs/products/${id}`), {
data,
onSuccess: (response) => resolve(response),
onError: (error) => reject(error)
});
});
}
/**
* Actualizar solo el estado de un producto
* @param {number} id - ID del producto
* @param {boolean} is_active - Nuevo estado
* @returns {Promise} Promesa con la respuesta
*/
async updateStatus(id, is_active) {
return new Promise((resolve, reject) => {
api.put(apiURL(`catalogs/products/${id}`), {
data: { is_active },
onSuccess: (response) => {
resolve(response);
},
onError: (error) => {
// Mejorar el manejo de errores
const enhancedError = {
...error,
timestamp: new Date().toISOString(),
action: 'updateStatus',
id: id,
is_active: is_active
};
reject(enhancedError);
}
});
});
}
/**
* Eliminar un producto
* @param {number} id - ID del producto
* @returns {Promise} Promesa con la respuesta
*/
async delete(id) {
return new Promise((resolve, reject) => {
api.delete(apiURL(`catalogs/products/${id}`), {
onSuccess: (response) => resolve(response),
onError: (error) => reject(error)
});
});
}
/**
* Alternar el estado de un producto
* @param {Object} item - Objeto con el producto
* @returns {Promise} Promesa con la respuesta
*/
async toggleStatus(item) {
const newStatus = !item.is_active;
return this.updateStatus(item.id, newStatus);
}
/**
* Obtener clasificaciones disponibles para productos
* @param {string} type - Tipo de clasificación ('warehouse' o 'comercial')
* @returns {Promise} Promesa con la respuesta
*/
async getClassifications(type = 'warehouse') {
return new Promise((resolve, reject) => {
const endpoint = type === 'warehouse'
? 'catalogs/warehouse-classifications'
: 'comercial-classifications';
api.get(apiURL(endpoint), {
onSuccess: (response) => {
// Aplanar la estructura jerárquica para selects
const flattenOptions = (items, level = 0) => {
let options = [];
items.forEach(item => {
options.push({
...item,
label: ' '.repeat(level) + item.name,
value: item.id,
level
});
if (item.children && item.children.length > 0) {
options = options.concat(flattenOptions(item.children, level + 1));
}
});
return options;
};
// Determinar la clave de datos según el tipo
const dataKey = type === 'warehouse'
? 'warehouse_classifications'
: 'comercial_classifications';
const data = response[dataKey]?.data || response.data || [];
const flatOptions = flattenOptions(data);
resolve(flatOptions);
},
onError: (error) => reject(error)
});
});
}
/**
* Obtener clasificaciones de almacén
* @returns {Promise} Promesa con la respuesta
*/
async getWarehouseClassifications() {
return this.getClassifications('warehouse');
}
/**
* Obtener clasificaciones comerciales
* @returns {Promise} Promesa con la respuesta
*/
async getComercialClassifications() {
return this.getClassifications('comercial');
}
/**
* Buscar productos con filtros avanzados
* @param {Object} filters - Filtros de búsqueda
* @param {string} [filters.search] - Búsqueda por nombre, código o SKU
* @param {boolean} [filters.is_active] - Filtrar por estado
* @param {number[]} [filters.warehouse_classification_ids] - Filtrar por clasificaciones de almacén
* @param {number[]} [filters.comercial_classification_ids] - Filtrar por clasificaciones comerciales
* @returns {Promise} Promesa con la respuesta
*/
async search(filters = {}) {
return this.getAll({ ...filters });
}
/**
* Exportar productos a formato específico
* @param {string} format - Formato de exportación (csv, excel, pdf)
* @param {Object} filters - Filtros aplicados
* @returns {Promise} Promesa con la respuesta
*/
async export(format = 'excel', filters = {}) {
return new Promise((resolve, reject) => {
api.get(apiURL(`catalogs/products/export/${format}`), {
params: filters,
responseType: 'blob',
onSuccess: (response) => resolve(response),
onError: (error) => reject(error)
});
});
}
/**
* Duplicar un producto
* @param {number} id - ID del producto a duplicar
* @returns {Promise} Promesa con la respuesta
*/
async duplicate(id) {
return new Promise((resolve, reject) => {
api.post(apiURL(`catalogs/products/${id}/duplicate`), {
onSuccess: (response) => resolve(response),
onError: (error) => reject(error)
});
});
}
/**
* Validar si un código o SKU ya existe
* @param {string} field - Campo a validar ('code' o 'sku')
* @param {string} value - Valor a validar
* @param {number|null} excludeId - ID a excluir de la validación (para edición)
* @returns {Promise<boolean>} True si existe, false si no
*/
async checkExists(field, value, excludeId = null) {
return new Promise((resolve, reject) => {
api.get(apiURL('catalogs/products/check-exists'), {
params: { field, value, exclude_id: excludeId },
onSuccess: (response) => resolve(response.exists || false),
onError: (error) => reject(error)
});
});
}
}

View File

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

View File

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

View File

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