Compare commits
No commits in common. "0b1001b8d1fadc635d7f3c7b01e0b4e444a25b3f" and "dfbe572c7966e4675e6983c2d76b74b52f84f501" have entirely different histories.
0b1001b8d1
...
dfbe572c79
@ -62,12 +62,6 @@ const menuItems = ref<MenuItem[]>([
|
|||||||
'companies.update',
|
'companies.update',
|
||||||
'companies.destroy',
|
'companies.destroy',
|
||||||
],
|
],
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Conceptos de Documentos',
|
|
||||||
icon: 'pi pi-book',
|
|
||||||
to: '/catalog/document-concepts',
|
|
||||||
permission: 'document_concepts.index'
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import PrimeVue from "primevue/config";
|
|||||||
import ConfirmationService from 'primevue/confirmationservice';
|
import ConfirmationService from 'primevue/confirmationservice';
|
||||||
import ToastService from 'primevue/toastservice';
|
import ToastService from 'primevue/toastservice';
|
||||||
import StyleClass from "primevue/styleclass";
|
import StyleClass from "primevue/styleclass";
|
||||||
import Tooltip from "primevue/tooltip";
|
|
||||||
import { createApp } from "vue";
|
import { createApp } from "vue";
|
||||||
import { createPinia } from "pinia";
|
import { createPinia } from "pinia";
|
||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
@ -49,7 +48,6 @@ app.use(PrimeVue, {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.directive("styleclass", StyleClass);
|
app.directive("styleclass", StyleClass);
|
||||||
app.directive("tooltip", Tooltip);
|
|
||||||
|
|
||||||
const bootstrap = async () => {
|
const bootstrap = async () => {
|
||||||
const { initAuth } = useAuth();
|
const { initAuth } = useAuth();
|
||||||
|
|||||||
@ -1,127 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { reactive, watch, ref } from 'vue';
|
|
||||||
import type { DocumentConcept, CreateDocumentConceptDTO } from './document-concepts.interface';
|
|
||||||
import type { DocumentModel } from './document-models.interface';
|
|
||||||
import { DocumentModelsService } from './document-models.services';
|
|
||||||
import { useToast } from 'primevue/usetoast';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
modelValue?: DocumentConcept | null;
|
|
||||||
canEdit?: boolean;
|
|
||||||
canCreate?: boolean;
|
|
||||||
canUpdate?: boolean;
|
|
||||||
}
|
|
||||||
const props = withDefaults(defineProps<Props>(), { modelValue: null, canEdit: true, canCreate: true, canUpdate: true });
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'submit', value: CreateDocumentConceptDTO): void;
|
|
||||||
(e: 'cancel'): void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const toast = useToast();
|
|
||||||
const documentModels = ref<DocumentModel[]>([]);
|
|
||||||
const loadingModels = ref(false);
|
|
||||||
|
|
||||||
const form = reactive<CreateDocumentConceptDTO>({
|
|
||||||
document_model_id: 0,
|
|
||||||
name: '',
|
|
||||||
serie: '',
|
|
||||||
initial_number: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const errors = reactive<{ [K in keyof CreateDocumentConceptDTO]?: string }>({});
|
|
||||||
|
|
||||||
watch(() => props.modelValue, (val) => {
|
|
||||||
if (val) {
|
|
||||||
form.document_model_id = val.document_model_id;
|
|
||||||
form.name = val.name;
|
|
||||||
form.serie = val.serie || '';
|
|
||||||
form.initial_number = val.initial_number;
|
|
||||||
} else {
|
|
||||||
form.document_model_id = 0;
|
|
||||||
form.name = '';
|
|
||||||
form.serie = '';
|
|
||||||
form.initial_number = 1;
|
|
||||||
}
|
|
||||||
}, { immediate: true });
|
|
||||||
|
|
||||||
const documentModelsService = new DocumentModelsService();
|
|
||||||
|
|
||||||
async function fetchDocumentModels(searchTerm?: string) {
|
|
||||||
loadingModels.value = true;
|
|
||||||
try {
|
|
||||||
const data = await documentModelsService.getDocumentModels({ paginate: false, ...(searchTerm ? { search: searchTerm } : {}) });
|
|
||||||
documentModels.value = Array.isArray(data) ? data : [];
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudieron obtener los modelos de documento', life: 4000 });
|
|
||||||
} finally {
|
|
||||||
loadingModels.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cargar todos los modelos al montar
|
|
||||||
fetchDocumentModels();
|
|
||||||
|
|
||||||
function onDropdownSearch(event: { value: string }) {
|
|
||||||
fetchDocumentModels(event.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function validate(): boolean {
|
|
||||||
let valid = true;
|
|
||||||
errors.document_model_id = form.document_model_id ? '' : 'Modelo es requerido';
|
|
||||||
errors.name = form.name ? '' : 'Nombre es requerido';
|
|
||||||
errors.initial_number = form.initial_number >= 0 ? '' : 'Número inicial inválido';
|
|
||||||
// 'serie' is optional
|
|
||||||
valid = !errors.document_model_id && !errors.name && !errors.initial_number;
|
|
||||||
return valid;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSubmit() {
|
|
||||||
// Bloquear si no hay permiso de edición (crear o actualizar)
|
|
||||||
if (!props.canEdit) return;
|
|
||||||
if (!validate()) return;
|
|
||||||
emit('submit', { ...form });
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<form @submit.prevent="handleSubmit" novalidate>
|
|
||||||
<div class="field mb-4">
|
|
||||||
<label class="block text-sm font-semibold mb-1">Modelo</label>
|
|
||||||
<Dropdown v-model="form.document_model_id"
|
|
||||||
:options="documentModels"
|
|
||||||
optionLabel="name"
|
|
||||||
optionValue="id"
|
|
||||||
filter
|
|
||||||
:placeholder="'Selecciona documento modelo'"
|
|
||||||
:loading="loadingModels"
|
|
||||||
:showClear="true"
|
|
||||||
@filter="onDropdownSearch"
|
|
||||||
class="w-full" />
|
|
||||||
<small v-if="errors.document_model_id" class="p-error">{{ errors.document_model_id }}</small>
|
|
||||||
</div>
|
|
||||||
<div class="field mb-4">
|
|
||||||
<label class="block text-sm font-semibold mb-1">Nombre</label>
|
|
||||||
<InputText v-model="form.name" inputId="name" class="w-full" />
|
|
||||||
<small v-if="errors.name" class="p-error">{{ errors.name }}</small>
|
|
||||||
</div>
|
|
||||||
<div class="field mb-4">
|
|
||||||
<label class="block text-sm font-semibold mb-1">Serie <span class="text-xs text-slate-400">(opcional)</span></label>
|
|
||||||
<InputText v-model="form.serie" inputId="serie" class="w-full" />
|
|
||||||
</div>
|
|
||||||
<div class="field mb-4">
|
|
||||||
<label class="block text-sm font-semibold mb-1">Número Inicial</label>
|
|
||||||
<InputNumber v-model="form.initial_number" inputId="initial-number" class="w-full" :min="0" />
|
|
||||||
<small v-if="errors.initial_number" class="p-error">{{ errors.initial_number }}</small>
|
|
||||||
</div>
|
|
||||||
<div class="form-actions flex justify-end gap-2 mt-5">
|
|
||||||
<Button type="button" label="Cancelar" outlined @click="emit('cancel')" />
|
|
||||||
<Button type="submit" label="Guardar" :disabled="!props.canEdit" />
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.field { display: flex; flex-direction: column; gap: 0.25rem; }
|
|
||||||
.p-error { color: #ef4444; font-size: 12px; }
|
|
||||||
</style>
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { DocumentConcept } from './document-concepts.interface';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
items: DocumentConcept[];
|
|
||||||
loading?: boolean;
|
|
||||||
canUpdate?: boolean;
|
|
||||||
canDelete?: boolean;
|
|
||||||
}
|
|
||||||
const props = withDefaults(defineProps<Props>(), { loading: false, canUpdate: false, canDelete: false });
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'edit', item: DocumentConcept): void;
|
|
||||||
(e: 'delete', id: number): void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
function onEdit(item: DocumentConcept) {
|
|
||||||
emit('edit', item);
|
|
||||||
}
|
|
||||||
function onDelete(id: number) {
|
|
||||||
emit('delete', id);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<DataTable :value="props.items" :loading="props.loading" striped-rows responsive-layout="scroll" class="min-w-full">
|
|
||||||
<Column field="id" header="ID" :sortable="true">
|
|
||||||
<template #body="{ data }">
|
|
||||||
<span class="text-xs font-mono font-bold text-slate-400">{{ data.id }}</span>
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
<Column field="name" header="Concept Name" :sortable="true">
|
|
||||||
<template #body="{ data }">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="w-8 h-8 rounded bg-blue-50 flex items-center justify-center">
|
|
||||||
<span class="pi pi-book text-blue-600 text-lg"></span>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm font-semibold text-on-surface">{{ data.name }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
<Column field="document_model_id" header="Model ID" :sortable="true">
|
|
||||||
<template #body="{ data }">
|
|
||||||
<span class="inline-flex items-center px-2 py-1 rounded bg-slate-100 text-slate-600 text-[10px] font-bold">
|
|
||||||
MOD-{{ data.document_model_id }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
<Column field="serie" header="Serie">
|
|
||||||
<template #body="{ data }">
|
|
||||||
<span class="text-sm font-medium text-primary">{{ data.serie || '-' }}</span>
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
<Column field="initial_number" header="Initial No.">
|
|
||||||
<template #body="{ data }">
|
|
||||||
<span class="text-sm font-mono text-on-surface-variant">{{ data.initial_number }}</span>
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
<Column header="Status">
|
|
||||||
<template #body="{ data }">
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-secondary-container text-on-secondary-container text-[11px] font-bold"
|
|
||||||
:class="{
|
|
||||||
'bg-secondary-container text-on-secondary-container': !data.deleted_at,
|
|
||||||
'bg-slate-200 text-slate-500': data.deleted_at,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<span class="w-1.5 h-1.5 rounded-full" :class="data.deleted_at ? 'bg-slate-400' : 'bg-blue-600 animate-pulse'"></span>
|
|
||||||
{{ data.deleted_at ? 'INACTIVE' : 'ACTIVE' }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
<Column header="Actions" body-class="text-right">
|
|
||||||
<template #body="{ data }">
|
|
||||||
<div class="flex items-center justify-end gap-1">
|
|
||||||
<Button icon="pi pi-pencil" text @click="props.canUpdate ? onEdit(data) : null" :disabled="!props.canUpdate" title="Editar" />
|
|
||||||
<Button icon="pi pi-trash" text severity="danger" @click="props.canDelete ? onDelete(data.id) : null" :disabled="!props.canDelete" title="Eliminar" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
</DataTable>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.min-w-full { min-width: 100%; }
|
|
||||||
</style>
|
|
||||||
@ -1,290 +0,0 @@
|
|||||||
<!--
|
|
||||||
Auto-generado desde el HTML fuente y adaptado con la skill opencode-vue-component
|
|
||||||
Ver convenciones en rules.md, AGENTS.md y SKILL.
|
|
||||||
-->
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted, computed } from 'vue';
|
|
||||||
import { useAuth } from '@/modules/auth/composables/useAuth';
|
|
||||||
import { useToast } from 'primevue/usetoast';
|
|
||||||
import TabView from 'primevue/tabview';
|
|
||||||
import TabPanel from 'primevue/tabpanel';
|
|
||||||
import DataTable from 'primevue/datatable';
|
|
||||||
import Column from 'primevue/column';
|
|
||||||
import Dialog from 'primevue/dialog';
|
|
||||||
import Select from 'primevue/select';
|
|
||||||
import ProgressSpinner from 'primevue/progressspinner';
|
|
||||||
import Button from 'primevue/button';
|
|
||||||
import { DocumentConceptConvertiblesService } from './document-concept-convertibles.services';
|
|
||||||
import type { DocumentConcept, CreateDocumentConceptDTO, UpdateDocumentConceptDTO } from './document-concepts.interface';
|
|
||||||
import { DocumentConceptsService } from './document-concepts.services';
|
|
||||||
import DocumentConceptsTable from './DocumentConceptsTable.vue';
|
|
||||||
import DocumentConceptsForm from './DocumentConceptsForm.vue';
|
|
||||||
|
|
||||||
const conceptsService = new DocumentConceptsService();
|
|
||||||
const toast = useToast();
|
|
||||||
|
|
||||||
// Permisos de usuario
|
|
||||||
const { hasPermission } = useAuth();
|
|
||||||
const canCreate = computed(() => hasPermission('document_concepts.store'));
|
|
||||||
const canUpdate = computed(() => hasPermission('document_concepts.update'));
|
|
||||||
const canDelete = computed(() => hasPermission('document_concepts.destroy'));
|
|
||||||
|
|
||||||
const items = ref<DocumentConcept[]>([]);
|
|
||||||
const loading = ref(false);
|
|
||||||
const showDialog = ref(false);
|
|
||||||
const selected = ref<DocumentConcept | null>(null);
|
|
||||||
|
|
||||||
// --- Convertibles Logic ---
|
|
||||||
const conceptCandidates = ref<DocumentConcept[]>([]); // todas menos la seleccionada
|
|
||||||
const selectedConvertibleIds = ref<number[]>([]); // ids actualmente asignados
|
|
||||||
const convertiblesLoading = ref(false);
|
|
||||||
const convertiblesSaving = ref(false);
|
|
||||||
const convertiblesService = new DocumentConceptConvertiblesService();
|
|
||||||
|
|
||||||
// CRUD para tabla
|
|
||||||
const convertibleConcepts = computed(() =>
|
|
||||||
conceptCandidates.value.filter(c => selectedConvertibleIds.value.includes(c.id))
|
|
||||||
);
|
|
||||||
|
|
||||||
const showAddConvertible = ref(false);
|
|
||||||
const newConvertibleId = ref<number|null>(null);
|
|
||||||
const availableForAdd = computed(() =>
|
|
||||||
conceptCandidates.value.filter(c => !selectedConvertibleIds.value.includes(c.id))
|
|
||||||
);
|
|
||||||
|
|
||||||
function resetAddConvertible() {
|
|
||||||
newConvertibleId.value = null;
|
|
||||||
showAddConvertible.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addConvertible() {
|
|
||||||
if (!selected.value?.id || !newConvertibleId.value) return;
|
|
||||||
convertiblesSaving.value = true;
|
|
||||||
try {
|
|
||||||
const nuevos = [...selectedConvertibleIds.value, newConvertibleId.value];
|
|
||||||
await convertiblesService.assignConvertibles(selected.value.id, { target_ids: nuevos });
|
|
||||||
selectedConvertibleIds.value = nuevos;
|
|
||||||
toast.add({ severity: 'success', summary: 'Convertibles actualizados', detail: 'Se agregó el convertible.', life: 3000 });
|
|
||||||
resetAddConvertible();
|
|
||||||
} catch (err) {
|
|
||||||
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudo agregar el convertible.', life: 4000 });
|
|
||||||
} finally {
|
|
||||||
convertiblesSaving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeConvertible(id: number) {
|
|
||||||
if (!selected.value?.id) return;
|
|
||||||
convertiblesSaving.value = true;
|
|
||||||
try {
|
|
||||||
const nuevos = selectedConvertibleIds.value.filter(cid => cid !== id);
|
|
||||||
await convertiblesService.assignConvertibles(selected.value.id, { target_ids: nuevos });
|
|
||||||
selectedConvertibleIds.value = nuevos;
|
|
||||||
toast.add({ severity: 'success', summary: 'Convertibles actualizados', detail: 'Convertible eliminado.', life: 3000 });
|
|
||||||
} catch (err) {
|
|
||||||
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudo eliminar el convertible.', life: 4000 });
|
|
||||||
} finally {
|
|
||||||
convertiblesSaving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchConvertiblesInfo() {
|
|
||||||
if (!selected.value?.id) return;
|
|
||||||
convertiblesLoading.value = true;
|
|
||||||
try {
|
|
||||||
// Fetch all document concepts (candidates for convertibles)
|
|
||||||
// Exclude the selected concept itself
|
|
||||||
const response = await conceptsService.getDocumentConcepts();
|
|
||||||
conceptCandidates.value = (response.data || []).filter(x => x.id !== selected.value!.id);
|
|
||||||
|
|
||||||
// Fetch assigned convertibles for this concept
|
|
||||||
const convertiblesResp = await convertiblesService.getConvertibles(selected.value.id);
|
|
||||||
selectedConvertibleIds.value = (convertiblesResp.data || []).map(x => x.id);
|
|
||||||
} catch (err) {
|
|
||||||
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudieron cargar los convertibles.', life: 4000 });
|
|
||||||
} finally {
|
|
||||||
convertiblesLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const filterName = ref<string>('');
|
|
||||||
const filterModelId = ref<number|null>(null);
|
|
||||||
|
|
||||||
async function fetchItems() {
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const response = await conceptsService.getDocumentConcepts({
|
|
||||||
name: filterName.value || undefined,
|
|
||||||
document_model_id: filterModelId.value || undefined,
|
|
||||||
});
|
|
||||||
items.value = response.data;
|
|
||||||
} catch {
|
|
||||||
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudieron cargar los conceptos', life: 3000 });
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onMounted(fetchItems);
|
|
||||||
|
|
||||||
function openCreate() {
|
|
||||||
if (!canCreate.value) {
|
|
||||||
toast.add({ severity: 'warn', summary: 'Sin permiso', detail: 'No tienes permiso para crear conceptos', life: 2500 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
selected.value = null;
|
|
||||||
showDialog.value = true;
|
|
||||||
}
|
|
||||||
function openEdit(item: DocumentConcept) {
|
|
||||||
if (!canUpdate.value) {
|
|
||||||
toast.add({ severity: 'warn', summary: 'Sin permiso', detail: 'No tienes permiso para editar conceptos', life: 2500 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
selected.value = { ...item };
|
|
||||||
showDialog.value = true;
|
|
||||||
// When editing is triggered, fetch convertibles information
|
|
||||||
fetchConvertiblesInfo();
|
|
||||||
}
|
|
||||||
async function handleDelete(id: number) {
|
|
||||||
if (!canDelete.value) {
|
|
||||||
toast.add({ severity: 'warn', summary: 'Sin permiso', detail: 'No tienes permiso para eliminar conceptos', life: 2500 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await conceptsService.deleteDocumentConcept(id);
|
|
||||||
toast.add({ severity: 'success', summary: 'Eliminado', detail: 'Concepto eliminado', life: 3000 });
|
|
||||||
await fetchItems();
|
|
||||||
} catch {
|
|
||||||
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudo eliminar', life: 3000 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function handleSubmit(data: CreateDocumentConceptDTO | UpdateDocumentConceptDTO) {
|
|
||||||
// Permiso requerido según si es update o store
|
|
||||||
const needsUpdate = !!selected.value?.id;
|
|
||||||
if ((needsUpdate && !canUpdate.value) || (!needsUpdate && !canCreate.value)) {
|
|
||||||
toast.add({ severity: 'warn', summary: 'Sin permiso', detail: 'No tienes permiso para guardar conceptos', life: 2500 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (selected.value?.id) {
|
|
||||||
await conceptsService.updateDocumentConcept(selected.value.id, data as UpdateDocumentConceptDTO);
|
|
||||||
toast.add({ severity: 'success', summary: 'Actualizado', detail: 'Concepto actualizado', life: 3000 });
|
|
||||||
} else {
|
|
||||||
await conceptsService.createDocumentConcept(data as CreateDocumentConceptDTO);
|
|
||||||
toast.add({ severity: 'success', summary: 'Creado', detail: 'Concepto creado', life: 3000 });
|
|
||||||
}
|
|
||||||
showDialog.value = false;
|
|
||||||
await fetchItems();
|
|
||||||
} catch {
|
|
||||||
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudo guardar', life: 3000 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="document-concepts-view p-4">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6 mb-10">
|
|
||||||
<div>
|
|
||||||
<nav class="flex items-center gap-2 text-xs font-medium text-slate-400 mb-2">
|
|
||||||
<span>Admin</span>
|
|
||||||
<span class="pi pi-chevron-right"></span>
|
|
||||||
<span>Documentación</span>
|
|
||||||
<span class="pi pi-chevron-right"></span>
|
|
||||||
<span class="text-primary-container">Conceptos</span>
|
|
||||||
</nav>
|
|
||||||
<h2 class="text-3xl font-extrabold text-on-surface tracking-tight">Gestión de Conceptos</h2>
|
|
||||||
<p class="text-on-surface-variant mt-1 text-sm font-medium">
|
|
||||||
Configure and organize document types for precise ERP automation.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button label="Nuevo Concepto" icon="pi pi-plus" @click="openCreate" :disabled="!canCreate" class="px-6 py-3 font-bold rounded-lg shadow-lg text-sm" v-if="canCreate" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filter/Search Bar -->
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-4 mb-6">
|
|
||||||
<div class="lg:col-span-8 flex flex-wrap gap-3">
|
|
||||||
<span class="pi pi-filter absolute left-4 top-1/2 -translate-y-1/2 text-slate-400"></span>
|
|
||||||
<InputText v-model="filterName" placeholder="Filter by Name or Serie (e.g. E-2026)..." class="w-full pl-12 pr-4 py-3 bg-surface-container-lowest border-b text-sm rounded-t-lg" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Data Table -->
|
|
||||||
<div class="bg-surface-container-lowest rounded-xl overflow-hidden shadow-sm">
|
|
||||||
<DocumentConceptsTable
|
|
||||||
:items="items"
|
|
||||||
:loading="loading"
|
|
||||||
@edit="openEdit"
|
|
||||||
@delete="handleDelete"
|
|
||||||
:can-update="canUpdate"
|
|
||||||
:can-delete="canDelete"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Dialog for Create/Edit -->
|
|
||||||
<Dialog v-model:visible="showDialog" :header="selected ? 'Editar Concepto' : 'Nuevo Concepto'" modal :style="{ width: '980px', maxWidth: '98vw' }" dismissableMask :closable="true" :draggable="false" >
|
|
||||||
<template v-if="selected">
|
|
||||||
<TabView>
|
|
||||||
<TabPanel header="Formulario" value="form">
|
|
||||||
<DocumentConceptsForm
|
|
||||||
:model-value="selected"
|
|
||||||
:can-edit="canUpdate"
|
|
||||||
:can-create="canCreate"
|
|
||||||
:can-update="canUpdate"
|
|
||||||
@submit="handleSubmit"
|
|
||||||
@cancel="() => showDialog = false"
|
|
||||||
/>
|
|
||||||
</TabPanel>
|
|
||||||
<TabPanel header="Convertibles" value="convertibles">
|
|
||||||
<div class="p-6 flex flex-col gap-6 min-h-[300px]">
|
|
||||||
<template v-if="convertiblesLoading">
|
|
||||||
<ProgressSpinner />
|
|
||||||
<div class="text-on-surface-variant text-center mt-2">Cargando convertibles...</div>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<DataTable :value="convertibleConcepts" :loading="convertiblesLoading" class="w-full" striped-rows size="small" tableStyle="min-width: 40rem">
|
|
||||||
<template #header>
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<span class="font-bold text-lg">Conceptos convertibles actuales</span>
|
|
||||||
<Button label="Agregar convertible" icon="pi pi-plus" @click="showAddConvertible = true" :disabled="!canUpdate || convertiblesSaving"/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<Column field="name" header="Concepto convertible" />
|
|
||||||
<Column header="Acciones">
|
|
||||||
<template #body="{ data }">
|
|
||||||
<Button icon="pi pi-trash" severity="danger" text rounded class="p-button-sm" v-tooltip.top="'Eliminar convertible'" @click="removeConvertible(data.id)" :disabled="!canUpdate || convertiblesSaving"/>
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
<template #empty>
|
|
||||||
<div class="text-center text-on-surface-variant">No hay conceptos convertibles asignados.</div>
|
|
||||||
</template>
|
|
||||||
</DataTable>
|
|
||||||
|
|
||||||
<!-- Dialog para agregar nuevo convertible -->
|
|
||||||
<Dialog v-model:visible="showAddConvertible" header="Agregar convertible" modal :style="{ width: '400px' }" :closable="true">
|
|
||||||
<div class="mb-4">
|
|
||||||
<Select v-model="newConvertibleId" :options="availableForAdd" optionLabel="name" optionValue="id" filter placeholder="Seleccione un concepto..." class="w-full" />
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-3 justify-end">
|
|
||||||
<Button label="Agregar" icon="pi pi-check" @click="addConvertible" :loading="convertiblesSaving" :disabled="!newConvertibleId || convertiblesSaving || !canUpdate"/>
|
|
||||||
<Button label="Cancelar" icon="pi pi-times" @click="showAddConvertible = false" class="p-button-text"/>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</TabPanel>
|
|
||||||
</TabView>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<DocumentConceptsForm
|
|
||||||
:model-value="selected"
|
|
||||||
:can-edit="canCreate"
|
|
||||||
:can-create="canCreate"
|
|
||||||
:can-update="canUpdate"
|
|
||||||
@submit="handleSubmit"
|
|
||||||
@cancel="() => showDialog = false"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
// document-concept-convertibles.interface.ts
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convertible Document Concept (response item for GET/POST convertibles)
|
|
||||||
*/
|
|
||||||
export interface ConvertibleDocumentConcept {
|
|
||||||
id: number;
|
|
||||||
tenant_id: number;
|
|
||||||
document_model_id: number;
|
|
||||||
name: string;
|
|
||||||
folder_path: string;
|
|
||||||
serie: string;
|
|
||||||
initial_number: number;
|
|
||||||
created_at: string; // ISO 8601
|
|
||||||
updated_at: string; // ISO 8601
|
|
||||||
deleted_at: string | null;
|
|
||||||
pivot: ConvertiblePivot;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConvertiblePivot {
|
|
||||||
concept_id: number;
|
|
||||||
target_concept_id: number;
|
|
||||||
tenant_id: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET/POST response for convertibles
|
|
||||||
*/
|
|
||||||
export interface ConvertibleDocumentConceptsResponse {
|
|
||||||
data: ConvertibleDocumentConcept[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST body for assigning convertibles
|
|
||||||
*/
|
|
||||||
export interface AssignConvertiblesBody {
|
|
||||||
target_ids: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Documentation
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* GET /api/document-concepts/{concept_id}/convertibles
|
|
||||||
* Busca todos los conceptos de documento convertibles para el concepto dado
|
|
||||||
* Response: { data: ConvertibleDocumentConcept[] }
|
|
||||||
*
|
|
||||||
* POST /api/document-concepts/{concept_id}/convertibles
|
|
||||||
* Asigna los destinos convertibles para el concepto dado
|
|
||||||
* Body: { target_ids: number[] }
|
|
||||||
* Response: { data: ConvertibleDocumentConcept[] }
|
|
||||||
*/
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
import api from "@/services/api";
|
|
||||||
import type {
|
|
||||||
ConvertibleDocumentConceptsResponse,
|
|
||||||
AssignConvertiblesBody
|
|
||||||
} from "./document-concept-convertibles.interface";
|
|
||||||
|
|
||||||
export class DocumentConceptConvertiblesService {
|
|
||||||
/**
|
|
||||||
* Obtiene los conceptos de documento convertibles para el concepto dado
|
|
||||||
* @param conceptId (ID del concepto base)
|
|
||||||
* @returns ConvertibleDocumentConceptsResponse
|
|
||||||
*/
|
|
||||||
public async getConvertibles(conceptId: number): Promise<ConvertibleDocumentConceptsResponse> {
|
|
||||||
try {
|
|
||||||
const response = await api.get(`/api/document-concepts/${conceptId}/convertibles`);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching convertibles:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Asigna / sobreescribe los destinos convertibles
|
|
||||||
* @param conceptId (ID del concepto base)
|
|
||||||
* @param body { target_ids: number[] }
|
|
||||||
* @returns ConvertibleDocumentConceptsResponse
|
|
||||||
*/
|
|
||||||
public async assignConvertibles(conceptId: number, body: AssignConvertiblesBody): Promise<ConvertibleDocumentConceptsResponse> {
|
|
||||||
try {
|
|
||||||
const response = await api.post(`/api/document-concepts/${conceptId}/convertibles`, body);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error assigning convertibles:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
// document-concepts.interface.ts
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Entity: DocumentConcept
|
|
||||||
* Representa un concepto documental gestionado en el sistema.
|
|
||||||
*/
|
|
||||||
export interface DocumentConcept {
|
|
||||||
id: number;
|
|
||||||
tenant_id: number;
|
|
||||||
document_model_id: number;
|
|
||||||
name: string;
|
|
||||||
folder_path: string; // Generado automáticamente por el backend
|
|
||||||
serie?: string | null; // Opcional
|
|
||||||
initial_number: number;
|
|
||||||
created_at: string; // ISO string
|
|
||||||
updated_at: string; // ISO string
|
|
||||||
deleted_at: string | null; // Puede ser null (no borrado)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DTO para creación de un concepto
|
|
||||||
*/
|
|
||||||
export interface CreateDocumentConceptDTO {
|
|
||||||
document_model_id: number;
|
|
||||||
name: string;
|
|
||||||
serie?: string | null;
|
|
||||||
initial_number: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DTO para actualización parcial de un concepto
|
|
||||||
* Todos los campos son opcionales para permitir updates parciales.
|
|
||||||
*/
|
|
||||||
export interface UpdateDocumentConceptDTO {
|
|
||||||
document_model_id?: number;
|
|
||||||
name?: string;
|
|
||||||
serie?: string | null;
|
|
||||||
initial_number?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Respuesta de la API para listado de conceptos
|
|
||||||
*/
|
|
||||||
export interface ResponseDocumentConceptDTO {
|
|
||||||
data: DocumentConcept[];
|
|
||||||
message?: string;
|
|
||||||
success?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Respuesta de la API para una sola entidad de concepto (por id o creación)
|
|
||||||
*/
|
|
||||||
export interface SingleDocumentConceptResponseDTO {
|
|
||||||
id: number;
|
|
||||||
tenant_id: number;
|
|
||||||
document_model_id: number;
|
|
||||||
name: string;
|
|
||||||
folder_path: string;
|
|
||||||
serie?: string | null;
|
|
||||||
initial_number: number;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
deleted_at: string | null;
|
|
||||||
}
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
// document-concepts.services.ts
|
|
||||||
import api from '@/services/api';
|
|
||||||
import type {
|
|
||||||
CreateDocumentConceptDTO,
|
|
||||||
ResponseDocumentConceptDTO,
|
|
||||||
SingleDocumentConceptResponseDTO
|
|
||||||
} from './document-concepts.interface';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Servicio para gestión de conceptos documentales.
|
|
||||||
*/
|
|
||||||
export class DocumentConceptsService {
|
|
||||||
/**
|
|
||||||
* Obtiene la lista de conceptos documentales, con filtros opcionales.
|
|
||||||
* @param filters name (string, opcional), document_model_id (number, opcional)
|
|
||||||
*/
|
|
||||||
public async getDocumentConcepts(filters?: { name?: string; document_model_id?: number }): Promise<ResponseDocumentConceptDTO> {
|
|
||||||
try {
|
|
||||||
const response = await api.get('/api/document-concepts', { params: filters });
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching document concepts:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Crea un nuevo concepto documental.
|
|
||||||
* @param data Payload con los valores requeridos.
|
|
||||||
*/
|
|
||||||
public async createDocumentConcept(data: CreateDocumentConceptDTO): Promise<SingleDocumentConceptResponseDTO> {
|
|
||||||
try {
|
|
||||||
const response = await api.post('/api/document-concepts', data);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating document concept:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Consulta un concepto documental por su id.
|
|
||||||
* @param id Identificador único del concepto
|
|
||||||
*/
|
|
||||||
public async getDocumentConceptById(id: number): Promise<SingleDocumentConceptResponseDTO> {
|
|
||||||
try {
|
|
||||||
const response = await api.get(`/api/document-concepts/${id}`);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching document concept by id:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Actualiza parcialmente un concepto documental (PATCH /document-concepts/:id)
|
|
||||||
*/
|
|
||||||
public async updateDocumentConcept(id: number, data: Partial<CreateDocumentConceptDTO>): Promise<SingleDocumentConceptResponseDTO> {
|
|
||||||
try {
|
|
||||||
const response = await api.patch(`/api/document-concepts/${id}`, data);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating document concept:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Elimina un concepto documental por su id (DELETE /document-concepts/:id)
|
|
||||||
*/
|
|
||||||
public async deleteDocumentConcept(id: number): Promise<void> {
|
|
||||||
try {
|
|
||||||
await api.delete(`/api/document-concepts/${id}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting document concept:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
// document-models.interface.ts
|
|
||||||
|
|
||||||
export interface DocumentModel {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
module: number;
|
|
||||||
type: number;
|
|
||||||
num_folio: number;
|
|
||||||
created_at: string; // ISO date
|
|
||||||
updated_at: string; // ISO date
|
|
||||||
deleted_at: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateDocumentModelDTO {
|
|
||||||
name: string;
|
|
||||||
module: number;
|
|
||||||
type: number;
|
|
||||||
num_folio?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateDocumentModelDTO {
|
|
||||||
name?: string;
|
|
||||||
module?: number;
|
|
||||||
type?: number;
|
|
||||||
num_folio?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaginatedDocumentModelsResponse {
|
|
||||||
current_page: number;
|
|
||||||
data: DocumentModel[];
|
|
||||||
first_page_url: string;
|
|
||||||
from: number;
|
|
||||||
last_page: number;
|
|
||||||
last_page_url: string;
|
|
||||||
next_page_url: string | null;
|
|
||||||
path: string;
|
|
||||||
per_page: number;
|
|
||||||
prev_page_url: string | null;
|
|
||||||
to: number;
|
|
||||||
total: number;
|
|
||||||
links: PaginationLink[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaginationLink {
|
|
||||||
url: string | null;
|
|
||||||
label: string;
|
|
||||||
active: boolean;
|
|
||||||
}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
import api from "@/services/api";
|
|
||||||
import type {
|
|
||||||
DocumentModel,
|
|
||||||
PaginatedDocumentModelsResponse
|
|
||||||
} from "./document-models.interface";
|
|
||||||
|
|
||||||
export class DocumentModelsService {
|
|
||||||
/**
|
|
||||||
* Obtiene modelos de documento, con paginación o sin ella.
|
|
||||||
* @param paginate default: true. Si es false, devuelve DocumentModel[] directamente;
|
|
||||||
* @param per_paginate default: 15. Si se envía, cambia el page size.
|
|
||||||
*/
|
|
||||||
public async getDocumentModels(opts?: {
|
|
||||||
paginate?: boolean;
|
|
||||||
per_paginate?: number;
|
|
||||||
search?: string;
|
|
||||||
[key: string]: any;
|
|
||||||
}): Promise<PaginatedDocumentModelsResponse | DocumentModel[]> {
|
|
||||||
try {
|
|
||||||
// Construye params siempre
|
|
||||||
const params: Record<string, any> = {
|
|
||||||
paginate: opts?.paginate,
|
|
||||||
per_paginate: opts?.per_paginate,
|
|
||||||
...opts
|
|
||||||
};
|
|
||||||
// Evita undefined
|
|
||||||
if (params.paginate === undefined) delete params.paginate;
|
|
||||||
if (params.per_paginate === undefined) delete params.per_paginate;
|
|
||||||
const response = await api.get("/api/catalogs/document-models", { params });
|
|
||||||
if (opts?.paginate === false) {
|
|
||||||
return response.data.data; // Solo el array de modelos
|
|
||||||
} else {
|
|
||||||
return response.data as PaginatedDocumentModelsResponse;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching document models:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -201,16 +201,6 @@ const routes: RouteRecordRaw[] = [
|
|||||||
requiresAuth: true
|
requiresAuth: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'document-concepts',
|
|
||||||
name: 'DocumentConcepts',
|
|
||||||
component: () => import('@/modules/catalog/components/document-concepts/DocumentConceptsView.vue'),
|
|
||||||
meta: {
|
|
||||||
title: 'Conceptos de Documentos',
|
|
||||||
requiresAuth: true,
|
|
||||||
permission: 'document_concepts.index'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
companiesRouter
|
companiesRouter
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user