Add: Módulo catalogo de clasificaciones de almacenes
This commit is contained in:
parent
c95d40787d
commit
efcad3fe1d
200
agent.md
Normal file
200
agent.md
Normal file
@ -0,0 +1,200 @@
|
||||
# Prompt: Generador de Módulos CRUD siguiendo la Estructura de Users
|
||||
|
||||
Eres un agente especializado en crear módulos CRUD completos para aplicaciones Vue 3 + Composition API siguiendo la estructura establecida en el módulo Users como referencia.
|
||||
|
||||
## 🏗️ ESTRUCTURA OBLIGATORIA
|
||||
|
||||
Cada módulo debe seguir exactamente esta estructura de archivos:
|
||||
|
||||
```
|
||||
ModuleName/
|
||||
├── Index.vue # Vista principal con listado paginado
|
||||
├── Create.vue # Formulario de creación
|
||||
├── Edit.vue # Formulario de edición
|
||||
├── Form.vue # Componente de formulario compartido
|
||||
├── Settings.vue # Configuraciones (si aplica)
|
||||
├── Module.js # Utilidades centralizadas del módulo
|
||||
├── Modals/
|
||||
│ └── Show.vue # Modal para mostrar detalles
|
||||
└── [OtrosArchivos].vue # Archivos específicos del módulo
|
||||
```
|
||||
|
||||
## 📋 ESPECIFICACIONES TÉCNICAS
|
||||
|
||||
### 1. **Module.js - Archivo Central** (OBLIGATORIO)
|
||||
```javascript
|
||||
import { lang } from '@Lang/i18n';
|
||||
import { hasPermission } from '@Plugins/RolePermission.js';
|
||||
|
||||
// Ruta API
|
||||
const apiTo = (name, params = {}) => route(`admin.{module}.${name}`, params)
|
||||
|
||||
// Ruta visual
|
||||
const viewTo = ({ name = '', params = {}, query = {} }) => view({
|
||||
name: `admin.{module}.${name}`, params, query
|
||||
})
|
||||
|
||||
// Obtener traducción del componente
|
||||
const transl = (str) => lang(`{module}.${str}`)
|
||||
|
||||
// Control de permisos
|
||||
const can = (permission) => hasPermission(`{module}.${permission}`)
|
||||
|
||||
export { can, viewTo, apiTo, transl }
|
||||
```
|
||||
|
||||
### 2. **Index.vue - Vista Principal** (OBLIGATORIO)
|
||||
Debe incluir:
|
||||
- ✅ `useSearcher` para búsqueda paginada
|
||||
- ✅ `SearcherHead` con título y botones de acción
|
||||
- ✅ `Table` component con templates: `#head`, `#body`, `#empty`
|
||||
- ✅ Modales para `Show` y `Destroy`
|
||||
- ✅ Control de permisos para cada acción (`can()`)
|
||||
- ✅ Acciones estándar: Ver, Editar, Eliminar, Settings (si aplica)
|
||||
|
||||
### 3. **Create.vue - Creación** (OBLIGATORIO)
|
||||
```javascript
|
||||
// Estructura mínima:
|
||||
const form = useForm({
|
||||
// campos del formulario
|
||||
});
|
||||
|
||||
function submit() {
|
||||
form.post(apiTo('store'), {
|
||||
onSuccess: () => {
|
||||
Notify.success(Lang('register.create.onSuccess'))
|
||||
router.push(viewTo({ name: 'index' }));
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 4. **Edit.vue - Edición** (OBLIGATORIO)
|
||||
```javascript
|
||||
// Estructura mínima:
|
||||
const form = useForm({
|
||||
id: null,
|
||||
// otros campos
|
||||
});
|
||||
|
||||
function submit() {
|
||||
form.put(apiTo('update', { [resource]: form.id }), {
|
||||
onSuccess: () => {
|
||||
Notify.success(Lang('register.edit.onSuccess'))
|
||||
router.push(viewTo({ name: 'index' }));
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
api.get(apiTo('show', { [resource]: vroute.params.id }), {
|
||||
onSuccess: (r) => form.fill(r.[resource])
|
||||
});
|
||||
})
|
||||
```
|
||||
|
||||
### 5. **Form.vue - Formulario Compartido** (OBLIGATORIO)
|
||||
- ✅ Props: `action` (create/update), `form` (objeto de formulario)
|
||||
- ✅ Emit: `submit` evento
|
||||
- ✅ Grid responsive: `grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4`
|
||||
- ✅ Descripción dinámica: `transl(\`\${action}.description\`)`
|
||||
- ✅ Slot para campos adicionales
|
||||
- ✅ Botón de submit con estado de carga
|
||||
|
||||
### 6. **Modals/Show.vue** (OBLIGATORIO)
|
||||
- ✅ `ShowModal` base component
|
||||
- ✅ `defineExpose({ open: (data) => {...} })`
|
||||
- ✅ Header con información principal
|
||||
- ✅ Detalles organizados con iconos
|
||||
- ✅ Enlaces de contacto (email, teléfono)
|
||||
- ✅ Fechas formateadas con `getDateTime`
|
||||
|
||||
## 🎨 PATRONES DE DISEÑO OBLIGATORIOS
|
||||
|
||||
### **Imports Estándar**
|
||||
```javascript
|
||||
// Vue
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
|
||||
// Services
|
||||
import { api, useForm, useSearcher } from '@Services/Api';
|
||||
|
||||
// Module
|
||||
import { apiTo, transl, viewTo, can } from './Module';
|
||||
|
||||
// Components
|
||||
import IconButton from '@Holos/Button/Icon.vue'
|
||||
import PageHeader from '@Holos/PageHeader.vue';
|
||||
```
|
||||
|
||||
### **Control de Permisos**
|
||||
```html
|
||||
<!-- Siempre usar can() del módulo -->
|
||||
<RouterLink v-if="can('create')" :to="viewTo({ name: 'create' })">
|
||||
<IconButton v-if="can('destroy')" @click="destroyModal.open(model)">
|
||||
```
|
||||
|
||||
### **Navegación Consistente**
|
||||
```html
|
||||
<!-- Header con botón de retorno -->
|
||||
<PageHeader :title="transl('create.title')">
|
||||
<RouterLink :to="viewTo({ name: 'index' })">
|
||||
<IconButton icon="arrow_back" :title="$t('return')" filled />
|
||||
</RouterLink>
|
||||
</PageHeader>
|
||||
```
|
||||
|
||||
### **Grid Responsive**
|
||||
```html
|
||||
<!-- Formularios siempre con esta estructura -->
|
||||
<form class="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
```
|
||||
|
||||
## 🔧 COMPONENTES REUTILIZABLES OBLIGATORIOS
|
||||
|
||||
### **De @Holos:**
|
||||
- `Button/Icon.vue`, `Button/Primary.vue`
|
||||
- `Form/Input.vue`, `Form/Selectable.vue`
|
||||
- `Modal/Template/Destroy.vue`, `Modal/Show.vue`
|
||||
- `PageHeader.vue`, `Searcher.vue`, `Table.vue`
|
||||
- `FormSection.vue`, `SectionBorder.vue`
|
||||
|
||||
### **De @Services:**
|
||||
- `useForm`, `useSearcher`, `api`
|
||||
|
||||
### **De @Plugins:**
|
||||
- `hasPermission`, sistema de roles
|
||||
|
||||
## 📝 INSTRUCCIONES PARA EL AGENTE
|
||||
|
||||
Cuando se te solicite crear un módulo:
|
||||
|
||||
1. **PREGUNTA PRIMERO:**
|
||||
- Nombre del módulo
|
||||
- Campos principales del formulario
|
||||
- Relaciones con otros módulos
|
||||
- Permisos específicos necesarios
|
||||
- Si requiere configuraciones especiales
|
||||
|
||||
2. **GENERA SIEMPRE:**
|
||||
- Todos los archivos de la estructura obligatoria
|
||||
- Module.js con las 4 funciones principales
|
||||
- Permisos específicos del módulo
|
||||
- Traducciones básicas necesarias
|
||||
|
||||
3. **RESPETA SIEMPRE:**
|
||||
- Los patrones de naming
|
||||
- La estructura de componentes
|
||||
- Los imports estándar
|
||||
- El sistema de permisos
|
||||
- La navegación consistente
|
||||
|
||||
4. **VALIDA QUE:**
|
||||
- Todos los archivos tengan la estructura correcta
|
||||
- Los permisos estén implementados
|
||||
- Las rutas sean consistentes
|
||||
- Los formularios tengan validación
|
||||
- Los modales funcionen correctamente
|
||||
|
||||
¿Entendido? Responde "ESTRUCTURA CONFIRMADA" y luego solicita los detalles del módulo que debo crear.
|
||||
138
src/components/Holos/Button/Button.vue
Normal file
138
src/components/Holos/Button/Button.vue
Normal file
@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<button :type="type" :disabled="disabled || loading" :class="buttonClasses" @click="handleClick">
|
||||
<svg v-if="loading" class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
|
||||
</path>
|
||||
</svg>
|
||||
<slot></slot>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface Props {
|
||||
variant?: 'solid' | 'outline' | 'ghost' | 'smooth';
|
||||
color?: 'primary' | 'danger' | 'success' | 'info' | 'warning';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
fullWidth?: boolean;
|
||||
iconOnly?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
variant: 'solid',
|
||||
color: 'primary',
|
||||
size: 'md',
|
||||
type: 'button',
|
||||
disabled: false,
|
||||
loading: false,
|
||||
fullWidth: false,
|
||||
iconOnly: false,
|
||||
});
|
||||
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [event: MouseEvent];
|
||||
}>();
|
||||
|
||||
function handleClick(event: MouseEvent) {
|
||||
if (props.disabled || props.loading) return;
|
||||
emit('click', event);
|
||||
}
|
||||
|
||||
const buttonClasses = computed(() => {
|
||||
const baseClasses = [
|
||||
'inline-flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'font-medium',
|
||||
'rounded-lg',
|
||||
'transition-all',
|
||||
'duration-200',
|
||||
'focus:outline-none',
|
||||
'focus:ring-2',
|
||||
'focus:ring-offset-2',
|
||||
'disabled:opacity-50',
|
||||
'disabled:cursor-not-allowed',
|
||||
];
|
||||
|
||||
const sizeClasses = {
|
||||
sm: ['px-3', 'py-1.5', 'text-sm'],
|
||||
md: ['px-4', 'py-2', 'text-sm'],
|
||||
lg: ['px-6', 'py-3', 'text-base'],
|
||||
};
|
||||
|
||||
// Estilos base por tipo
|
||||
const variantClasses = {
|
||||
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'],
|
||||
};
|
||||
|
||||
// Colores por tipo
|
||||
const colorClasses = {
|
||||
primary: {
|
||||
solid: ['bg-primary-600', 'text-white', 'hover:bg-primary-700', 'focus:ring-primary-500'],
|
||||
outline: ['border-primary-600', 'text-primary-600', 'focus:ring-primary-500'],
|
||||
ghost: ['text-primary-600', 'focus:ring-primary-500'],
|
||||
smooth: ['bg-primary-100', 'text-primary-600', 'hover:bg-primary-200', 'focus:ring-primary-300'],
|
||||
},
|
||||
danger: {
|
||||
solid: ['bg-red-600', 'text-white', 'hover:bg-red-700', 'focus:ring-red-500'],
|
||||
outline: ['border-red-600', 'text-red-600', 'focus:ring-red-500'],
|
||||
ghost: ['text-red-600', 'focus:ring-red-500'],
|
||||
smooth: ['bg-red-100', 'text-red-600', 'hover:bg-red-200', 'focus:ring-red-300'],
|
||||
},
|
||||
success: {
|
||||
solid: ['bg-green-600', 'text-white', 'hover:bg-green-700', 'focus:ring-green-500'],
|
||||
outline: ['border-green-600', 'text-green-600', 'focus:ring-green-500'],
|
||||
ghost: ['text-green-600', 'focus:ring-green-500'],
|
||||
smooth: ['bg-green-100', 'text-green-600', 'hover:bg-green-200', 'focus:ring-green-300'],
|
||||
},
|
||||
info: {
|
||||
solid: ['bg-blue-600', 'text-white', 'hover:bg-blue-700', 'focus:ring-blue-500'],
|
||||
outline: ['border-blue-600', 'text-blue-600', 'focus:ring-blue-500'],
|
||||
ghost: ['text-blue-600', 'focus:ring-blue-500'],
|
||||
smooth: ['bg-blue-100', 'text-blue-600', 'hover:bg-blue-200', 'focus:ring-blue-300'],
|
||||
},
|
||||
warning: {
|
||||
solid: ['bg-yellow-500', 'text-white', 'hover:bg-yellow-600', 'focus:ring-yellow-400'],
|
||||
outline: ['border-yellow-500', 'text-yellow-600', 'focus:ring-yellow-400'],
|
||||
ghost: ['text-yellow-600', 'focus:ring-yellow-400'],
|
||||
smooth: ['bg-yellow-100', 'text-yellow-600', 'hover:bg-yellow-200', 'focus:ring-yellow-200'],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// Asegura que siempre haya un array válido para los estilos
|
||||
const safeVariant = props.variant && variantClasses[props.variant] ? props.variant : 'solid';
|
||||
const safeColor = props.color && colorClasses[props.color] ? props.color : 'primary';
|
||||
|
||||
const classes = [
|
||||
...baseClasses,
|
||||
...sizeClasses[props.size],
|
||||
...(variantClasses[safeVariant] || []),
|
||||
...(colorClasses[safeColor]?.[safeVariant] || []),
|
||||
];
|
||||
|
||||
if (props.fullWidth) {
|
||||
classes.push('w-full');
|
||||
}
|
||||
if (props.iconOnly) {
|
||||
if (props.size === 'sm') classes.push('w-8', 'h-8', 'p-0');
|
||||
else if (props.size === 'lg') classes.push('w-12', 'h-12', 'p-0');
|
||||
else classes.push('w-10', 'h-10', 'p-0');
|
||||
} else if (props.fullWidth) {
|
||||
classes.push('w-full');
|
||||
}
|
||||
|
||||
return classes;
|
||||
});
|
||||
</script>
|
||||
42
src/components/Holos/Card/Card.vue
Normal file
42
src/components/Holos/Card/Card.vue
Normal file
@ -0,0 +1,42 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'outline',
|
||||
validator: (value) => ['default', 'outline', 'ghost'].includes(value)
|
||||
},
|
||||
className: {
|
||||
type: String,
|
||||
default: '',
|
||||
}
|
||||
});
|
||||
|
||||
const cardClasses = computed(() => {
|
||||
const baseClasses = [
|
||||
'rounded-lg',
|
||||
'shadow-sm',
|
||||
'transition-all',
|
||||
'duration-200',
|
||||
];
|
||||
|
||||
const variantClasses = {
|
||||
default: ['border', 'bg-white', 'dark:bg-gray-800', 'text-gray-900', 'dark:text-gray-100'],
|
||||
outline: ['border-2', 'border-gray-100', 'dark:border-gray-700', 'bg-transparent'],
|
||||
ghost: ['shadow-none', 'border-none', 'bg-transparent'],
|
||||
};
|
||||
|
||||
return [
|
||||
...baseClasses,
|
||||
...variantClasses[props.variant],
|
||||
props.className,
|
||||
].filter(Boolean);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cardClasses">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
22
src/components/Holos/Card/CardContent.vue
Normal file
22
src/components/Holos/Card/CardContent.vue
Normal file
@ -0,0 +1,22 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
className: {
|
||||
type: String,
|
||||
default: '',
|
||||
}
|
||||
});
|
||||
|
||||
const contentClasses = computed(() => [
|
||||
'p-6',
|
||||
'pt-0',
|
||||
props.className,
|
||||
].filter(Boolean));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="contentClasses">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
23
src/components/Holos/Card/CardDescription.vue
Normal file
23
src/components/Holos/Card/CardDescription.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
className: {
|
||||
type: String,
|
||||
default: '',
|
||||
}
|
||||
});
|
||||
|
||||
const descriptionClasses = computed(() => [
|
||||
'text-sm',
|
||||
'text-gray-600',
|
||||
'dark:text-gray-400',
|
||||
props.className,
|
||||
].filter(Boolean));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p :class="descriptionClasses">
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
24
src/components/Holos/Card/CardFooter.vue
Normal file
24
src/components/Holos/Card/CardFooter.vue
Normal file
@ -0,0 +1,24 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
className: {
|
||||
type: String,
|
||||
default: '',
|
||||
}
|
||||
});
|
||||
|
||||
const footerClasses = computed(() => [
|
||||
'flex',
|
||||
'items-center',
|
||||
'p-6',
|
||||
'pt-0',
|
||||
props.className,
|
||||
].filter(Boolean));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="footerClasses">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
24
src/components/Holos/Card/CardHeader.vue
Normal file
24
src/components/Holos/Card/CardHeader.vue
Normal file
@ -0,0 +1,24 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
className: {
|
||||
type: String,
|
||||
default: '',
|
||||
}
|
||||
});
|
||||
|
||||
const headerClasses = computed(() => [
|
||||
'flex',
|
||||
'flex-col',
|
||||
'space-y-1.5',
|
||||
'p-6',
|
||||
props.className,
|
||||
].filter(Boolean));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="headerClasses">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
24
src/components/Holos/Card/CardTitle.vue
Normal file
24
src/components/Holos/Card/CardTitle.vue
Normal file
@ -0,0 +1,24 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
className: {
|
||||
type: String,
|
||||
default: '',
|
||||
}
|
||||
});
|
||||
|
||||
const titleClasses = computed(() => [
|
||||
'text-2xl',
|
||||
'font-semibold',
|
||||
'leading-none',
|
||||
'tracking-tight',
|
||||
props.className,
|
||||
].filter(Boolean));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h3 :class="titleClasses">
|
||||
<slot />
|
||||
</h3>
|
||||
</template>
|
||||
@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div class="md:col-span-1 flex justify-between">
|
||||
<div class="px-4 sm:px-0">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
<h3 class="text-3xl font-extrabold text-gray-900 dark:text-primary-dt">
|
||||
<slot name="title" />
|
||||
</h3>
|
||||
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-white/50">
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-primary-dt/70">
|
||||
<slot name="description" />
|
||||
</p>
|
||||
</div>
|
||||
@ -15,3 +15,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@ -88,6 +88,11 @@ onMounted(() => {
|
||||
name="Almacén"
|
||||
to="admin.warehouses.index"
|
||||
/>
|
||||
<Link
|
||||
icon="tag"
|
||||
name="Clasificaciones de almacenes"
|
||||
to="admin.warehouse-classifications.index"
|
||||
/>
|
||||
</Section>
|
||||
<Section name="Capacitaciones">
|
||||
<DropDown
|
||||
|
||||
89
src/pages/WarehouseClassifications/Create.vue
Normal file
89
src/pages/WarehouseClassifications/Create.vue
Normal file
@ -0,0 +1,89 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter, useRoute } 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>
|
||||
141
src/pages/WarehouseClassifications/Edit.vue
Normal file
141
src/pages/WarehouseClassifications/Edit.vue
Normal file
@ -0,0 +1,141 @@
|
||||
<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: '',
|
||||
is_active: true,
|
||||
parent: null
|
||||
});
|
||||
|
||||
const parentOptions = ref([]);
|
||||
|
||||
/** Métodos */
|
||||
function submit() {
|
||||
form.transform(data => ({
|
||||
...data,
|
||||
parent_id: data.parent?.id || null
|
||||
})).put(apiTo('update', { warehouse_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 filteredData = excludeCurrentAndChildren(r.warehouse_classifications.data, form.id);
|
||||
parentOptions.value = flattenOptions(filteredData);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
api.get(apiTo('show', { warehouse_classification: vroute.params.id }), {
|
||||
onSuccess: (r) => {
|
||||
const classification = r.warehouse_classification;
|
||||
form.fill({
|
||||
id: classification.id,
|
||||
code: classification.code,
|
||||
name: classification.name,
|
||||
description: classification.description,
|
||||
is_active: classification.is_active,
|
||||
parent: classification.parent || null
|
||||
});
|
||||
|
||||
// Cargar opciones padre después de obtener los datos actuales
|
||||
loadParentOptions();
|
||||
}
|
||||
});
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeader :title="`${transl('edit.title')}${form.parent ? ' - Subcategoría' : ''}`">
|
||||
<RouterLink :to="viewTo({ name: 'index' })">
|
||||
<IconButton
|
||||
class="text-white"
|
||||
icon="arrow_back"
|
||||
:title="$t('return')"
|
||||
filled
|
||||
/>
|
||||
</RouterLink>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Mostrar información del padre si es una subcategoría -->
|
||||
<div v-if="form.parent" class="mb-4 p-4 bg-yellow-50 dark:bg-yellow-900 rounded-lg border border-yellow-200 dark:border-yellow-700">
|
||||
<div class="flex items-center">
|
||||
<GoogleIcon class="text-yellow-600 text-xl mr-2" name="account_tree" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||
Editando subcategoría de:
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-yellow-900 dark:text-yellow-100">
|
||||
<code class="bg-yellow-100 dark:bg-yellow-800 px-2 py-1 rounded text-sm mr-2">{{ form.parent.code }}</code>
|
||||
{{ form.parent.name }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
action="update"
|
||||
:form="form"
|
||||
:parent-options="parentOptions"
|
||||
@submit="submit"
|
||||
/>
|
||||
</template>
|
||||
68
src/pages/WarehouseClassifications/Form.vue
Normal file
68
src/pages/WarehouseClassifications/Form.vue
Normal file
@ -0,0 +1,68 @@
|
||||
<script setup>
|
||||
import { transl } from './Module';
|
||||
|
||||
import PrimaryButton from '@Holos/Button/Primary.vue';
|
||||
import Input from '@Holos/Form/Input.vue';
|
||||
import Selectable from '@Holos/Form/Selectable.vue';
|
||||
import Checkbox from '@Holos/Checkbox.vue';
|
||||
|
||||
/** Eventos */
|
||||
const emit = defineEmits([
|
||||
'submit'
|
||||
])
|
||||
|
||||
/** Propiedades */
|
||||
defineProps({
|
||||
action: {
|
||||
default: 'create',
|
||||
type: String
|
||||
},
|
||||
form: Object,
|
||||
parentOptions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
/** Métodos */
|
||||
function submit() {
|
||||
emit('submit')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full pb-2">
|
||||
<p class="text-justify text-sm" v-text="transl(`${action}.description`)" />
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<form @submit.prevent="submit" class="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<Input
|
||||
v-model="form.code"
|
||||
id="code"
|
||||
:onError="form.errors.code"
|
||||
autofocus
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
v-model="form.name"
|
||||
id="name"
|
||||
:onError="form.errors.name"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
v-model="form.description"
|
||||
id="description"
|
||||
:onError="form.errors.description"
|
||||
class="md:col-span-2"
|
||||
/>
|
||||
<slot />
|
||||
<div class="col-span-1 md:col-span-2 lg:col-span-3 xl:col-span-4 flex flex-col items-center justify-end space-y-4 mt-4">
|
||||
<PrimaryButton
|
||||
v-text="$t(action)"
|
||||
:class="{ 'opacity-25': form.processing }"
|
||||
:disabled="form.processing"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
212
src/pages/WarehouseClassifications/Index.vue
Normal file
212
src/pages/WarehouseClassifications/Index.vue
Normal file
@ -0,0 +1,212 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } 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(false);
|
||||
const destroyModal = ref(false);
|
||||
|
||||
/** Métodos */
|
||||
const searcher = useSearcher({
|
||||
url: apiTo('index'),
|
||||
onSuccess: (r) => models.value = r.warehouse_classifications,
|
||||
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);
|
||||
};
|
||||
|
||||
/** 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('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="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"
|
||||
/>
|
||||
|
||||
<DestroyView
|
||||
ref="destroyModal"
|
||||
subtitle="name"
|
||||
:to="(id) => apiTo('destroy', { warehouse_classification: id })"
|
||||
@update="searcher.search()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
362
src/pages/WarehouseClassifications/Modals/Show.vue
Normal file
362
src/pages/WarehouseClassifications/Modals/Show.vue
Normal file
@ -0,0 +1,362 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { getDateTime } from '@Controllers/DateController';
|
||||
import { viewTo, apiTo } from '../Module';
|
||||
import WarehouseClassificationService from '../services/WarehouseClassificationService';
|
||||
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 WarehouseClassificationService();
|
||||
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 warehouseService.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 warehouseService.getById(model.value.id);
|
||||
|
||||
// Actualizar el modelo local con los datos frescos
|
||||
if (response && response.warehouse_classification) {
|
||||
model.value = response.warehouse_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-blue-500 to-purple-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', { warehouse_classification: id })"
|
||||
@update="onSubcategoryDeleted"
|
||||
/>
|
||||
</ShowModal>
|
||||
</template>
|
||||
23
src/pages/WarehouseClassifications/Module.js
Normal file
23
src/pages/WarehouseClassifications/Module.js
Normal file
@ -0,0 +1,23 @@
|
||||
import { lang } from '@Lang/i18n';
|
||||
import { hasPermission } from '@Plugins/RolePermission.js';
|
||||
|
||||
// Ruta API
|
||||
const apiTo = (name, params = {}) => route(`warehouse-classifications.${name}`, params)
|
||||
|
||||
// Ruta visual
|
||||
const viewTo = ({ name = '', params = {}, query = {} }) => view({
|
||||
name: `admin.warehouse-classifications.${name}`, params, query
|
||||
})
|
||||
|
||||
// Obtener traducción del componente
|
||||
const transl = (str) => lang(`admin.warehouse_classifications.${str}`)
|
||||
|
||||
// Control de permisos
|
||||
const can = (permission) => hasPermission(`admin.warehouse-classifications.${permission}`)
|
||||
|
||||
export {
|
||||
can,
|
||||
viewTo,
|
||||
apiTo,
|
||||
transl
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Interfaces para Warehouse Classifications
|
||||
*
|
||||
* @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 {WarehouseClassification|null} parent - Clasificación padre
|
||||
* @property {WarehouseClassification[]} children - Clasificaciones hijas
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} WarehouseClassificationResponse
|
||||
* @property {string} status - Estado de la respuesta
|
||||
* @property {Object} data - Datos de la respuesta
|
||||
* @property {string} data.message - Mensaje de la respuesta
|
||||
* @property {WarehouseClassification} data.warehouse_classification - Clasificación de almacén
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} WarehouseClassificationsListResponse
|
||||
* @property {string} status - Estado de la respuesta
|
||||
* @property {Object} data - Datos de la respuesta
|
||||
* @property {WarehouseClassification[]} data.warehouse_classifications - Lista de clasificaciones
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} CreateWarehouseClassificationData
|
||||
* @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} UpdateWarehouseClassificationData
|
||||
* @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 {
|
||||
WarehouseClassification,
|
||||
WarehouseClassificationResponse,
|
||||
WarehouseClassificationsListResponse,
|
||||
CreateWarehouseClassificationData,
|
||||
UpdateWarehouseClassificationData
|
||||
};
|
||||
@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Servicio para Warehouse Classifications
|
||||
*
|
||||
* @author Sistema
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { api, apiURL } from '@Services/Api';
|
||||
|
||||
export default class WarehouseClassificationService {
|
||||
|
||||
/**
|
||||
* Obtener todas las clasificaciones de almacén
|
||||
* @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/warehouse-classifications'), {
|
||||
params,
|
||||
onSuccess: (response) => resolve(response),
|
||||
onError: (error) => reject(error)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener una clasificación de almacén 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(`catalogs/warehouse-classifications/${id}`), {
|
||||
onSuccess: (response) => resolve(response),
|
||||
onError: (error) => reject(error)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Crear una nueva clasificación de almacén
|
||||
* @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|null} data.description - Descripción de la clasificación
|
||||
* @param {boolean} data.is_active - Estado activo/inactivo
|
||||
* @param {number|null} data.parent_id - ID del padre
|
||||
* @returns {Promise} Promesa con la respuesta
|
||||
*/
|
||||
async create(data) {
|
||||
return new Promise((resolve, reject) => {
|
||||
api.post(apiURL('catalogs/warehouse-classifications'), {
|
||||
data,
|
||||
onSuccess: (response) => {
|
||||
resolve(response);
|
||||
},
|
||||
onError: (error) => { reject(error); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar una clasificación de almacén
|
||||
* @param {number} id - ID de la clasificación
|
||||
* @param {Object} data - Datos a actualizar
|
||||
* @param {string} [data.code] - Código de la clasificación
|
||||
* @param {string} [data.name] - Nombre de la clasificación
|
||||
* @param {string|null} [data.description] - Descripción de la clasificación
|
||||
* @param {boolean} [data.is_active] - Estado activo/inactivo
|
||||
* @param {number|null} [data.parent_id] - ID del padre
|
||||
* @returns {Promise} Promesa con la respuesta
|
||||
*/
|
||||
async update(id, data) {
|
||||
return new Promise((resolve, reject) => {
|
||||
api.put(apiURL(`catalogs/warehouse-classifications/${id}`), {
|
||||
data,
|
||||
onSuccess: (response) => resolve(response),
|
||||
onError: (error) => reject(error)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar solo el estado de una clasificación de almacén
|
||||
* @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(`catalogs/warehouse-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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Eliminar una clasificación de almacén
|
||||
* @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(`catalogs/warehouse-classifications/${id}`), {
|
||||
onSuccess: (response) => resolve(response),
|
||||
onError: (error) => reject(error)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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('catalogs/warehouse-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.warehouse_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);
|
||||
}
|
||||
}
|
||||
@ -1,386 +1,133 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import GoogleIcon from '@Shared/GoogleIcon.vue'
|
||||
import Searcher from '@Holos/Searcher.vue'
|
||||
import Adding from '@Holos/Button/ButtonRh.vue'
|
||||
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'
|
||||
|
||||
// Reactive state
|
||||
const warehouses = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const inventoryMovements = ref([
|
||||
// kept mock movements for demo; in real app these would come from API
|
||||
{
|
||||
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 warehouses = ref({});
|
||||
const processing = ref(false);
|
||||
const selectedStatus = ref('');
|
||||
|
||||
const statusOptions = [
|
||||
{ id: '', name: 'Todos los estados' },
|
||||
{ id: 'active', name: 'Activos' },
|
||||
{ id: 'inactive', name: 'Inactivos' }
|
||||
];
|
||||
|
||||
const filteredWarehouses = computed(() => {
|
||||
if (!selectedStatus.value || selectedStatus.value === '') {
|
||||
return warehouses.value;
|
||||
}
|
||||
])
|
||||
|
||||
const stockByWarehouse = ref([])
|
||||
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;
|
||||
}) || []
|
||||
};
|
||||
|
||||
// UI state
|
||||
const activeTab = ref('warehouses')
|
||||
const searchTerm = ref('')
|
||||
return filtered;
|
||||
});
|
||||
|
||||
const fetchWarehousesFromApi = (q = '') => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
api.get(apiTo('index'), {
|
||||
params: { q },
|
||||
onStart: () => {
|
||||
loading.value = true
|
||||
function fetchWarehouses(url = null) {
|
||||
processing.value = true;
|
||||
api.get(url || apiTo('index'), {
|
||||
onSuccess: (r) => {
|
||||
warehouses.value = r.warehouses;
|
||||
processing.value = false;
|
||||
},
|
||||
onSuccess: (data, fullPayload) => {
|
||||
const payload = Array.isArray(data) ? data : (fullPayload && Array.isArray(fullPayload.data) ? fullPayload.data : [])
|
||||
|
||||
warehouses.value = payload.map((w) => ({
|
||||
id: w.id,
|
||||
code: w.code ?? `WH-${String(w.id).padStart(3, '0')}`,
|
||||
name: w.name,
|
||||
location: w.address ?? '',
|
||||
type: w.type ?? '',
|
||||
totalProducts: (w.classifications && Array.isArray(w.classifications)) ? w.classifications.length : 0,
|
||||
totalValue: w.total_value ?? 0,
|
||||
capacity: w.capacity ?? '0%',
|
||||
status: w.is_active ? 'active' : 'inactive',
|
||||
classifications: w.classifications ?? []
|
||||
}))
|
||||
},
|
||||
onFail: (data) => {
|
||||
error.value = data?.message || 'Error al obtener almacenes'
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error('API Error:', err)
|
||||
try {
|
||||
error.value = err?.message || err?.data?.message || 'Error desconocido'
|
||||
} catch (e) {
|
||||
error.value = 'Error desconocido'
|
||||
}
|
||||
},
|
||||
onFinish: () => {
|
||||
loading.value = false
|
||||
onError: () => {
|
||||
processing.value = false;
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchWarehousesFromApi()
|
||||
})
|
||||
|
||||
// Computed totals
|
||||
const totalInventoryValue = computed(() => warehouses.value.reduce((sum, wh) => sum + (wh.totalValue || 0), 0))
|
||||
const totalProducts = computed(() => warehouses.value.reduce((sum, wh) => sum + (wh.totalProducts || 0), 0))
|
||||
|
||||
// Filtered movements by searchTerm
|
||||
const filteredMovements = computed(() => {
|
||||
const q = searchTerm.value.trim().toLowerCase()
|
||||
if (!q) return inventoryMovements.value
|
||||
return inventoryMovements.value.filter(m => {
|
||||
return [m.product, m.code, m.warehouse, m.user, m.reference]
|
||||
.filter(Boolean)
|
||||
.some(f => f.toLowerCase().includes(q))
|
||||
})
|
||||
})
|
||||
|
||||
// Helpers
|
||||
const parseCapacity = (cap) => {
|
||||
if (typeof cap === 'string') return parseInt(cap.replace('%', ''), 10) || 0
|
||||
if (typeof cap === 'number') return Math.round(cap)
|
||||
return 0
|
||||
}
|
||||
|
||||
const getMovementIconClass = (type) => {
|
||||
switch (type) {
|
||||
case 'entrada':
|
||||
return 'icon-arrow-down-left text-success'
|
||||
case 'salida':
|
||||
return 'icon-arrow-up-right text-destructive'
|
||||
case 'transferencia':
|
||||
return 'icon-refresh text-warning'
|
||||
case 'ajuste':
|
||||
return 'icon-trending-up text-primary'
|
||||
default:
|
||||
return 'icon-refresh'
|
||||
}
|
||||
}
|
||||
|
||||
const movementVariant = (type) => {
|
||||
const map = { entrada: 'default', salida: 'destructive', transferencia: 'secondary', ajuste: 'outline' }
|
||||
return map[type] || 'default'
|
||||
}
|
||||
|
||||
const formatCurrency = (value) => {
|
||||
try {
|
||||
return new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(value)
|
||||
} catch (e) {
|
||||
return `$${value}`
|
||||
}
|
||||
}
|
||||
fetchWarehouses();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 max-w-auto mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-extrabold text-gray-900 dark:text-primary-dt">Inventario</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-primary-dt/70">Control de stock y movimientos por almacén
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
<Adding text="Nuevo Almacén" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- <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> -->
|
||||
|
||||
<!-- Searcher for warehouses -->
|
||||
<div class="mt-6">
|
||||
<Searcher title="Buscar Almacenes" placeholder="Buscar por nombre o ubicación..."
|
||||
@search="fetchWarehousesFromApi" />
|
||||
|
||||
<!-- Loading / Error -->
|
||||
<div class="mt-2">
|
||||
<div v-if="loading" class="text-sm text-muted-foreground">Cargando almacenes...</div>
|
||||
<div v-if="error" class="mt-2 text-sm text-destructive">{{ error }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick actions -->
|
||||
<div class="flex items-center space-x-2 mt-4">
|
||||
<button class="btn btn-outline" aria-label="Entrada">
|
||||
<span class="icon">+</span> Entrada
|
||||
</button>
|
||||
<button class="btn btn-outline" aria-label="Salida">
|
||||
<span class="icon">-</span> Salida
|
||||
</button>
|
||||
<button class="bg-primary text-white" aria-label="Transferencia">Transferencia</button>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mt-6">
|
||||
<div class="card">
|
||||
<div class="card-body p-4 flex items-center space-x-2">
|
||||
<span class="icon text-primary">W</span>
|
||||
<div>
|
||||
<p class="text-2xl font-bold">{{ warehouses.length }}</p>
|
||||
<p class="text-sm text-muted-foreground">Almacenes</p>
|
||||
<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>
|
||||
</CardTitle>
|
||||
</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>
|
||||
</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'">
|
||||
{{ warehouse.is_active ? 'Activo' : 'Inactivo' }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-4 flex items-center space-x-2">
|
||||
<div class="w-8 h-8 rounded-full bg-success flex items-center justify-center">
|
||||
<span class="text-success-foreground font-bold text-sm">#</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-success">{{ totalProducts }}</p>
|
||||
<p class="text-sm text-muted-foreground">Total Productos</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-4 flex items-center space-x-2">
|
||||
<div class="w-8 h-8 rounded-full bg-warning flex items-center justify-center">
|
||||
<span class="text-warning-foreground font-bold text-sm">$</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-warning">{{ (totalInventoryValue / 1000000).toFixed(1) }}M</p>
|
||||
<p class="text-sm text-muted-foreground">Valor Total</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
<td colspan="4" class="text-center py-4">No hay almacenes registrados.</td>
|
||||
</template>
|
||||
</Table>
|
||||
</CardContent>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-4 flex items-center space-x-2">
|
||||
<div class="w-8 h-8 rounded-full bg-primary flex items-center justify-center">
|
||||
<span class="text-primary-foreground">R</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-primary">{{ inventoryMovements.length }}</p>
|
||||
<p class="text-sm text-muted-foreground">Movimientos Hoy</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="mt-6">
|
||||
<div class="tabs">
|
||||
<button :class="['tab', { 'active': activeTab === 'warehouses' }]"
|
||||
@click="activeTab = 'warehouses'">Almacenes</button>
|
||||
<button :class="['tab', { 'active': activeTab === 'movements' }]"
|
||||
@click="activeTab = 'movements'">Movimientos</button>
|
||||
<button :class="['tab', { 'active': activeTab === 'stock' }]" @click="activeTab = 'stock'">Stock por
|
||||
Almacén</button>
|
||||
</div>
|
||||
|
||||
<!-- Warehouses Tab -->
|
||||
<div v-if="activeTab === 'warehouses'" class="mt-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div v-for="wh in warehouses" :key="wh.id" class="card overflow-hidden">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="icon">W</span>
|
||||
<div>
|
||||
<h3 class="font-semibold text-sm">{{ wh.name }}</h3>
|
||||
<p class="text-xs text-muted-foreground font-mono">{{ wh.code }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
:class="['badge', wh.status === 'active' ? 'badge-default' : 'badge-destructive']">{{
|
||||
wh.status === 'active' ? 'Activo' : 'Lleno' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-1 text-xs text-muted-foreground mb-3">
|
||||
<span class="icon">📍</span>
|
||||
<span>{{ wh.location }}</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-muted-foreground">Productos:</span>
|
||||
<span class="font-medium">{{ wh.totalProducts }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-muted-foreground">Valor:</span>
|
||||
<span class="font-medium">{{ (wh.totalValue / 1000000).toFixed(1) }}M</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-muted-foreground">Capacidad:</span>
|
||||
<span
|
||||
:class="['font-medium', parseCapacity(wh.capacity) > 80 ? 'text-destructive' : parseCapacity(wh.capacity) > 60 ? 'text-warning' : 'text-success']">{{
|
||||
wh.capacity }}</span>
|
||||
</div>
|
||||
|
||||
<div class="w-full bg-muted rounded-full h-2 mt-2">
|
||||
<div :class="['h-2 rounded-full transition-all', parseCapacity(wh.capacity) > 80 ? 'bg-destructive' : parseCapacity(wh.capacity) > 60 ? 'bg-warning' : 'bg-success']"
|
||||
:style="{ width: wh.capacity }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Movements Tab -->
|
||||
<div v-if="activeTab === 'movements'" class="mt-4">
|
||||
<div class="card">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center space-x-4 mb-6">
|
||||
<div class="relative flex-1 max-w-sm">
|
||||
<Searcher title="Buscar movimientos..." placeholder="Buscar movimientos..."
|
||||
@search="val => searchTerm = val" />
|
||||
<input class="hidden" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Fecha</th>
|
||||
<th>Tipo</th>
|
||||
<th>Producto</th>
|
||||
<th>Cantidad</th>
|
||||
<th>Almacén</th>
|
||||
<th>Usuario</th>
|
||||
<th>Estado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="movement in filteredMovements" :key="movement.id">
|
||||
<td class="text-sm">{{ new Date(movement.date).toLocaleString('es-MX') }}</td>
|
||||
<td>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span :class="getMovementIconClass(movement.type)"></span>
|
||||
<span class="badge" :data-variant="movementVariant(movement.type)">{{
|
||||
movement.type }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>
|
||||
<p class="font-medium text-sm">{{ movement.product }}</p>
|
||||
<p class="text-xs text-muted-foreground font-mono">{{ movement.code }}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="font-mono">
|
||||
<span
|
||||
:class="movement.quantity > 0 ? 'text-success' : 'text-destructive'">{{
|
||||
movement.quantity > 0 ? '+' : '' }}{{ movement.quantity }}</span>
|
||||
</td>
|
||||
<td class="text-sm">{{ movement.warehouse }}</td>
|
||||
<td class="text-sm">{{ movement.user }}</td>
|
||||
<td>
|
||||
<span class="badge"
|
||||
:data-variant="movement.status === 'completed' ? 'default' : 'secondary'">{{
|
||||
movement.status === 'completed' ? 'Completado' : 'Pendiente' }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stock by Warehouse Tab -->
|
||||
<div v-if="activeTab === 'stock'" class="mt-4 space-y-4">
|
||||
<div v-for="(wh, index) in stockByWarehouse" :key="index" class="card">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center space-x-2 mb-2">
|
||||
<span class="icon">W</span>
|
||||
<h4 class="font-semibold">{{ wh.warehouse }}</h4>
|
||||
</div>
|
||||
<div class="rounded-md border">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Código</th>
|
||||
<th>Producto</th>
|
||||
<th>Stock</th>
|
||||
<th>Valor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="product in wh.products" :key="product.code">
|
||||
<td class="font-mono text-sm">{{ product.code }}</td>
|
||||
<td class="font-medium">{{ product.name }}</td>
|
||||
<td class="font-mono">{{ product.stock }} uds</td>
|
||||
<td class="font-mono">{{ formatCurrency(product.value) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -343,6 +343,40 @@ const router = createRouter({
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'warehouse-classifications',
|
||||
name: 'admin.warehouse-classifications',
|
||||
meta: {
|
||||
title: 'Clasificaciones de Bodegas',
|
||||
icon: 'category',
|
||||
},
|
||||
redirect: '/admin/warehouse-classifications',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'admin.warehouse-classifications.index',
|
||||
component: () => import('@Pages/WarehouseClassifications/Index.vue'),
|
||||
},
|
||||
{
|
||||
path: 'create',
|
||||
name: 'admin.warehouse-classifications.create',
|
||||
component: () => import('@Pages/WarehouseClassifications/Create.vue'),
|
||||
meta: {
|
||||
title: 'Crear',
|
||||
icon: 'add',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: ':id/edit',
|
||||
name: 'admin.warehouse-classifications.edit',
|
||||
component: () => import('@Pages/WarehouseClassifications/Edit.vue'),
|
||||
meta: {
|
||||
title: 'Editar',
|
||||
icon: 'edit',
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'roles',
|
||||
name: 'admin.roles',
|
||||
|
||||
218
src/stores/WarehouseClassifications.js
Normal file
218
src/stores/WarehouseClassifications.js
Normal file
@ -0,0 +1,218 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { api } from '../services/Api'
|
||||
|
||||
// Store para las clasificaciones de almacén
|
||||
const useWarehouseClassifications = defineStore('warehouseClassifications', {
|
||||
state: () => ({
|
||||
classifications: [],
|
||||
loading: false,
|
||||
error: null
|
||||
}),
|
||||
|
||||
getters: {
|
||||
// Obtener todas las clasificaciones
|
||||
allClassifications(state) {
|
||||
return state.classifications
|
||||
},
|
||||
|
||||
// Obtener solo las clasificaciones principales (sin padre)
|
||||
mainClassifications(state) {
|
||||
return state.classifications.filter(c => !c.parent_id)
|
||||
},
|
||||
|
||||
// Obtener clasificaciones por estado
|
||||
activeClassifications(state) {
|
||||
return state.classifications.filter(c => c.is_active)
|
||||
},
|
||||
|
||||
// Obtener subcategorías de una clasificación específica
|
||||
getSubcategoriesByParentId: (state) => (parentId) => {
|
||||
return state.classifications.filter(c => c.parent_id === parentId)
|
||||
},
|
||||
|
||||
// Obtener una clasificación por ID
|
||||
getClassificationById: (state) => (id) => {
|
||||
return state.classifications.find(c => c.id === id)
|
||||
},
|
||||
|
||||
// Verificar si está cargando
|
||||
isLoading(state) {
|
||||
return state.loading
|
||||
},
|
||||
|
||||
// Obtener error actual
|
||||
currentError(state) {
|
||||
return state.error
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
// Cargar todas las clasificaciones
|
||||
async fetchClassifications() {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const response = await api.get('/warehouse-classifications')
|
||||
this.classifications = response.data.data || response.data
|
||||
return response.data
|
||||
} catch (error) {
|
||||
this.error = error.message || 'Error al cargar las clasificaciones'
|
||||
console.error('Error fetching classifications:', error)
|
||||
throw error
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
// Crear nueva clasificación
|
||||
async createClassification(data) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const response = await api.post('/warehouse-classifications', data)
|
||||
const newClassification = response.data.data || response.data
|
||||
|
||||
// Agregar la nueva clasificación al estado
|
||||
this.classifications.push(newClassification)
|
||||
|
||||
// Si tiene parent_id, actualizar la lista de children del padre
|
||||
if (newClassification.parent_id) {
|
||||
const parent = this.getClassificationById(newClassification.parent_id)
|
||||
if (parent) {
|
||||
if (!parent.children) {
|
||||
parent.children = []
|
||||
}
|
||||
parent.children.push(newClassification)
|
||||
}
|
||||
}
|
||||
|
||||
return response.data
|
||||
} catch (error) {
|
||||
this.error = error.message || 'Error al crear la clasificación'
|
||||
console.error('Error creating classification:', error)
|
||||
throw error
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
// Actualizar clasificación
|
||||
async updateClassification(id, data) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const response = await api.put(`/warehouse-classifications/${id}`, data)
|
||||
const updatedClassification = response.data.data || response.data
|
||||
|
||||
// Actualizar en el estado local
|
||||
const index = this.classifications.findIndex(c => c.id === id)
|
||||
if (index !== -1) {
|
||||
this.classifications[index] = { ...this.classifications[index], ...updatedClassification }
|
||||
}
|
||||
|
||||
// También actualizar en children si existe
|
||||
this.classifications.forEach(classification => {
|
||||
if (classification.children) {
|
||||
const childIndex = classification.children.findIndex(c => c.id === id)
|
||||
if (childIndex !== -1) {
|
||||
classification.children[childIndex] = { ...classification.children[childIndex], ...updatedClassification }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return response.data
|
||||
} catch (error) {
|
||||
this.error = error.message || 'Error al actualizar la clasificación'
|
||||
console.error('Error updating classification:', error)
|
||||
throw error
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
// Eliminar clasificación
|
||||
async deleteClassification(id) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
await api.delete(`/warehouse-classifications/${id}`)
|
||||
|
||||
// Remover del estado local
|
||||
this.classifications = this.classifications.filter(c => c.id !== id)
|
||||
|
||||
// También remover de children si existe
|
||||
this.classifications.forEach(classification => {
|
||||
if (classification.children) {
|
||||
classification.children = classification.children.filter(c => c.id !== id)
|
||||
}
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
this.error = error.message || 'Error al eliminar la clasificación'
|
||||
console.error('Error deleting classification:', error)
|
||||
throw error
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
// Cambiar estado de una clasificación
|
||||
async toggleClassificationStatus(classification) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const newStatus = !classification.is_active
|
||||
const response = await api.put(`/warehouse-classifications/${classification.id}`, {
|
||||
...classification,
|
||||
is_active: newStatus
|
||||
})
|
||||
|
||||
const updatedClassification = response.data.data || response.data
|
||||
|
||||
// Actualizar en el estado local
|
||||
const index = this.classifications.findIndex(c => c.id === classification.id)
|
||||
if (index !== -1) {
|
||||
this.classifications[index].is_active = newStatus
|
||||
}
|
||||
|
||||
// También actualizar en children si existe
|
||||
this.classifications.forEach(parentClassification => {
|
||||
if (parentClassification.children) {
|
||||
const childIndex = parentClassification.children.findIndex(c => c.id === classification.id)
|
||||
if (childIndex !== -1) {
|
||||
parentClassification.children[childIndex].is_active = newStatus
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return response.data
|
||||
} catch (error) {
|
||||
this.error = error.message || 'Error al cambiar el estado'
|
||||
console.error('Error toggling status:', error)
|
||||
throw error
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
// Limpiar errores
|
||||
clearError() {
|
||||
this.error = null
|
||||
},
|
||||
|
||||
// Reset del store
|
||||
$reset() {
|
||||
this.classifications = []
|
||||
this.loading = false
|
||||
this.error = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export { useWarehouseClassifications }
|
||||
82
tailwind.config.js
Normal file
82
tailwind.config.js
Normal file
@ -0,0 +1,82 @@
|
||||
// tailwind.config.js
|
||||
import { defineConfig } from 'tailwindcss';
|
||||
|
||||
export default defineConfig({
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
secondary: {
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
DEFAULT: '#3b82f6',
|
||||
},
|
||||
info: {
|
||||
100: '#cffafe',
|
||||
200: '#a5f3fc',
|
||||
300: '#67e8f9',
|
||||
400: '#22d3ee',
|
||||
500: '#06b6d4',
|
||||
600: '#0891b2',
|
||||
700: '#0e7490',
|
||||
800: '#155e75',
|
||||
900: '#164e63',
|
||||
DEFAULT: '#06b6d4',
|
||||
},
|
||||
success: {
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
DEFAULT: '#22c55e',
|
||||
},
|
||||
danger: {
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
DEFAULT: '#ef4444',
|
||||
},
|
||||
warning: {
|
||||
100: '#fef9c3',
|
||||
200: '#fef08a',
|
||||
300: '#fde047',
|
||||
400: '#facc15',
|
||||
500: '#eab308',
|
||||
600: '#ca8a04',
|
||||
700: '#a16207',
|
||||
800: '#854d0e',
|
||||
900: '#713f12',
|
||||
DEFAULT: '#eab308',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user