Add: Feature warehouse

This commit is contained in:
Edgar Méndez Mendoza 2025-09-30 10:28:36 -06:00
parent efcad3fe1d
commit 6b7f80d53a
20 changed files with 3873 additions and 115 deletions

View File

@ -23,6 +23,7 @@ interface Props {
loading?: boolean;
fullWidth?: boolean;
iconOnly?: boolean;
asLink?: boolean; // Nueva prop para comportamiento de link
}
const props = withDefaults(defineProps<Props>(), {
@ -34,6 +35,7 @@ const props = withDefaults(defineProps<Props>(), {
loading: false,
fullWidth: false,
iconOnly: false,
asLink: true, // Por defecto no es link
});
@ -42,8 +44,15 @@ const emit = defineEmits<{
}>();
function handleClick(event: MouseEvent) {
if (props.disabled || props.loading) return;
// 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;
}
const buttonClasses = computed(() => {

View File

@ -93,6 +93,11 @@ onMounted(() => {
name="Clasificaciones de almacenes"
to="admin.warehouse-classifications.index"
/>
<Link
icon="straighten"
name="Unidades de medida"
to="admin.units-measure.index"
/>
</Section>
<Section name="Capacitaciones">
<DropDown

View File

@ -0,0 +1,63 @@
<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: '',
abbreviation: '',
type: 4, // Unidad por defecto
is_active: true
});
/** Métodos */
function submit() {
form.transform(data => ({
...data,
type: data.type?.value || data.type, // Extraer solo el value si es objeto, sino usar tal como está
is_active: data.is_active ? 1 : 0 // Convertir boolean a número
})).post(apiTo('store'), {
onSuccess: () => {
Notify.success('Unidad de medida creada con éxito')
router.push(viewTo({ name: 'index' }));
}
})
}
/** Ciclos */
onMounted(() => {
// Inicialización si es necesaria
});
</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,104 @@
<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 UnitsMeasureService from './services/UnitsMeasureService';
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();
const unitsService = new UnitsMeasureService();
/** Propiedades */
const form = useForm({
id: null,
code: '',
name: '',
abbreviation: '',
type: 4,
is_active: true
});
/** Métodos */
function submit() {
form.transform(data => ({
...data,
type: data.type?.value || data.type, // Extraer solo el value si es objeto, sino usar tal como está
is_active: data.is_active ? 1 : 0 // Convertir boolean a número
})).put(apiTo('update', { units_of_measure: form.id }), {
onSuccess: () => {
Notify.success(lang('register.edit.onSuccess'))
router.push(viewTo({ name: 'index' }));
},
})
}
function loadData() {
api.get(apiTo('show', { units_of_measure: vroute.params.id }), {
onSuccess: async (r) => {
const data = r.data || r.units_of_measure || r;
console.log(data);
// Cargar los tipos para convertir el número a objeto
try {
const typeOptions = await unitsService.getUnitTypes();
const selectedType = typeOptions.find(option => option.id == data.type) || { value: data.type, label: data.name };
console.log('Tipos disponibles:', selectedType);
form.fill({
id: data.units.id,
code: data.units.code,
name: data.units.name,
abbreviation: data.units.abbreviation,
type: selectedType, // Convertir a objeto {value, label}
is_active: Boolean(data.is_active)
});
} catch (error) {
console.error('Error cargando tipos:', error);
// Fallback: usar solo el valor numérico
form.fill({
id: data.units.id,
code: data.units.code,
name: data.units.name,
abbreviation: data.units.abbreviation,
type: data.type,
is_active: Boolean(data.is_active)
});
}
}
})
}
/** Ciclos */
onMounted(() => {
loadData();
});
</script>
<template>
<PageHeader
:title="transl('update.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>

View File

@ -0,0 +1,108 @@
<script setup>
import { ref, onMounted } from 'vue';
import { transl } from './Module';
import UnitsMeasureService from './services/UnitsMeasureService';
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'
])
/** Servicios */
const unitsService = new UnitsMeasureService();
/** Propiedades reactivas */
const typeOptions = ref([]);
const loadingTypes = ref(true);
/** Propiedades */
defineProps({
action: {
default: 'create',
type: String
},
form: Object
})
/** Métodos */
function submit() {
emit('submit')
}
/** Cargar tipos de medida desde la API */
async function loadUnitTypes() {
try {
loadingTypes.value = true;
typeOptions.value = await unitsService.getUnitTypes();
} catch (error) {
console.error('Error cargando tipos de medida:', error);
// Usar tipos por defecto en caso de error
const fallbackTypes = unitsService.getTypes().map(type => ({
value: type.id,
label: type.name
}));
typeOptions.value = fallbackTypes;
} finally {
loadingTypes.value = false;
}
}
/** Ciclo de vida */
onMounted(() => {
loadUnitTypes();
});
</script>
<template>
<div class="w-full pb-2">
<p class="text-justify text-sm" v-text="transl(`form.${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.abbreviation"
id="Abreviación"
:onError="form.errors.abbreviation"
required
/>
<Selectable
v-model="form.type"
id="type"
title="Tipo de medida"
label="label"
:onError="form.errors.type"
:options="typeOptions"
:disabled="loadingTypes"
required
/>
<div class="md:col-span-2 lg:col-span-3 xl:col-span-4">
<PrimaryButton
:loading="form.processing"
:disabled="form.processing"
>
Crear unidad de medida
</PrimaryButton>
</div>
</form>
</div>
</template>

View File

@ -0,0 +1,124 @@
<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';
/** 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) => models.value = r.units_of_measure,
onError: () => models.value = []
});
/** Función para eliminar */
const deleteItem = (item) => {
destroyModal.value.open(item);
};
/** Ciclos */
onMounted(() => {
searcher.search();
});
</script>
<template>
<div>
<SearcherHead :title="transl('name')" @search="(x) => searcher.search(x)">
<RouterLink :to="viewTo({ name: 'create' })">
<!-- v-if="can('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('Abreviatura')" />
<th v-text="$t('Unidad de medida')" />
<th v-text="$t('Tipo de unidad')" />
<th v-text="$t('Estado')" class="w-20 text-center" />
<th v-text="$t('Acciones')" class="w-64 text-center" />
</template>
<template #body="{ items }">
<tr v-for="model in items" :key="model.id" class="table-row">
<td class="table-cell">
<code class="font-mono text-sm text-gray-900 dark:text-gray-100">
{{ model.code }}
</code>
</td>
<td class="table-cell">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{{ model.abbreviation }}
</span>
</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.type_name || '-' }}
</span>
</td>
<td class="table-cell">
<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="visibility" :title="$t('crud.show')" @click="showModal.open(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">
<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>
</template>
</Table>
</div>
<ShowView ref="showModal" @reload="searcher.search()" />
<DestroyView ref="destroyModal" subtitle="name" :to="(id) => apiTo('destroy', { units_of_measure: id })"
@update="searcher.search()" />
</div>
</template>

View File

