CTL-51-DEVELOP #21

Merged
edgar.mendez merged 2 commits from CTL-51-DEVELOP into qa 2026-03-26 04:41:36 +00:00
12 changed files with 843 additions and 0 deletions

View File

@ -62,6 +62,12 @@ 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'
} }
] ]
}, },

View File

@ -6,6 +6,7 @@ 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";
@ -48,6 +49,7 @@ 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();

View File

@ -0,0 +1,127 @@
<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>

View File

@ -0,0 +1,86 @@
<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>

View File

@ -0,0 +1,290 @@
<!--
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>

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;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -204,6 +204,16 @@ 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
] ]
}, },