CTL-51-DEVELOP #21

Merged
edgar.mendez merged 2 commits from CTL-51-DEVELOP into qa 2026-03-26 04:41:36 +00:00
4 changed files with 240 additions and 8 deletions
Showing only changes of commit bf8c55c354 - Show all commits

View File

@ -6,6 +6,7 @@ import PrimeVue from "primevue/config";
import ConfirmationService from 'primevue/confirmationservice';
import ToastService from 'primevue/toastservice';
import StyleClass from "primevue/styleclass";
import Tooltip from "primevue/tooltip";
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
@ -48,6 +49,7 @@ app.use(PrimeVue, {
});
app.directive("styleclass", StyleClass);
app.directive("tooltip", Tooltip);
const bootstrap = async () => {
const { initAuth } = useAuth();

View File

@ -6,6 +6,15 @@ Ver convenciones en rules.md, AGENTS.md y SKILL.
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';
@ -24,6 +33,81 @@ 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);
@ -58,6 +142,8 @@ function openEdit(item: DocumentConcept) {
}
selected.value = { ...item };
showDialog.value = true;
// When editing is triggered, fetch convertibles information
fetchConvertiblesInfo();
}
async function handleDelete(id: number) {
if (!canDelete.value) {
@ -137,14 +223,68 @@ async function handleSubmit(data: CreateDocumentConceptDTO | UpdateDocumentConce
<!-- 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" >
<DocumentConceptsForm
:model-value="selected"
:can-edit="selected ? canUpdate : canCreate"
:can-create="canCreate"
:can-update="canUpdate"
@submit="handleSubmit"
@cancel="() => showDialog = 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>

View File

@ -0,0 +1,52 @@
// 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[] }
*/

View File

@ -0,0 +1,38 @@
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;
}
}
}