@ -0,0 +1,210 @@
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { getDateTime } from '@Controllers/DateController';
import { viewTo, apiTo, transl } from '../Module';
import UnitsMeasureService from '../services/UnitsMeasureService';
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';
/** Eventos */
const emit = defineEmits([
'close',
'reload'
]);
/** Servicios */
const unitsService = new UnitsMeasureService();
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 alternar estado */
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 unitsService.updateStatus(item.id, newStatus);
// Actualizar el modelo local
item.is_active = newStatus;
// Notificación de éxito
const statusText = newStatus ? 'activada' : 'desactivada';
Notify.success(
`Unidad "${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 de errores
let errorMessage = 'Error al actualizar el estado de la unidad de medida';
let errorTitle = 'Error';
if (error?.response?.data) {
const errorData = error.response.data;
if (errorData.status === 'error') {
if (errorData.errors) {
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) {
errorMessage = errorData.message;
errorTitle = 'Error del servidor';
}
} else if (errorData.message) {
errorMessage = errorData.message;
}
} else if (error?.message) {
errorMessage = `Error de conexión: ${error.message}`;
errorTitle = 'Error de red';
}
Notify.error(errorMessage, errorTitle);
} finally {
loading.value = false;
}
}
/** Exposer métodos públicos */
defineExpose({
open: (data) => {
model.value = data;
modalRef.value.open();
},
close
});
</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="straighten" />
</div>
</div>
</div>
</Header>
<div class="flex w-full p-4 space-y-6">
<div class="w-full space-y-6">
<!-- Información Principal -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Código y Estado -->
<div class="space-y-4">
<div>
<label class="text-sm font-medium text-gray-600 dark:text-gray-400">
{{ $t('code') }}
</label>
<div class="mt-1">
<code
class="font-mono text-lg bg-gray-100 dark:bg-gray-700 px-3 py-2 rounded-lg block">
{{ model.code }}
</code>
</div>
</div>
<div>
<label class="text-sm font-medium text-gray-600 dark:text-gray-400">
{{ $t('Estado') }}
</label>
<div class="mt-2">
<Button :variant="'smooth'" :color="model.is_active ? 'success' : 'danger'"
:size="'sm'" :loading="loading" @click="toggleStatus(model)">
{{ model.is_active ? $t('Activo') : $t('Inactivo') }}
</Button>
</div>
</div>
</div>
<!-- Nombre y Abreviación -->
<div class="space-y-4">
<div>
<label class="text-sm font-medium text-gray-600 dark:text-gray-400">
{{ $t('Unidad de medida') }}
</label>
<p class="mt-1 text-lg font-semibold">{{ model.name }}</p>
</div>
<div>
<label class="text-sm font-medium text-gray-600 dark:text-gray-400">
{{ $t('Abreviación') }}
</label>
<div class="mt-1">
<span
class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{{ model.abbreviation }}
</span>
</div>
</div>
</div>
</div>
<!-- Tipo de Medida -->
<div>
<label class="text-sm font-medium text-gray-600 dark:text-gray-400">
{{ $t('Tipo de unidad') }}
</label>
<div class="mt-1 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="flex items-center">
<GoogleIcon class="text-2xl mr-3 text-indigo-500" name="straighten" />
<div>
<p class="font-semibold">{{ model.type_name }}</p>
</div>
</div>
</div>
</div>
<!-- Información de Fechas -->
<div class="border-t pt-6">
<h4 class="text-sm font-medium text-gray-600 dark:text-gray-400 mb-4">
Información del Sistema
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<span class="font-medium text-gray-600 dark:text-gray-400">Creado:</span>
<p class="mt-1">{{ getDateTime(model.created_at) }}</p>
</div>
<div>
<span class="font-medium text-gray-600 dark:text-gray-400">Actualizado:</span>
<p class="mt-1">{{ getDateTime(model.updated_at) }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</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(`units-of-measure.${name}`, params)
// Ruta visual
const viewTo = ({ name = '', params = {}, query = {} }) => view({
name: `admin.units-measure.${name}`, params, query
})
// Obtener traducción del componente
const transl = (str) => lang(`admin.units_measure.${str}`)
// Control de permisos
const can = (permission) => hasPermission(`admin.units-measure.${permission}`)
export {
can,
viewTo,
apiTo,
transl
}

View File

@ -0,0 +1,227 @@
/**
* Servicio para Units of Measure
*
* @author Sistema
* @version 1.0.0
*/
import { api, apiURL } from '@Services/Api';
export default class UnitsMeasureService {
/**
* Obtener todas las unidades de medida
* @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/units-of-measure'), {
params,
onSuccess: (response) => resolve(response),
onError: (error) => reject(error)
});
});
}
/**
* Obtener una unidad de medida por ID
* @param {number} id - ID de la unidad de medida
* @returns {Promise} Promesa con la respuesta
*/
async getById(id) {
return new Promise((resolve, reject) => {
api.get(apiURL(`catalogs/units-of-measure/${id}`), {
onSuccess: (response) => resolve(response),
onError: (error) => reject(error)
});
});
}
/**
* Crear una nueva unidad de medida
* @param {Object} data - Datos de la unidad de medida
* @param {string} data.code - Código de la unidad
* @param {string} data.name - Nombre de la unidad
* @param {string} data.abbreviation - Abreviación de la unidad
* @param {number} data.type - Tipo de medida (1-7)
* @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('catalogs/units-of-measure'), data, {
onSuccess: (response) => resolve(response),
onError: (error) => reject(error)
});
});
}
/**
* Actualizar una unidad de medida existente
* @param {number} id - ID de la unidad de medida
* @param {Object} data - Datos actualizados
* @returns {Promise} Promesa con la respuesta
*/
async update(id, data) {
return new Promise((resolve, reject) => {
api.put(apiURL(`catalogs/units-of-measure/${id}`), data, {
onSuccess: (response) => resolve(response),
onError: (error) => reject(error)
});
});
}
/**
* Eliminar una unidad de medida
* @param {number} id - ID de la unidad de medida
* @returns {Promise} Promesa con la respuesta
*/
async delete(id) {
return new Promise((resolve, reject) => {
api.delete(apiURL(`catalogs/units-of-measure/${id}`), {
onSuccess: (response) => resolve(response),
onError: (error) => reject(error)
});
});
}
/**
* Actualizar el estado de una unidad de medida
* @param {number} id - ID de la unidad de medida
* @param {boolean} isActive - Nuevo estado
* @returns {Promise} Promesa con la respuesta
*/
async updateStatus(id, isActive) {
return new Promise((resolve, reject) => {
api.put(apiURL(`catalogs/units-of-measure/${id}`), {
data: { is_active: isActive },
onSuccess: (response) => {
resolve(response);
},
onError: (error) => {
// Mejorar el manejo de errores
const enhancedError = {
...error,
timestamp: new Date().toISOString(),
action: 'updateStatus',
id: id,
is_active: isActive
};
reject(enhancedError);
}
});
});
}
/**
* Obtener tipos de medida disponibles desde la API
* @returns {Promise} Promesa con la respuesta
*/
async getUnitTypes() {
return new Promise((resolve, reject) => {
api.get(apiURL('catalogs/unit-types'), {
onSuccess: (response) => {
// Extraer los tipos de la respuesta y formatearlos para el Selectable
const unitTypes = response.data?.unit_types || response.unit_types || [];
const formattedTypes = unitTypes.map(type => ({
value: type.id,
label: type.name
}));
resolve(formattedTypes);
},
onError: (error) => reject(error)
});
});
}
/**
* Obtener tipos de medida como objetos completos (para mapping)
* @returns {Promise} Promesa con los tipos completos
*/
async getUnitTypesMap() {
return new Promise((resolve, reject) => {
api.get(apiURL('catalogs/unit-types'), {
onSuccess: (response) => {
const unitTypes = response.data?.unit_types || response.unit_types || [];
// Crear un mapa para acceso rápido por ID
const typesMap = {};
unitTypes.forEach(type => {
typesMap[type.id] = type;
});
resolve(typesMap);
},
onError: (error) => reject(error)
});
});
}
/**
* Obtener tipos de medida disponibles (método legacy - mantener por compatibilidad)
* @returns {Array} Array con los tipos de medida
*/
getTypes() {
return [
{ id: 1, name: 'Distancia', description: 'Medidas de longitud (metros, kilómetros, etc.)' },
{ id: 2, name: 'Peso', description: 'Medidas de masa (kilogramos, gramos, etc.)' },
{ id: 3, name: 'Temperatura', description: 'Medidas de temperatura (grados Celsius, etc.)' },
{ id: 4, name: 'Unidad', description: 'Unidades discretas (piezas, unidades, etc.)' },
{ id: 5, name: 'Volumen', description: 'Medidas de volumen (litros, mililitros, etc.)' }
];
}
/**
* Obtener unidades de medida activas
* @returns {Promise} Promesa con las unidades activas
*/
async getActive() {
return new Promise((resolve, reject) => {
this.getAll({ is_active: true })
.then(response => {
const activeUnits = response.data?.units_of_measure?.data || [];
resolve(activeUnits.filter(unit => unit.is_active));
})
.catch(error => reject(error));
});
}
/**
* Buscar unidades de medida por término
* @param {string} term - Término de búsqueda
* @returns {Promise} Promesa con los resultados
*/
async search(term) {
return new Promise((resolve, reject) => {
this.getAll({ search: term })
.then(response => resolve(response))
.catch(error => reject(error));
});
}
/**
* Validar datos antes de enviar
* @param {Object} data - Datos a validar
* @returns {Object} Objeto con errores si los hay
*/
validate(data) {
const errors = {};
if (!data.code || data.code.trim() === '') {
errors.code = 'El código es requerido';
}
if (!data.name || data.name.trim() === '') {
errors.name = 'El nombre es requerido';
}
if (!data.abbreviation || data.abbreviation.trim() === '') {
errors.abbreviation = 'La abreviación es requerida';
}
if (!data.type || ![1, 2, 3, 4, 5, 6, 7].includes(data.type)) {
errors.type = 'El tipo de medida es requerido y debe ser válido';
}
return errors;
}
}

View File

@ -0,0 +1,66 @@
<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 WarehouseService from './services/WarehouseService';
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();
const warehouseService = new WarehouseService();
/** Propiedades */
const form = useForm({
code: '',
name: '',
description: '',
address: '',
classifications: [], // Array de objetos {value, label}
is_active: true
});
/** Métodos */
function submit() {
form.transform(data => ({
...data,
classifications: warehouseService.processClassificationsForSubmit(data.classifications)
})).post(apiTo('store'), {
onSuccess: () => {
Notify.success('Almacén creado con éxito')
router.push(viewTo({ name: 'index' }));
}
})
}
/** Ciclos */
onMounted(() => {
// Inicialización si es necesaria
});
</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,457 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useRoute, useRouter, RouterLink } from 'vue-router';
import { api } from '@Services/Api';
import { apiTo, viewTo, transl } from './Module';
import WarehouseService from './services/WarehouseService';
import Card from '@Holos/Card/Card.vue';
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 PageHeader from '@Holos/PageHeader.vue';
import IconButton from '@Holos/Button/Icon.vue';
import Button from '@Holos/Button/Button.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Definiciones */
const route = useRoute();
const router = useRouter();
const warehouseService = new WarehouseService();
/** Estado reactivo */
const warehouse = ref(null);
const loading = ref(true);
const error = ref(null);
// Mock data para el dashboard (adaptado del JSX)
const totalInventoryValue = ref(2400000);
const totalProducts = ref(145);
const inventoryMovements = ref([
{
id: 1,
date: "2024-01-15T10:30:00",
type: "entrada",
product: "Laptop Dell Inspiron 15",
code: "PROD-001",
quantity: 10,
warehouse: "Almacén Principal",
reference: "PO-2024-001",
user: "Ana García",
status: "completed"
},
{
id: 2,
date: "2024-01-15T09:15:00",
type: "salida",
product: "Mouse Inalámbrico Logitech",
code: "PROD-002",
quantity: 25,
warehouse: "Almacén Norte",
reference: "SO-2024-045",
user: "Carlos López",
status: "completed"
}
]);
const stockByWarehouse = ref([
{
warehouse: "Almacén Principal",
products: [
{ code: "PROD-001", name: "Laptop Dell Inspiron 15", stock: 45, value: 719955 },
{ code: "PROD-002", name: "Mouse Inalámbrico Logitech", stock: 85, value: 50915 },
{ code: "PROD-003", name: "Monitor LG 24 pulgadas", stock: 8, value: 26392 },
{ code: "PROD-004", name: "Teclado Mecánico RGB", stock: 15, value: 32985 },
]
}
]);
/** Métodos */
async function loadWarehouse() {
try {
loading.value = true;
error.value = null;
const response = await api.get(apiTo('show', { warehouse: route.params.id }), {
onSuccess: (r) => {
warehouse.value = r.warehouse;
},
onError: (err) => {
error.value = 'Error cargando el almacén';
}
});
} catch (err) {
error.value = 'Error de conexión';
} finally {
loading.value = false;
}
}
/** Función para navegar a editar */
const editWarehouse = () => {
router.push(viewTo({ name: 'edit', params: { id: warehouse.value.id } }));
};
/** Función para formatear fecha */
const formatDate = (dateString) => {
if (!dateString) return 'N/A';
return new Date(dateString).toLocaleDateString('es-ES', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
/** Función para formatear moneda */
const formatCurrency = (amount) => {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN'
}).format(amount);
};
/** Función para obtener icono de movimiento */
const getMovementIcon = (type) => {
switch (type) {
case 'entrada': return 'arrow_downward';
case 'salida': return 'arrow_upward';
case 'transferencia': return 'swap_horiz';
case 'ajuste': return 'tune';
default: return 'inventory';
}
};
/** Función para obtener clase de badge de movimiento */
const getMovementBadgeClass = (type) => {
switch (type) {
case 'entrada':
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
case 'salida':
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
case 'transferencia':
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200';
case 'ajuste':
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
}
};
const getAllChildren = (classifications) => {
let children = [];
const extractChildren = (items) => {
items.forEach(item => {
if (item.children && item.children.length > 0) {
children = children.concat(item.children);
extractChildren(item.children);
}
});
};
extractChildren(classifications);
return children;
};
/** Función para obtener clase de estado */
const getStatusBadgeClass = (status) => {
switch (status) {
case 'completed':
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
case 'pending':
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
case 'cancelled':
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
}
};
/** Ciclo de vida */
onMounted(() => {
loadWarehouse();
});
</script>
<template>
<div>
<!-- Header con navegación -->
<PageHeader :title="warehouse?.name || 'Cargando...'" :subtitle="`Código: ${warehouse?.code || 'N/A'}`">
<RouterLink :to="viewTo({ name: 'index' })">
<IconButton class="text-white" icon="arrow_back" :title="$t('return')" filled />
</RouterLink>
</PageHeader>
<!-- Estado de carga -->
<div v-if="loading" class="flex items-center justify-center py-12">
<div class="text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
<p class="text-gray-600">Cargando información del almacén...</p>
</div>
</div>
<!-- Estado de error -->
<div v-else-if="error" class="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
<GoogleIcon class="w-12 h-12 text-red-400 mx-auto mb-4" name="error" />
<h3 class="text-lg font-medium text-red-800 mb-2">Error al cargar</h3>
<p class="text-red-600 mb-4">{{ error }}</p>
<Button @click="loadWarehouse()" color="danger" variant="outline">
Reintentar
</Button>
</div>
<!-- Contenido principal -->
<div v-else-if="warehouse" class="space-y-6">
<!-- Información básica -->
<Card>
<CardHeader>
<CardTitle class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<GoogleIcon class="w-6 h-6" name="warehouse" />
<span class="text-xl font-semibold text-gray-900 dark:text-gray-100">{{ warehouse.name
}}</span>
</div>
<div class="flex items-center justify-between gap-3">
<Button @click="editWarehouse" color="warning" variant="smooth" size="sm">
<GoogleIcon class="w-4 h-4 mr-2" name="edit" />
Editar Almacén
</Button>
<Button variant="smooth" color="info" size="sm">
<GoogleIcon class="w-4 h-4 mr-2" name="settings" />
Configuración
</Button>
</div>
</CardTitle>
</CardHeader>
<CardContent>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-6">
<!-- Columna Izquierda -->
<div class="space-y-6">
<!-- Código -->
<div class="space-y-1">
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Código</p>
<p class="font-mono text-base font-semibold text-gray-900 dark:text-gray-100">
{{ warehouse.code }}
</p>
</div>
<!-- Ubicación -->
<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' }}
</p>
</div>
<!-- Categoría -->
<div class="space-y-2">
<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"
class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">
{{ classification.name }}
</span>
</div>
</div>
<div class="space-y-2">
<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"
class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">
{{ child.name }}
</span>
</div>
</div>
<!-- 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'">
{{ warehouse.is_active ? 'Activo' : 'Inactivo' }}
</span>
</div>
</div>
<!-- Columna Derecha -->
<div class="space-y-6">
<!-- Responsable -->
<div class="space-y-1">
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Responsable</p>
<p class="text-base text-gray-900 dark:text-gray-100">
{{ warehouse.manager || 'Sin usuario responsable' }}
</p>
</div>
<!-- Teléfono -->
<div class="space-y-1">
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Teléfono</p>
<p class="text-base text-gray-900 dark:text-gray-100">
{{ warehouse.phone || 'Sin número de teléfono' }}
</p>
</div>
<div class="space-y-1">
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Fecha de creación</p>
<p class="text-base text-gray-900 dark:text-gray-100">
{{ formatDate(warehouse.created_at) }}
</p>
</div>
<div class="space-y-1">
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Ultima actualización</p>
<p class="text-base text-gray-900 dark:text-gray-100">
{{ formatDate(warehouse.updated_at) }}
</p>
</div>
</div>
</div>
</CardContent>
</Card>
<!-- Recent Movements -->
<Card>
<CardHeader>
<CardTitle class="flex items-center space-x-2">
<GoogleIcon class="w-6 h-6 text-primary" name="history" />
<span>Movimientos Recientes</span>
</CardTitle>
<CardDescription>
Últimos movimientos de inventario en este almacén
</CardDescription>
</CardHeader>
<CardContent>
<div class="space-y-4">
<div v-for="movement in inventoryMovements" :key="movement.id"
class="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
<div class="flex items-center space-x-4">
<div class="h-10 w-10 rounded-lg flex items-center justify-center" :class="movement.type === 'entrada' ? 'bg-green-100 dark:bg-green-900' :
movement.type === 'salida' ? 'bg-red-100 dark:bg-red-900' :
movement.type === 'transferencia' ? 'bg-blue-100 dark:bg-blue-900' :
'bg-yellow-100 dark:bg-yellow-900'">
<GoogleIcon class="w-5 h-5" :class="movement.type === 'entrada' ? 'text-green-600 dark:text-green-400' :
movement.type === 'salida' ? 'text-red-600 dark:text-red-400' :
movement.type === 'transferencia' ? 'text-blue-600 dark:text-blue-400' :
'text-yellow-600 dark:text-yellow-400'"
:name="getMovementIcon(movement.type)" />
</div>
<div>
<p class="font-medium text-gray-900 dark:text-gray-100">{{ movement.product }}</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
{{ movement.code }} {{ movement.reference }}
</p>
</div>
</div>
<div class="flex items-center space-x-4">
<div class="text-right">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize"
:class="getMovementBadgeClass(movement.type)">
{{ movement.type }}
</span>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Cantidad: {{ movement.quantity }}
</p>
</div>
<div class="text-right">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">{{ movement.user }}
</p>
<p class="text-xs text-gray-500">{{ formatDate(movement.date) }}</p>
</div>
<span
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium capitalize"
:class="getStatusBadgeClass(movement.status)">
{{ movement.status === 'completed' ? 'Completado' :
movement.status === 'pending' ? 'Pendiente' : 'Cancelado' }}
</span>
</div>
</div>
</div>
</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>
</template>

View File

@ -0,0 +1,129 @@
<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 WarehouseService from './services/WarehouseService';
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();
const warehouseService = new WarehouseService();
/** Propiedades */
const form = useForm({
id: null,
code: '',
name: '',
description: '',
address: '',
classifications: [], // Array de objetos {value, label}
is_active: true
});
/** Métodos */
function submit() {
form.transform(data => ({
...data,
classifications: warehouseService.processClassificationsForSubmit(data.classifications)
})).put(apiTo('update', { warehouse: form.id }), {
onSuccess: () => {
Notify.success(transl('update.success'))
router.push(viewTo({ name: 'index' }));
},
})
}
function loadData() {
api.get(apiTo('show', { warehouse: vroute.params.id }), {
onSuccess: async (r) => {
const data = r.warehouse;
console.log('Datos del warehouse:', data);
try {
// Cargar las clasificaciones disponibles
const availableClassifications = await warehouseService.getAvailableClassifications();
// Procesar las clasificaciones del warehouse para el formulario
const warehouseClassifications = data.classifications || [];
const formattedClassifications = [];
warehouseClassifications.forEach(classification => {
// Agregar la clasificación padre
formattedClassifications.push({
value: classification.id,
label: classification.name,
id: classification.id,
name: classification.name
});
// Agregar los hijos si existen
if (classification.children && classification.children.length > 0) {
classification.children.forEach(child => {
formattedClassifications.push({
value: child.id,
label: child.name,
id: child.id,
name: child.name
});
});
}
});
form.fill({
id: data.id,
code: data.code,
name: data.name,
description: data.description || '',
address: data.address || '',
classifications: formattedClassifications,
is_active: Boolean(data.is_active)
});
} catch (error) {
console.error('Error cargando clasificaciones:', error);
// Fallback: llenar sin formatear las clasificaciones
form.fill({
id: data.id,
code: data.code,
name: data.name,
description: data.description || '',
address: data.address || '',
classifications: [],
is_active: Boolean(data.is_active)
});
}
}
})
}
/** Ciclos */
onMounted(() => {
loadData();
});
</script>
<template>
<PageHeader
:title="transl('update.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>

View File

@ -0,0 +1,338 @@
<script setup>
import { ref, onMounted, computed, watch } from 'vue';
import { transl } from './Module';
import WarehouseService from './services/WarehouseService';
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 Checkbox from '@Holos/Checkbox.vue';
/** Eventos */
const emit = defineEmits([
'submit'
])
/** Servicios */
const warehouseService = new WarehouseService();
/** Propiedades reactivas */
const allClassifications = ref([]);
const loadingClassifications = ref(true);
const selectedParentClassifications = ref([]);
/** Propiedades */
const props = defineProps({
action: {
default: 'create',
type: String
},
form: Object
})
/** Computadas */
// Obtener solo las clasificaciones padre (sin parent_id)
const parentClassifications = computed(() => {
return allClassifications.value.filter(classification => !classification.parent_id);
});
// Obtener subclasificaciones de los padres seleccionados
const availableSubclassifications = computed(() => {
if (selectedParentClassifications.value.length === 0) {
return [];
}
return allClassifications.value.filter(classification =>
classification.parent_id &&
selectedParentClassifications.value.includes(classification.parent_id)
);
});
/** Métodos */
function submit() {
emit('submit')
}
/** Manejar selección de clasificación padre */
function handleParentClassificationChange(parentId, isSelected) {
if (isSelected) {
if (!selectedParentClassifications.value.includes(parentId)) {
selectedParentClassifications.value.push(parentId);
// Agregar la clasificación padre al form.classifications
const parentClassification = allClassifications.value.find(c => c.id === parentId);
if (parentClassification) {
props.form.classifications.push({
value: parentClassification.id,
label: parentClassification.name,
id: parentClassification.id,
name: parentClassification.name
});
}
}
} else {
selectedParentClassifications.value = selectedParentClassifications.value.filter(id => id !== parentId);
// Remover la clasificación padre del form.classifications
props.form.classifications = props.form.classifications.filter(classification => {
const classificationId = classification.value || classification.id || classification;
return classificationId !== parentId;
});
// Remover también las subclasificaciones de este padre
if (props.form.classifications) {
props.form.classifications = props.form.classifications.filter(classification => {
const classificationId = classification.value || classification.id || classification;
const subClass = allClassifications.value.find(c => c.id === classificationId);
return !subClass || subClass.parent_id !== parentId;
});
}
}
}
/** Cargar clasificaciones desde la API */
async function loadClassifications() {
try {
loadingClassifications.value = true;
const response = await warehouseService.getAvailableClassifications();
// La respuesta viene en el formato { data: { warehouse_classifications: { data: [...] } } }
const classificationsData = response.data?.warehouse_classifications?.data || response.warehouse_classifications?.data || response.data || [];
// Aplanar la estructura jerárquica para tener todos los elementos en un solo array
const flattenClassifications = (items) => {
let flattened = [];
items.forEach(item => {
// Agregar el item padre
flattened.push(item);
// Agregar los hijos si existen
if (item.children && item.children.length > 0) {
flattened.push(...item.children);
}
});
return flattened;
};
allClassifications.value = flattenClassifications(classificationsData);
console.log('Clasificaciones cargadas:', allClassifications.value);
} catch (error) {
console.error('Error cargando clasificaciones:', error);
allClassifications.value = [];
} finally {
loadingClassifications.value = false;
}
}
/** Watch para sincronizar con form.classifications existentes */
watch(() => props.form?.classifications, (newClassifications) => {
console.log('Watch form.classifications triggered:', newClassifications);
if (newClassifications && newClassifications.length > 0 && allClassifications.value.length > 0) {
// Extraer padres de las clasificaciones ya seleccionadas
const parentIds = new Set();
newClassifications.forEach(classification => {
const classificationId = classification.value || classification.id || classification;
console.log('Processing classification ID:', classificationId);
const fullClassification = allClassifications.value.find(c => c.id === classificationId);
console.log('Found full classification:', fullClassification);
if (fullClassification) {
if (fullClassification.parent_id) {
// Es una subclasificación, agregar su padre
console.log('Adding parent ID:', fullClassification.parent_id);
parentIds.add(fullClassification.parent_id);
} else {
// Es una clasificación padre, agregarla directamente
console.log('Adding parent classification ID:', fullClassification.id);
parentIds.add(fullClassification.id);
}
}
});
console.log('Final parent IDs:', Array.from(parentIds));
selectedParentClassifications.value = Array.from(parentIds);
}
}, { deep: true, immediate: true });
/** Watch adicional para cuando se cargan las clasificaciones */
watch(() => allClassifications.value, () => {
console.log('Watch allClassifications triggered, length:', allClassifications.value.length);
if (props.form?.classifications && allClassifications.value.length > 0) {
console.log('Re-processing form.classifications:', props.form.classifications);
// Re-ejecutar la lógica de sincronización cuando se cargan las clasificaciones
const parentIds = new Set();
props.form.classifications.forEach(classification => {
const classificationId = classification.value || classification.id || classification;
console.log('Re-processing classification ID:', classificationId);
const fullClassification = allClassifications.value.find(c => c.id === classificationId);
if (fullClassification) {
if (fullClassification.parent_id) {
// Es una subclasificación, agregar su padre
console.log('Re-adding parent ID:', fullClassification.parent_id);
parentIds.add(fullClassification.parent_id);
} else {
// Es una clasificación padre, agregarla directamente
console.log('Re-adding parent classification ID:', fullClassification.id);
parentIds.add(fullClassification.id);
}
}
});
console.log('Re-processed parent IDs:', Array.from(parentIds));
selectedParentClassifications.value = Array.from(parentIds);
}
});
/** Ciclo de vida */
onMounted(() => {
loadClassifications();
});
</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">
<!-- Código del almacén -->
<Input
v-model="props.form.code"
id="code"
title="Código"
:onError="props.form.errors.code"
placeholder="Ej: WH001"
autofocus
required
/>
<!-- Nombre del almacén -->
<Input
v-model="props.form.name"
id="name"
title="Nombre"
:onError="props.form.errors.name"
placeholder="Ej: Almacén Central"
required
/>
<!-- Descripción -->
<Textarea
v-model="props.form.description"
id="description"
title="Descripción"
:onError="props.form.errors.description"
placeholder="Descripción del almacén..."
class="md:col-span-2"
rows="3"
/>
<!-- Dirección -->
<Textarea
v-model="props.form.address"
id="address"
title="Dirección"
:onError="props.form.errors.address"
placeholder="Dirección física del almacén..."
class="md:col-span-2"
rows="2"
/>
<!-- Clasificaciones Jerárquicas -->
<div class="md:col-span-2 lg:col-span-3 xl:col-span-4">
<div class="space-y-4">
<!-- Titulo de Clasificaciones -->
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">
Clasificaciones
</h3>
<!-- Mensaje de carga -->
<div v-if="loadingClassifications" class="text-sm text-gray-500">
Cargando clasificaciones...
</div>
<!-- Clasificaciones Padre -->
<div v-if="parentClassifications.length > 0" class="space-y-3">
<h4 class="text-sm font-medium text-gray-600 dark:text-gray-400">
Clasificaciones
</h4>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
<div
v-for="parent in parentClassifications"
:key="parent.id"
class="flex items-center space-x-2"
>
<Checkbox
:id="`parent-${parent.id}`"
:checked="selectedParentClassifications.includes(parent.id)"
@update:checked="(value) => handleParentClassificationChange(parent.id, value)"
/>
<label
:for="`parent-${parent.id}`"
class="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer"
>
{{ parent.name }}
</label>
</div>
</div>
<!-- Tags de clasificaciones seleccionadas -->
<div v-if="selectedParentClassifications.length > 0" class="flex flex-wrap gap-2 mt-2">
<span
v-for="parentId in selectedParentClassifications"
:key="parentId"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
>
{{ parentClassifications.find(p => p.id === parentId)?.name }}
</span>
</div>
</div>
<!-- Subclasificaciones -->
<div v-if="availableSubclassifications.length > 0" class="space-y-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<h4 class="text-sm font-medium text-gray-600 dark:text-gray-400">
Subclasificaciones
</h4>
<Selectable
v-model="props.form.classifications"
id="subclassifications"
label="name"
value="id"
:onError="props.form.errors.classifications"
:options="availableSubclassifications.map(sub => ({
value: sub.id,
label: sub.name,
name: sub.name,
id: sub.id
}))"
placeholder="Seleccionar subclasificaciones"
multiple
/>
</div>
<!-- Mensaje cuando no hay clasificaciones padre seleccionadas -->
<div v-else-if="!loadingClassifications && selectedParentClassifications.length === 0" class="text-sm text-gray-500 italic">
Selecciona al menos una clasificación para ver las subclasificaciones disponibles
</div>
<!-- Error de clasificaciones -->
<div v-if="props.form.errors.classifications" class="text-sm text-red-600 dark:text-red-400">
{{ props.form.errors.classifications }}
</div>
</div>
</div>
<!-- Botón de envío -->
<div class="md:col-span-2 lg:col-span-3 xl:col-span-4">
<PrimaryButton
:loading="props.form.processing"
:disabled="props.form.processing"
>
{{ transl(`${props.action}.button`) }}
</PrimaryButton>
</div>
</form>
</div>
</template>

View File

@ -1,133 +1,162 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import Table from '@/components/Holos/Table.vue'
import SectionTitle from '@/components/Holos/SectionTitle.vue'
import Button from '@/components/Holos/Button/Button.vue'
import Card from '@/components/Holos/Card/Card.vue'
import CardHeader from '@/components/Holos/Card/CardHeader.vue'
import CardTitle from '@/components/Holos/Card/CardTitle.vue'
import CardDescription from '@/components/Holos/Card/CardDescription.vue'
import Searcher from '@/components/Holos/Searcher.vue'
import SimpleCard from '@/components/Holos/Card/Simple.vue'
import Selectable from '@/components/Holos/Form/Selectable.vue'
import { api } from '@Services/Api'
import { apiTo } from './Module'
import CardContent from '../../components/Holos/Card/CardContent.vue'
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'
const warehouses = ref({});
const processing = ref(false);
const selectedStatus = ref('');
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';
const statusOptions = [
{ id: '', name: 'Todos los estados' },
{ id: 'active', name: 'Activos' },
{ id: 'inactive', name: 'Inactivos' }
];
import Card from '@Holos/Card/Card.vue';
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';
const filteredWarehouses = computed(() => {
if (!selectedStatus.value || selectedStatus.value === '') {
return warehouses.value;
import GoogleIcon from '@Shared/GoogleIcon.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);
console.log('Warehouses:', r.warehouses);
if (r.warehouses && r.warehouses.length > 0) {
console.log('Primer warehouse:', r.warehouses[0]);
}
models.value = r.warehouses;
},
onError: () => models.value = []
});
const filtered = {
...warehouses.value,
data: warehouses.value.data?.filter(warehouse => {
if (selectedStatus.value === 'active') return warehouse.is_active;
if (selectedStatus.value === 'inactive') return !warehouse.is_active;
return true;
}) || []
/** Función para eliminar */
const deleteItem = (item) => {
destroyModal.value.open(item);
};
return filtered;
});
function fetchWarehouses(url = null) {
processing.value = true;
api.get(url || apiTo('index'), {
onSuccess: (r) => {
warehouses.value = r.warehouses;
processing.value = false;
},
onError: () => {
processing.value = false;
}
});
}
/** Función de debug para navegación */
const debugNavigation = (warehouse) => {
console.log('Debug navigation:', warehouse);
console.log('viewTo result:', viewTo({ name: 'edit', params: { id: warehouse.id } }));
router.push(viewTo({ name: 'edit', params: { id: warehouse.id } }));
};
/** Ciclos */
onMounted(() => {
fetchWarehouses();
searcher.search();
});
</script>
<template>
<div class="space-y-6">
<SectionTitle>
<template #title>Almacenes</template>
<template #description>Listado de almacenes registrados en el sistema.</template>
<template #aside>
<Button color="success" variant="smooth" size="md">
Nuevo almacén
</Button>
</template>
</SectionTitle>
<!-- <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<SimpleCard icon="warehouse" :title="`Total: ${filteredWarehouses.total || 0}`" />
<SimpleCard icon="check_circle"
:title="`Activos: ${filteredWarehouses.data?.filter(w => w.is_active)?.length || 0}`" />
<SimpleCard icon="cancel"
:title="`Inactivos: ${filteredWarehouses.data?.filter(w => !w.is_active)?.length || 0}`" />
<SimpleCard icon="category"
:title="`Con clasificaciones: ${filteredWarehouses.data?.filter(w => w.classifications?.length > 0)?.length || 0}`" />
</div> -->
<Card>
<CardHeader>
<CardTitle class="flex items-center justify-between">
<span>Almacenes</span>
<div class="flex items-center space-x-2">
<span class="text-sm text-muted-foreground">Total de almacenes: {{
filteredWarehouses.data?.length || 0
}}</span>
<div>
<div className="flex items-center justify-between">
<div>
<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>
</CardTitle>
</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>
<CardHeader>
<CardTitle>Gestión de Almacenes</CardTitle>
<CardDescription>
Gestiona los almacenes donde se almacenan los productos. Puedes crear, editar y eliminar almacenes
según sea necesario.
</CardDescription>
</CardHeader>
<CardContent>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<Selectable v-model="selectedStatus" :options="statusOptions" title="Filtrar por estado"
placeholder="Seleccionar estado..." label="name" trackBy="id" />
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<Card v-for="warehouse in models.data" :key="warehouse.id" class="relative overflow-hidden">
<CardContent class="pt-4 pb-4 px-4">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center space-x-2">
<GoogleIcon class="w-6 h-6 text-primary" name="warehouse" />
<div>
<h3 class="font-semibold text-sm">{{ warehouse.name }}</h3>
<p class="text-xs text-muted-foreground font-mono">
{{ warehouse.code }}
</p>
</div>
<Table :items="filteredWarehouses" :processing="processing" @send-pagination="fetchWarehouses">
<template #head>
<th class="text-left">Código</th>
<th class="text-left">Nombre</th>
<th class="text-left">Descripción</th>
<th class="text-left">Estado</th>
</template>
<template #body="{ items }">
<tr v-for="warehouse in items" :key="warehouse.id">
<td>{{ warehouse.code }}</td>
<td>{{ warehouse.name }}</td>
<td>{{ warehouse.description }}</td>
<td>
<span :class="warehouse.is_active ? 'text-green-600' : 'text-red-600'">
</div>
<div class="flex items-center space-x-2">
{{ warehouse.is_active ? 'Activo' : 'Inactivo' }}
</div>
</div>
<div class="flex items-center space-x-1 text-xs text-muted-foreground mb-2">
<GoogleIcon class="w-6 h-6" name="distance" />
<p>{{ warehouse.address ? warehouse.address : 'Sin dirección' }}</p>
</div>
<div className="flex items-center space-x-1 text-xs text-muted-foreground mb-3">
<span v-for="classification in warehouse.classifications" :key="classification.id"
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200">
{{ classification.code }}
</span>
<span v-if="warehouse.classifications.length > 3"
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200">
+{{ warehouse.classifications.length - 3 }}
</span>
<span v-if="warehouse.classifications.length === 0" class="text-xs text-gray-400">
Sin clasificaciones
</span>
</td>
</tr>
</div>
<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>
</RouterLink>
<!-- 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>
</RouterLink>
<Button size="sm" variant="solid" @click="deleteItem(warehouse)" color="danger">
Eliminar
</Button>
</div>
</template>
<template #empty>
<td colspan="4" class="text-center py-4">No hay almacenes registrados.</td>
</template>
</Table>
</CardContent>
</Card>
</div>
</CardContent>
</div>
<ShowView ref="showModal" @reload="searcher.search()" />
<DestroyView ref="destroyModal" subtitle="name" :to="(id) => apiTo('destroy', { warehouse: id })"
@update="searcher.search()" />
</div>
</template>

View File

@ -0,0 +1,311 @@
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { getDateTime } from '@Controllers/DateController';
import { viewTo, apiTo, transl } from '../Module';
import WarehouseService from '../services/WarehouseService';
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 warehouseService = new WarehouseService();
const router = useRouter();
/** Propiedades */
const model = ref(null);
const loading = ref(false);
/** Referencias */
const modalRef = ref(null);
const destroyModal = ref(null);
/** Métodos */
function close() {
model.value = null;
emit('close');
}
/** Función para actualizar el estado del almacé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 warehouseService.updateStatus(item.id, newStatus);
// Actualizar el modelo local
item.is_active = newStatus;
// Notificación de éxito
const statusText = newStatus ? 'activado' : 'desactivado';
Notify.success(
`Almacé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 del almacé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 almacén */
function editWarehouse() {
// Navegar a la vista de edición del almacén
const editUrl = viewTo({ name: 'edit', params: { id: model.value.id } });
router.push(editUrl);
// Cerrar el modal actual
close();
}
/** Función para eliminar almacén */
function deleteWarehouse() {
destroyModal.value.open(model.value);
}
/** Función para recargar después de eliminar */
function onWarehouseDeleted() {
close();
emit('reload');
Notify.success(
'Almacén eliminado exitosamente',
'Eliminación exitosa'
);
}
/** Función para cancelar eliminación */
function onDeleteCancelled() {
// No es necesario hacer nada especial
}
/** Exposer métodos públicos */
defineExpose({
open: (data) => {
model.value = data;
modalRef.value.open();
},
close
});
</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-green-500 to-blue-600 flex items-center justify-center">
<GoogleIcon
class="text-white text-3xl"
name="warehouse"
/>
</div>
</div>
</div>
</Header>
<div class="flex w-full p-4 space-y-6">
<div class="w-full space-y-6">
<!-- Información Principal -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Código y Estado -->
<div class="space-y-4">
<div>
<label class="text-sm font-medium text-gray-600 dark:text-gray-400">
{{ transl('code') }}
</label>
<div class="mt-1">
<code class="font-mono text-lg bg-gray-100 dark:bg-gray-700 px-3 py-2 rounded-lg block">
{{ model.code }}
</code>
</div>
</div>
<div>
<label class="text-sm font-medium text-gray-600 dark:text-gray-400">
{{ transl('is_active') }}
</label>
<div class="mt-2">
<Button
:variant="'smooth'"
:color="model.is_active ? 'success' : 'danger'"
:size="'sm'"
:loading="loading"
@click="toggleStatus(model)"
>
{{ model.is_active ? $t('Activo') : $t('Inactivo') }}
</Button>
</div>
</div>
</div>
<!-- Nombre y Dirección -->
<div class="space-y-4">
<div>
<label class="text-sm font-medium text-gray-600 dark:text-gray-400">
{{ transl('name') }}
</label>
<p class="mt-1 text-lg font-semibold">{{ model.name }}</p>
</div>
<div v-if="model.address">
<label class="text-sm font-medium text-gray-600 dark:text-gray-400">
{{ transl('address') }}
</label>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">{{ model.address }}</p>
</div>
</div>
</div>
<!-- Descripción -->
<div v-if="model.description">
<label class="text-sm font-medium text-gray-600 dark:text-gray-400">
{{ transl('description') }}
</label>
<div class="mt-1 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<p class="text-sm">{{ model.description }}</p>
</div>
</div>
<!-- Clasificaciones -->
<div>
<label class="text-sm font-medium text-gray-600 dark:text-gray-400">
{{ transl('classifications') }}
</label>
<div class="mt-2">
<div v-if="model.classifications && model.classifications.length > 0" class="flex flex-wrap gap-2">
<span
v-for="classification in model.classifications"
:key="classification.id"
class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200"
>
<GoogleIcon class="w-4 h-4 mr-2" name="category" />
{{ classification.code }} - {{ classification.name }}
</span>
</div>
<div v-else class="flex items-center p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<GoogleIcon class="w-5 h-5 mr-2 text-gray-400" name="info" />
<p class="text-sm text-gray-500">Sin clasificaciones asignadas</p>
</div>
</div>
</div>
<!-- Información de Fechas -->
<div class="border-t pt-6">
<h4 class="text-sm font-medium text-gray-600 dark:text-gray-400 mb-4">
Información del Sistema
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<span class="font-medium text-gray-600 dark:text-gray-400">Creado:</span>
<p class="mt-1">{{ getDateTime(model.created_at) }}</p>
</div>
<div>
<span class="font-medium text-gray-600 dark:text-gray-400">Actualizado:</span>
<p class="mt-1">{{ getDateTime(model.updated_at) }}</p>
</div>
</div>
</div>
<!-- Botones de acción -->
<div class="flex justify-between space-x-3 pt-4 border-t">
<div class="flex space-x-2">
<Button
variant="outline"
@click="editWarehouse"
>
<GoogleIcon class="w-4 h-4 mr-2" name="edit" />
{{ $t('crud.edit') }}
</Button>
<Button
variant="outline"
color="danger"
@click="deleteWarehouse"
>
<GoogleIcon class="w-4 h-4 mr-2" name="delete" />
{{ $t('crud.destroy') }}
</Button>
</div>
<Button
variant="ghost"
@click="close"
>
{{ $t('crud.close') }}
</Button>
</div>
</div>
</div>
</div>
</ShowModal>
<!-- Modal de confirmación para eliminar -->
<DestroyView
ref="destroyModal"
subtitle="name"
:to="(id) => apiTo('destroy', { warehouse: id })"
@update="onWarehouseDeleted"
@cancel="onDeleteCancelled"
/>
</template>

View File

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

1125
src/pages/Warehouses/e.jsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,125 @@
/**
* Interfaces para Warehouses
*
* @author Sistema
* @version 1.0.0
*/
/**
* @typedef {Object} WarehouseClassification
* @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 {Object} pivot - Relación many-to-many
* @property {number} pivot.warehouse_id - ID del almacén
* @property {number} pivot.classification_id - ID de la clasificación
*/
/**
* @typedef {Object} Warehouse
* @property {number} id - ID del almacén
* @property {string} code - Código del almacén
* @property {string} name - Nombre del almacén
* @property {string|null} description - Descripción del almacén
* @property {string|null} address - Dirección del almacén
* @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 {WarehouseClassification[]} classifications - Clasificaciones asociadas
*/
/**
* @typedef {Object} WarehousePaginatedResponse
* @property {number} current_page - Página actual
* @property {Warehouse[]} data - Array de almacenes
* @property {string} first_page_url - URL de la primera página
* @property {number} from - Registro inicial
* @property {number} last_page - Última página
* @property {string} last_page_url - URL de la última página
* @property {Object[]} links - Enlaces de paginación
* @property {string|null} next_page_url - URL de la siguiente página
* @property {string} path - Path base de la API
* @property {number} per_page - Registros por página
* @property {string|null} prev_page_url - URL de la página anterior
* @property {number} to - Registro final
* @property {number} total - Total de registros
*/
/**
* @typedef {Object} WarehouseResponse
* @property {string} status - Estado de la respuesta
* @property {Object} data - Datos de la respuesta
* @property {WarehousePaginatedResponse} data.warehouses - Datos paginados de almacenes
*/
/**
* @typedef {Object} SingleWarehouseResponse
* @property {string} status - Estado de la respuesta
* @property {Object} data - Datos de la respuesta
* @property {string} data.message - Mensaje de la respuesta
* @property {Warehouse} data.warehouse - Almacén individual
*/
/**
* @typedef {Object} WarehouseCreateRequest
* @property {string} code - Código del almacén
* @property {string} name - Nombre del almacén
* @property {string|null} description - Descripción del almacén
* @property {string|null} address - Dirección del almacén
* @property {boolean} is_active - Estado activo/inactivo
* @property {number[]} classifications - Array de IDs de clasificaciones
*/
/**
* @typedef {Object} WarehouseUpdateRequest
* @property {string} code - Código del almacén
* @property {string} name - Nombre del almacén
* @property {string|null} description - Descripción del almacén
* @property {string|null} address - Dirección del almacén
* @property {boolean} is_active - Estado activo/inactivo
* @property {number[]} classifications - Array de IDs de clasificaciones
*/
/**
* @typedef {Object} WarehouseFormData
* @property {number|null} id - ID del almacén (para edición)
* @property {string} code - Código del almacén
* @property {string} name - Nombre del almacén
* @property {string} description - Descripción del almacén
* @property {string} address - Dirección del almacén
* @property {boolean} is_active - Estado activo/inactivo
* @property {Object[]} classifications - Clasificaciones seleccionadas en formato {value, label}
*/
/**
* @typedef {Object} ClassificationSelectOption
* @property {number} value - ID de la clasificación
* @property {string} label - Nombre de la clasificación con indentación
* @property {string} code - Código de la clasificación
* @property {string|null} description - Descripción de la clasificación
* @property {number} level - Nivel jerárquico (para indentación)
*/
/**
* @typedef {Object} WarehouseSearchFilters
* @property {string|null} search - Término de búsqueda
* @property {string|null} status - Estado del filtro (active/inactive)
* @property {number} page - Número de página
* @property {number} per_page - Elementos por página
*/
/**
* @typedef {Object} WarehouseValidationErrors
* @property {string[]} code - Errores del campo código
* @property {string[]} name - Errores del campo nombre
* @property {string[]} description - Errores del campo descripción
* @property {string[]} address - Errores del campo dirección
* @property {string[]} classifications - Errores del campo clasificaciones
*/

View File

@ -0,0 +1,239 @@
/**
* Servicio para Warehouses
*
* @author Sistema
* @version 1.0.0
*/
import { api, apiURL } from '@Services/Api';
export default class WarehouseService {
/**
* Obtener todos los almacenes
* @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('warehouses'), {
params,
onSuccess: (response) => resolve(response),
onError: (error) => reject(error)
});
});
}
/**
* Obtener un almacén por ID
* @param {number} id - ID del almacén
* @returns {Promise} Promesa con la respuesta
*/
async getById(id) {
return new Promise((resolve, reject) => {
api.get(apiURL(`warehouses/${id}`), {
onSuccess: (response) => resolve(response),
onError: (error) => reject(error)
});
});
}
/**
* Crear un nuevo almacén
* @param {Object} data - Datos del almacén
* @param {string} data.code - Código del almacén
* @param {string} data.name - Nombre del almacén
* @param {string|null} data.description - Descripción del almacén
* @param {string|null} data.address - Dirección del almacén
* @param {boolean} data.is_active - Estado activo/inactivo
* @param {number[]} data.classifications - Array de IDs de clasificaciones
* @returns {Promise} Promesa con la respuesta
*/
async create(data) {
return new Promise((resolve, reject) => {
api.post(apiURL('warehouses'), data, {
onSuccess: (response) => resolve(response),
onError: (error) => reject(error)
});
});
}
/**
* Actualizar un almacén existente
* @param {number} id - ID del almacén
* @param {Object} data - Datos actualizados
* @returns {Promise} Promesa con la respuesta
*/
async update(id, data) {
return new Promise((resolve, reject) => {
api.put(apiURL(`warehouses/${id}`), data, {
onSuccess: (response) => resolve(response),
onError: (error) => reject(error)
});
});
}
/**
* Eliminar un almacén
* @param {number} id - ID del almacén
* @returns {Promise} Promesa con la respuesta
*/
async delete(id) {
return new Promise((resolve, reject) => {
api.delete(apiURL(`warehouses/${id}`), {
onSuccess: (response) => resolve(response),
onError: (error) => reject(error)
});
});
}
/**
* Actualizar el estado de un almacén
* @param {number} id - ID del almacén
* @param {boolean} isActive - Nuevo estado
* @returns {Promise} Promesa con la respuesta
*/
async updateStatus(id, isActive) {
return new Promise((resolve, reject) => {
api.put(apiURL(`warehouses/${id}`), {
data: { is_active: isActive },
onSuccess: (response) => {
resolve(response);
},
onError: (error) => {
// Mejorar el manejo de errores
const enhancedError = {
...error,
timestamp: new Date().toISOString(),
action: 'updateStatus',
id: id,
is_active: isActive
};
reject(enhancedError);
}
});
});
}
/**
* Buscar almacenes con filtros
* @param {Object} filters - Filtros de búsqueda
* @param {string} filters.search - Término de búsqueda
* @param {string} filters.status - Estado (active/inactive)
* @param {number} filters.page - Página
* @returns {Promise} Promesa con la respuesta
*/
async search(filters = {}) {
return new Promise((resolve, reject) => {
api.get(apiURL('warehouses'), {
params: filters,
onSuccess: (response) => resolve(response),
onError: (error) => reject(error)
});
});
}
/**
* Validar datos del almacén
* @param {Object} data - Datos a validar
* @returns {Object} Objeto con errores de validación si los hay
*/
validate(data) {
const errors = {};
if (!data.code || data.code.trim() === '') {
errors.code = 'El código es requerido';
}
if (!data.name || data.name.trim() === '') {
errors.name = 'El nombre es requerido';
}
return errors;
}
/**
* Obtener clasificaciones disponibles para asignar a almacenes
* @returns {Promise} Promesa con las clasificaciones
*/
async getAvailableClassifications() {
return new Promise((resolve, reject) => {
api.get(apiURL('catalogs/warehouse-classifications'), {
onSuccess: (response) => {
// Retornar la respuesta completa tal como viene de la API
resolve(response);
},
onError: (error) => reject(error)
});
});
}
/**
* Formatear clasificaciones jerárquicas para uso en Selectable
* @param {Array} classifications - Array de clasificaciones
* @param {number} level - Nivel de indentación
* @returns {Array} Clasificaciones formateadas
*/
formatClassificationsForSelect(classifications, level = 0) {
const formatted = [];
classifications.forEach(classification => {
formatted.push({
value: classification.id,
label: ' '.repeat(level) + classification.name,
code: classification.code,
description: classification.description,
level: level
});
// Agregar hijos recursivamente
if (classification.children && classification.children.length > 0) {
formatted.push(...this.formatClassificationsForSelect(classification.children, level + 1));
}
});
return formatted;
}
/**
* Procesar clasificaciones seleccionadas para envío al backend
* Incluye tanto clasificaciones padre como subclasificaciones
* @param {Array} selectedClassifications - Clasificaciones seleccionadas del formulario
* @returns {Array} Array de IDs de clasificaciones (padres + hijos)
*/
processClassificationsForSubmit(selectedClassifications) {
if (!Array.isArray(selectedClassifications)) {
return [];
}
const classificationIds = selectedClassifications.map(classification => {
return typeof classification === 'object' ?
(classification.value || classification.id) :
classification;
});
// Remover duplicados y retornar
return [...new Set(classificationIds)];
}
/**
* Procesar clasificaciones del backend para mostrar en formulario
* @param {Array} classifications - Clasificaciones del backend
* @param {Array} availableClassifications - Todas las clasificaciones disponibles
* @returns {Array} Clasificaciones formateadas para el formulario
*/
processClassificationsForForm(classifications, availableClassifications) {
if (!Array.isArray(classifications) || !Array.isArray(availableClassifications)) {
return [];
}
return classifications.map(classification => {
const found = availableClassifications.find(available => available.value === classification.id);
return found || {
value: classification.id,
label: classification.name,
code: classification.code
};
});
}
}

View File

@ -315,6 +315,15 @@ const router = createRouter({
},
redirect: '/admin/warehouses',
children: [
{
path: ':id',
name: 'admin.warehouses.details',
component: () => import('@Pages/Warehouses/Details.vue'),
meta: {
title: 'Detalles',
icon: 'info',
},
},
{
path: '',
name: 'admin.warehouses.index',
@ -324,7 +333,7 @@ const router = createRouter({
{
path: 'create',
name: 'admin.warehouses.create',
component: () => import('@Pages/Admin/Roles/Index.vue'),
component: () => import('@Pages/Warehouses/Create.vue'),
meta: {
title: 'Crear',
@ -334,7 +343,7 @@ const router = createRouter({
{
path: ':id/edit',
name: 'admin.warehouses.edit',
component: () => import('@Pages/Admin/Roles/Index.vue'),
component: () => import('@Pages/Warehouses/Edit.vue'),
meta: {
title: 'Editar',
@ -343,6 +352,22 @@ const router = createRouter({
}
]
},
{
path: 'units-measure',
name: 'admin.units-measure',
meta: {
title: 'Unidades de Medida',
icon: 'straighten',
},
redirect: '/admin/units-measure',
children: [
{
path: '',
name: 'admin.units-measure.index',
component: () => import('@Pages/UnitsMeasure/Index.vue'),
},
]
},
{
path: 'warehouse-classifications',
name: 'admin.warehouse-classifications',
@ -377,6 +402,40 @@ const router = createRouter({
}
]
},
{
path: 'units-measure',
name: 'admin.units-measure',
meta: {
title: 'Unidades de Medida',
icon: 'straighten',
},
redirect: '/admin/units-measure',
children: [
{
path: '',
name: 'admin.units-measure.index',
component: () => import('@Pages/UnitsMeasure/Index.vue'),
},
{
path: 'create',
name: 'admin.units-measure.create',
component: () => import('@Pages/UnitsMeasure/Create.vue'),
meta: {
title: 'Crear',
icon: 'add',
},
},
{
path: ':id/edit',
name: 'admin.units-measure.edit',
component: () => import('@Pages/UnitsMeasure/Edit.vue'),
meta: {
title: 'Editar',
icon: 'edit',
},
}
]
},
{
path: 'roles',
name: 'admin.roles',