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