feat: implement catalog modules for routes, risk factors, and route segments with supporting services and UI components.
This commit is contained in:
parent
7e58077dd0
commit
ab04091306
@ -81,6 +81,42 @@ const menuItems = ref<MenuItem[]>([
|
|||||||
'locations.destroy',
|
'locations.destroy',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Segmentos de Ruta',
|
||||||
|
icon: 'pi pi-map',
|
||||||
|
to: '/catalog/route-segments',
|
||||||
|
permission: [
|
||||||
|
'route-segments.index',
|
||||||
|
'route-segments.show',
|
||||||
|
'route-segments.store',
|
||||||
|
'route-segments.update',
|
||||||
|
'route-segments.destroy',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Catálogo de Rutas',
|
||||||
|
icon: 'pi pi-sitemap',
|
||||||
|
to: '/catalog/routes',
|
||||||
|
permission: [
|
||||||
|
'routes.index',
|
||||||
|
'routes.show',
|
||||||
|
'routes.store',
|
||||||
|
'routes.update',
|
||||||
|
'routes.destroy',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Factores de Riesgo',
|
||||||
|
icon: 'pi pi-exclamation-triangle',
|
||||||
|
to: '/catalog/risk-factors',
|
||||||
|
permission: [
|
||||||
|
'risk-factors.index',
|
||||||
|
'risk-factors.show',
|
||||||
|
'risk-factors.store',
|
||||||
|
'risk-factors.update',
|
||||||
|
'risk-factors.destroy',
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
|
||||||
import mapboxgl from 'mapbox-gl';
|
import mapboxgl from 'mapbox-gl';
|
||||||
import MapboxDraw from '@mapbox/mapbox-gl-draw';
|
import MapboxDraw from '@mapbox/mapbox-gl-draw';
|
||||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||||
@ -11,7 +11,8 @@ import InputText from 'primevue/inputtext';
|
|||||||
import Button from 'primevue/button';
|
import Button from 'primevue/button';
|
||||||
import Textarea from 'primevue/textarea';
|
import Textarea from 'primevue/textarea';
|
||||||
|
|
||||||
import type { LocationCreateRequest, LocationFormErrors, LocationGeofenceGeoJSON } from '../../types/locations.interfaces';
|
import { locationServices } from '../../services/location.services';
|
||||||
|
import type { LocationCreateRequest, LocationFormErrors, LocationGeofenceGeoJSON, Location } from '../../types/locations.interfaces';
|
||||||
|
|
||||||
type GeofenceFeature = Feature<Polygon, GeoJsonProperties>;
|
type GeofenceFeature = Feature<Polygon, GeoJsonProperties>;
|
||||||
|
|
||||||
@ -31,6 +32,7 @@ type FormData = {
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
initialData?: Partial<LocationCreateRequest> & {
|
initialData?: Partial<LocationCreateRequest> & {
|
||||||
|
id?: number;
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
address?: string | null;
|
address?: string | null;
|
||||||
@ -53,11 +55,14 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const mapToken = import.meta.env.VITE_MAPBOX_TOKEN as string | undefined;
|
const mapToken = import.meta.env.VITE_MAPBOX_TOKEN as string | undefined;
|
||||||
const mapContainer = ref<HTMLElement | null>(null);
|
const mapContainer = ref<HTMLElement | null>(null);
|
||||||
const mapRef = ref<any>(null);
|
const mapRef = shallowRef<any>(null);
|
||||||
const markerRef = ref<any>(null);
|
const markerRef = shallowRef<any>(null);
|
||||||
const drawRef = ref<MapboxDraw | null>(null);
|
const drawRef = shallowRef<MapboxDraw | null>(null);
|
||||||
const mapReady = ref(false);
|
const mapReady = ref(false);
|
||||||
|
|
||||||
|
const locations = ref<Location[]>([]);
|
||||||
|
const locationMarkersOnMap = shallowRef<mapboxgl.Marker[]>([]);
|
||||||
|
|
||||||
const form = ref<FormData>({
|
const form = ref<FormData>({
|
||||||
name: props.initialData?.name ?? '',
|
name: props.initialData?.name ?? '',
|
||||||
description: props.initialData?.description ?? '',
|
description: props.initialData?.description ?? '',
|
||||||
@ -149,6 +154,36 @@ const parseCoordinates = (value?: string | null): { lat: number; lng: number } |
|
|||||||
return parseHexEwkbPoint(normalized);
|
return parseHexEwkbPoint(normalized);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderLocationMarkers = () => {
|
||||||
|
if (!mapReady.value || !mapRef.value || !locations.value?.length) return;
|
||||||
|
|
||||||
|
locationMarkersOnMap.value.forEach(m => m.remove());
|
||||||
|
locationMarkersOnMap.value = [];
|
||||||
|
|
||||||
|
locations.value.forEach(loc => {
|
||||||
|
if (loc.id === props.initialData?.id) return; // Ignore current if editing
|
||||||
|
|
||||||
|
const locCoords = parseCoordinates(loc.coordinates);
|
||||||
|
if (!locCoords) return;
|
||||||
|
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'existing-location-marker';
|
||||||
|
|
||||||
|
const marker = new mapboxgl.Marker({ element: el })
|
||||||
|
.setLngLat([locCoords.lng, locCoords.lat])
|
||||||
|
.addTo(mapRef.value!);
|
||||||
|
|
||||||
|
if (loc.name) {
|
||||||
|
const popup = new mapboxgl.Popup({ offset: 25 }).setText(loc.name);
|
||||||
|
marker.setPopup(popup);
|
||||||
|
el.addEventListener('mouseenter', () => marker.togglePopup());
|
||||||
|
el.addEventListener('mouseleave', () => marker.togglePopup());
|
||||||
|
}
|
||||||
|
|
||||||
|
locationMarkersOnMap.value.push(marker);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const cloneGeofenceFeature = (feature: GeofenceFeature): GeofenceFeature => {
|
const cloneGeofenceFeature = (feature: GeofenceFeature): GeofenceFeature => {
|
||||||
return JSON.parse(JSON.stringify(feature)) as GeofenceFeature;
|
return JSON.parse(JSON.stringify(feature)) as GeofenceFeature;
|
||||||
};
|
};
|
||||||
@ -595,6 +630,7 @@ const initializeMap = () => {
|
|||||||
|
|
||||||
mapRef.value.on('load', () => {
|
mapRef.value.on('load', () => {
|
||||||
mapReady.value = true;
|
mapReady.value = true;
|
||||||
|
renderLocationMarkers();
|
||||||
if (form.value.geofence) {
|
if (form.value.geofence) {
|
||||||
setDrawGeofence(form.value.geofence);
|
setDrawGeofence(form.value.geofence);
|
||||||
fitMapToGeofence(form.value.geofence);
|
fitMapToGeofence(form.value.geofence);
|
||||||
@ -660,11 +696,23 @@ watch(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
onMounted(() => {
|
const loadLocations = async () => {
|
||||||
|
try {
|
||||||
|
const response = await locationServices.getLocations(false);
|
||||||
|
locations.value = ('data' in response) ? response.data : [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading locations:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadLocations();
|
||||||
initializeMap();
|
initializeMap();
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
locationMarkersOnMap.value.forEach(m => m.remove());
|
||||||
|
locationMarkersOnMap.value = [];
|
||||||
drawRef.value = null;
|
drawRef.value = null;
|
||||||
if (mapRef.value) {
|
if (mapRef.value) {
|
||||||
mapRef.value.remove();
|
mapRef.value.remove();
|
||||||
@ -801,6 +849,22 @@ onBeforeUnmount(() => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(.existing-location-marker) {
|
||||||
|
cursor: pointer;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #3b82f6;
|
||||||
|
border: 2px solid white;
|
||||||
|
box-shadow: 0 0 0 2px white, 0 0 0 4px #3b82f6;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.existing-location-marker:hover) {
|
||||||
|
transform: scale(1.3);
|
||||||
|
box-shadow: 0 0 0 2px white, 0 0 0 5px #3b82f6;
|
||||||
|
}
|
||||||
.lg\:flex-1 :deep(.p-card-content > div) {
|
.lg\:flex-1 :deep(.p-card-content > div) {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
283
src/modules/catalog/components/risk-factors/RiskFactorModal.vue
Normal file
283
src/modules/catalog/components/risk-factors/RiskFactorModal.vue
Normal file
@ -0,0 +1,283 @@
|
|||||||
|
<template>
|
||||||
|
<div class="risk-factor-form">
|
||||||
|
<Card>
|
||||||
|
<template #title>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span>{{ form.id ? (isDuplicating ? 'Duplicar Factor' : 'Editar Factor') : 'Nuevo Factor de Riesgo' }}</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button v-if="form.id && !isDuplicating"
|
||||||
|
label="Duplicar"
|
||||||
|
icon="pi pi-copy"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
@click="handleDuplicate"
|
||||||
|
:loading="loading" />
|
||||||
|
<Button label="Guardar"
|
||||||
|
icon="pi pi-save"
|
||||||
|
@click="handleSave"
|
||||||
|
:loading="loading" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #content>
|
||||||
|
<!-- Formulario Maestro -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label for="name" class="font-semibold">Nombre del Factor</label>
|
||||||
|
<InputText id="name"
|
||||||
|
v-model="form.name"
|
||||||
|
placeholder="Ej. Condiciones Climáticas"
|
||||||
|
:class="{ 'p-invalid': errors.name }" />
|
||||||
|
<small v-if="errors.name" class="p-error">{{ errors.name[0] }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label for="context" class="font-semibold">Contexto de Evaluación</label>
|
||||||
|
<Dropdown id="context"
|
||||||
|
v-model="form.evaluation_context"
|
||||||
|
:options="contextOptions"
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
placeholder="Seleccione Contexto"
|
||||||
|
:class="{ 'p-invalid': errors.evaluation_context }" />
|
||||||
|
<small v-if="errors.evaluation_context" class="p-error">{{ errors.evaluation_context[0] }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 md:col-span-2">
|
||||||
|
<label for="description" class="font-semibold">Descripción (Opcional)</label>
|
||||||
|
<Textarea id="description"
|
||||||
|
v-model="form.description"
|
||||||
|
rows="3"
|
||||||
|
autoResize
|
||||||
|
placeholder="Detalles sobre este factor de riesgo..." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Formulario Detalle (DataTable) -->
|
||||||
|
<div class="mt-8 border-t pt-6">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="text-xl font-bold">Opciones de Evaluación</h3>
|
||||||
|
<Button label="Agregar Opción"
|
||||||
|
icon="pi pi-plus"
|
||||||
|
size="small"
|
||||||
|
severity="success"
|
||||||
|
outlined
|
||||||
|
@click="addOption" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable :value="form.options"
|
||||||
|
editMode="row"
|
||||||
|
v-model:editingRows="editingRows"
|
||||||
|
@row-edit-save="onRowEditSave"
|
||||||
|
dataKey="tempId"
|
||||||
|
responsiveLayout="scroll"
|
||||||
|
class="p-datatable-sm overflow-hidden rounded-lg shadow-sm">
|
||||||
|
|
||||||
|
<Column field="name" header="Nombre de la Opción" style="width: 60%">
|
||||||
|
<template #editor="{ data, field }">
|
||||||
|
<InputText v-model="data[field]" class="w-full" autofocus />
|
||||||
|
</template>
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ data.name || '---' }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="value" header="Valor de Riesgo" style="width: 20%">
|
||||||
|
<template #editor="{ data, field }">
|
||||||
|
<InputNumber v-model="data[field]" :min="0" :max="100" class="w-full" />
|
||||||
|
</template>
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag :value="data.value" :severity="getRiskSeverity(data.value)" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column :rowEditor="true" style="width: 10%; min-width: 8rem" bodyStyle="text-align:center"></Column>
|
||||||
|
|
||||||
|
<Column style="width: 10%; min-width: 4rem" bodyStyle="text-align:center">
|
||||||
|
<template #body="{ index }">
|
||||||
|
<Button icon="pi pi-trash"
|
||||||
|
severity="danger"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
@click="removeOption(index)" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<template #empty>
|
||||||
|
<div class="text-center p-4 italic text-gray-500">
|
||||||
|
No hay opciones definidas. Debe agregar al menos una.
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
<small v-if="errors.options" class="p-error block mt-2 text-center">{{ errors.options[0] }}</small>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue';
|
||||||
|
import { useToast } from 'primevue/usetoast';
|
||||||
|
import Card from 'primevue/card';
|
||||||
|
import InputText from 'primevue/inputtext';
|
||||||
|
import Textarea from 'primevue/textarea';
|
||||||
|
import Dropdown from 'primevue/dropdown';
|
||||||
|
import DataTable from 'primevue/datatable';
|
||||||
|
import Column from 'primevue/column';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import InputNumber from 'primevue/inputnumber';
|
||||||
|
import Tag from 'primevue/tag';
|
||||||
|
import { riskFactorServices } from '../../services/risk-factors.services';
|
||||||
|
import type { RiskFactor, RiskOption, RiskFactorFormErrors } from '../../types/risk-factors.interfaces';
|
||||||
|
|
||||||
|
interface ExtendedRiskOption extends RiskOption {
|
||||||
|
tempId?: number; // Para facilitar el manejo reactivo en la tabla
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
initialData?: RiskFactor;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits(['saved', 'cancel']);
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
const loading = ref(false);
|
||||||
|
const isDuplicating = ref(false);
|
||||||
|
const editingRows = ref([]);
|
||||||
|
const errors = ref<RiskFactorFormErrors>({});
|
||||||
|
|
||||||
|
const contextOptions = [
|
||||||
|
{ label: 'Ruta', value: 1 },
|
||||||
|
{ label: 'Programa', value: 2 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const form = reactive<RiskFactor & { options: ExtendedRiskOption[] }>({
|
||||||
|
id: undefined,
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
evaluation_context: 1,
|
||||||
|
options: []
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.initialData) {
|
||||||
|
Object.assign(form, JSON.parse(JSON.stringify(props.initialData)));
|
||||||
|
// Asegurar que cada opción tenga un tempId para PrimeVue
|
||||||
|
form.options = form.options.map((opt, i) => ({ ...opt, tempId: i }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const addOption = () => {
|
||||||
|
const newOption: ExtendedRiskOption = {
|
||||||
|
name: '',
|
||||||
|
value: 1, // Valor por defecto solicitado
|
||||||
|
tempId: Date.now() + Math.random()
|
||||||
|
};
|
||||||
|
form.options.push(newOption);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeOption = (index: number) => {
|
||||||
|
form.options.splice(index, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRowEditSave = (event: any) => {
|
||||||
|
let { newData, index } = event;
|
||||||
|
form.options[index] = newData;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRiskSeverity = (val: number) => {
|
||||||
|
if (val <= 2) return 'success';
|
||||||
|
if (val <= 5) return 'warning';
|
||||||
|
return 'danger';
|
||||||
|
};
|
||||||
|
|
||||||
|
const validate = (): boolean => {
|
||||||
|
errors.value = {};
|
||||||
|
let isValid = true;
|
||||||
|
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
errors.value.name = ['El nombre es obligatorio'];
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.options.length === 0) {
|
||||||
|
errors.value.options = ['Debe agregar al menos una opción'];
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!validate()) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Validación', detail: 'Revise los campos obligatorios', life: 3000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(JSON.stringify(form));
|
||||||
|
let response;
|
||||||
|
|
||||||
|
if (form.id && !isDuplicating.value) {
|
||||||
|
response = await riskFactorServices.updateRiskFactor(form.id, payload);
|
||||||
|
} else {
|
||||||
|
response = await riskFactorServices.createRiskFactor(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.add({ severity: 'success', summary: 'Éxito', detail: response.message, life: 3000 });
|
||||||
|
emit('saved', response.data);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.response?.data?.errors) {
|
||||||
|
errors.value = error.response.data.errors;
|
||||||
|
}
|
||||||
|
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudo guardar el factor de riesgo', life: 3000 });
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDuplicate = async () => {
|
||||||
|
if (!form.id) return;
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await riskFactorServices.duplicateRiskFactor(form.id);
|
||||||
|
toast.add({ severity: 'success', summary: 'Duplicado', detail: 'Factor clonado correctamente', life: 3000 });
|
||||||
|
|
||||||
|
// Cargar los datos del duplicado en el form
|
||||||
|
Object.assign(form, response.data);
|
||||||
|
form.options = form.options.map((opt, i) => ({ ...opt, tempId: i }));
|
||||||
|
isDuplicating.value = true; // Avisar que ahora estamos en modo "clon"
|
||||||
|
|
||||||
|
emit('saved', response.data);
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Error', detail: 'Error al duplicar el factor', life: 3000 });
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.risk-factor-form {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.p-dropdown), :deep(.p-inputtext) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.p-datatable.p-datatable-sm .p-datatable-thead > tr > th) {
|
||||||
|
padding: 0.5rem 0.5rem;
|
||||||
|
background-color: var(--surface-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.p-card) {
|
||||||
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
222
src/modules/catalog/components/risk-factors/RiskFactors.vue
Normal file
222
src/modules/catalog/components/risk-factors/RiskFactors.vue
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
<template>
|
||||||
|
<div class="risk-factors-view p-4">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h1 class="text-3xl font-bold text-surface-900 dark:text-surface-0">Factores de Riesgo</h1>
|
||||||
|
<Button label="Nuevo Factor"
|
||||||
|
icon="pi pi-plus"
|
||||||
|
@click="openNew"
|
||||||
|
v-if="hasPermission('risk-factors.store')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<template #content>
|
||||||
|
<DataTable :value="factors"
|
||||||
|
:loading="loading"
|
||||||
|
paginator
|
||||||
|
:rows="10"
|
||||||
|
dataKey="id"
|
||||||
|
responsiveLayout="scroll"
|
||||||
|
class="p-datatable-sm">
|
||||||
|
|
||||||
|
<template #header>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<span class="p-input-icon-left">
|
||||||
|
<i class="pi pi-search" />
|
||||||
|
<InputText v-model="filters.name" placeholder="Buscar por nombre..." @input="fetchFactors" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<Column field="name" header="Nombre" sortable />
|
||||||
|
|
||||||
|
<Column field="evaluation_context" header="Contexto" sortable>
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag :value="data.evaluation_context === 1 ? 'Ruta' : 'Programa'"
|
||||||
|
:severity="data.evaluation_context === 1 ? 'info' : 'success'" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="description" header="Descripción">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="text-sm text-surface-600 dark:text-surface-400">
|
||||||
|
{{ data.description || 'Sin descripción' }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="options" header="Opciones">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="font-medium">{{ data.options?.length || 0 }} niveles</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column header="Acciones" headerStyle="min-width:10rem">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button icon="pi pi-pencil"
|
||||||
|
severity="info"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
@click="editFactor(data)"
|
||||||
|
v-if="hasPermission('risk-factors.update')" />
|
||||||
|
|
||||||
|
<Button icon="pi pi-copy"
|
||||||
|
severity="secondary"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
@click="duplicateFactor(data)"
|
||||||
|
v-if="hasPermission('risk-factors.store')"
|
||||||
|
title="Duplicar" />
|
||||||
|
|
||||||
|
<Button icon="pi pi-trash"
|
||||||
|
severity="danger"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
@click="confirmDelete(data)"
|
||||||
|
v-if="hasPermission('risk-factors.destroy')" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<template #empty>
|
||||||
|
<div class="text-center p-8">
|
||||||
|
No se encontraron factores de riesgo.
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Modal del Formulario -->
|
||||||
|
<Dialog v-model:visible="formDialog"
|
||||||
|
:header="dialogTitle"
|
||||||
|
modal
|
||||||
|
:style="{ width: '800px' }"
|
||||||
|
:breakpoints="{ '960px': '75vw', '641px': '90vw' }"
|
||||||
|
dismissableMask
|
||||||
|
@hide="onDialogHide">
|
||||||
|
<RiskFactorModal v-if="formDialog"
|
||||||
|
:initialData="selectedFactor"
|
||||||
|
@saved="onSaved"
|
||||||
|
@cancel="formDialog = false" />
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Confirmación de Eliminación -->
|
||||||
|
<ConfirmDialog />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, reactive } from 'vue';
|
||||||
|
import { useToast } from 'primevue/usetoast';
|
||||||
|
import { useConfirm } from 'primevue/useconfirm';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import Card from 'primevue/card';
|
||||||
|
import DataTable from 'primevue/datatable';
|
||||||
|
import Column from 'primevue/column';
|
||||||
|
import InputText from 'primevue/inputtext';
|
||||||
|
import Tag from 'primevue/tag';
|
||||||
|
import Dialog from 'primevue/dialog';
|
||||||
|
import ConfirmDialog from 'primevue/confirmdialog';
|
||||||
|
import RiskFactorModal from './RiskFactorModal.vue';
|
||||||
|
import { riskFactorServices } from '../../services/risk-factors.services';
|
||||||
|
import type { RiskFactor } from '../../types/risk-factors.interfaces';
|
||||||
|
import { useAuth } from '../../../auth/composables/useAuth';
|
||||||
|
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
|
const toast = useToast();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
|
||||||
|
const factors = ref<RiskFactor[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const formDialog = ref(false);
|
||||||
|
const selectedFactor = ref<RiskFactor | undefined>(undefined);
|
||||||
|
const dialogTitle = ref('Nuevo Factor');
|
||||||
|
|
||||||
|
const filters = reactive({
|
||||||
|
name: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchFactors = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await riskFactorServices.getRiskFactors({ name: filters.name });
|
||||||
|
factors.value = response.data;
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudieron cargar los datos', life: 3000 });
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(fetchFactors);
|
||||||
|
|
||||||
|
const openNew = () => {
|
||||||
|
selectedFactor.value = undefined;
|
||||||
|
dialogTitle.value = 'Nuevo Factor de Riesgo';
|
||||||
|
formDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const editFactor = (factor: RiskFactor) => {
|
||||||
|
selectedFactor.value = JSON.parse(JSON.stringify(factor));
|
||||||
|
dialogTitle.value = 'Editar Factor de Riesgo';
|
||||||
|
formDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const duplicateFactor = (factor: RiskFactor) => {
|
||||||
|
// Para duplicar, cargamos los datos pero limpiamos el ID
|
||||||
|
const clone = JSON.parse(JSON.stringify(factor));
|
||||||
|
delete clone.id;
|
||||||
|
clone.name = clone.name + ' (Copia)';
|
||||||
|
// También limpiamos los IDs de las opciones
|
||||||
|
clone.options = clone.options.map((opt: any) => {
|
||||||
|
delete opt.id;
|
||||||
|
delete opt.risk_factor_id;
|
||||||
|
return opt;
|
||||||
|
});
|
||||||
|
|
||||||
|
selectedFactor.value = clone;
|
||||||
|
dialogTitle.value = 'Duplicar Factor de Riesgo';
|
||||||
|
formDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = (factor: RiskFactor) => {
|
||||||
|
confirm.require({
|
||||||
|
message: `¿Está seguro de eliminar el factor "${factor.name}"?`,
|
||||||
|
header: 'Confirmación de Eliminación',
|
||||||
|
icon: 'pi pi-exclamation-triangle',
|
||||||
|
acceptLabel: 'Sí, eliminar',
|
||||||
|
rejectLabel: 'No',
|
||||||
|
acceptClass: 'p-button-danger',
|
||||||
|
accept: async () => {
|
||||||
|
try {
|
||||||
|
await riskFactorServices.deleteRiskFactor(factor.id!);
|
||||||
|
toast.add({ severity: 'success', summary: 'Eliminado', detail: 'Factor eliminado correctamente', life: 3000 });
|
||||||
|
fetchFactors();
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudo eliminar el factor', life: 3000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSaved = () => {
|
||||||
|
formDialog.value = false;
|
||||||
|
fetchFactors();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDialogHide = () => {
|
||||||
|
selectedFactor.value = undefined;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.risk-factors-view {
|
||||||
|
animation: fadeIn 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,672 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Formulario de creación/edición de Segmentos de Ruta
|
||||||
|
*
|
||||||
|
* Layout dividido: panel lateral (formulario + puntos) y panel principal (mapa Mapbox)
|
||||||
|
* Los puntos se agregan haciendo clic en el mapa o manualmente.
|
||||||
|
* La ruta se calcula automáticamente con Mapbox Directions API.
|
||||||
|
*
|
||||||
|
* @author Raul Rene Velazco Narvaez <raul310882@gmail.com>
|
||||||
|
* @version 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
import mapboxgl from 'mapbox-gl';
|
||||||
|
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||||
|
|
||||||
|
import Card from 'primevue/card';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import InputText from 'primevue/inputtext';
|
||||||
|
import InputNumber from 'primevue/inputnumber';
|
||||||
|
import Select from 'primevue/select';
|
||||||
|
|
||||||
|
import { locationServices } from '../../services/location.services';
|
||||||
|
import type { Location } from '../../types/locations.interfaces';
|
||||||
|
import type {
|
||||||
|
RouteSegment,
|
||||||
|
RouteSegmentCreateRequest,
|
||||||
|
RouteSegmentFormErrors,
|
||||||
|
RoutePoint,
|
||||||
|
GeoJSONLineString,
|
||||||
|
NavigationLeg,
|
||||||
|
} from '../../types/routeSegments.interfaces';
|
||||||
|
|
||||||
|
// ─── Props y Emits ───────────────────────────────────────────
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
initialData?: RouteSegment;
|
||||||
|
formErrors?: RouteSegmentFormErrors;
|
||||||
|
isEditing?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'submit', payload: RouteSegmentCreateRequest): void;
|
||||||
|
(e: 'cancel'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// ─── Estado del mapa ─────────────────────────────────────────
|
||||||
|
|
||||||
|
const mapToken = import.meta.env.VITE_MAPBOX_TOKEN as string | undefined;
|
||||||
|
const mapContainer = ref<HTMLElement | null>(null);
|
||||||
|
const mapRef = ref<mapboxgl.Map | null>(null);
|
||||||
|
const mapReady = ref(false);
|
||||||
|
const markersOnMap = ref<mapboxgl.Marker[]>([]);
|
||||||
|
const locationMarkersOnMap = ref<mapboxgl.Marker[]>([]);
|
||||||
|
|
||||||
|
// ─── Estado del formulario ───────────────────────────────────
|
||||||
|
|
||||||
|
const locations = ref<Location[]>([]);
|
||||||
|
const originLocation = ref<Location | null>(null);
|
||||||
|
const destinationLocation = ref<Location | null>(null);
|
||||||
|
const points = ref<RoutePoint[]>([]);
|
||||||
|
const routeGeometry = ref<GeoJSONLineString | null>(null);
|
||||||
|
const navigationMetadata = ref<NavigationLeg[] | null>(null);
|
||||||
|
const metersDistance = ref<number | null>(null);
|
||||||
|
const secondsDuration = ref<number | null>(null);
|
||||||
|
const isLoadingRoute = ref(false);
|
||||||
|
const routeError = ref<string | null>(null);
|
||||||
|
|
||||||
|
const manualLatitude = ref('');
|
||||||
|
const manualLongitude = ref('');
|
||||||
|
|
||||||
|
let activeFetchToken = 0;
|
||||||
|
|
||||||
|
// ─── Inicialización ──────────────────────────────────────────
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadLocations();
|
||||||
|
|
||||||
|
if (props.initialData) {
|
||||||
|
loadInitialData(props.initialData);
|
||||||
|
}
|
||||||
|
|
||||||
|
initMap();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
markersOnMap.value.forEach(m => m.remove());
|
||||||
|
markersOnMap.value = [];
|
||||||
|
locationMarkersOnMap.value.forEach(m => m.remove());
|
||||||
|
locationMarkersOnMap.value = [];
|
||||||
|
if (mapRef.value) {
|
||||||
|
mapRef.value.remove();
|
||||||
|
mapRef.value = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadLocations() {
|
||||||
|
try {
|
||||||
|
const response = await locationServices.getLocations(false);
|
||||||
|
locations.value = ('data' in response) ? response.data : [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading locations:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadInitialData(segment: RouteSegment) {
|
||||||
|
originLocation.value = segment.origin ?? null;
|
||||||
|
destinationLocation.value = segment.destination ?? null;
|
||||||
|
metersDistance.value = segment.meters_distance;
|
||||||
|
secondsDuration.value = segment.seconds_duration;
|
||||||
|
navigationMetadata.value = segment.navigation_metadata ?? null;
|
||||||
|
|
||||||
|
if (segment.route_points?.length) {
|
||||||
|
points.value = segment.route_points.map(normalizePoint).filter(Boolean) as RoutePoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segment.path_geojson) {
|
||||||
|
routeGeometry.value = segment.path_geojson;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mapa ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function initMap() {
|
||||||
|
if (!mapToken || !mapContainer.value) return;
|
||||||
|
|
||||||
|
mapboxgl.accessToken = mapToken;
|
||||||
|
|
||||||
|
const map = new mapboxgl.Map({
|
||||||
|
container: mapContainer.value,
|
||||||
|
style: 'mapbox://styles/mapbox/streets-v12',
|
||||||
|
center: [-92.579, 18.039],
|
||||||
|
zoom: 6,
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addControl(new mapboxgl.NavigationControl(), 'top-right');
|
||||||
|
|
||||||
|
map.on('load', () => {
|
||||||
|
mapRef.value = map;
|
||||||
|
mapReady.value = true;
|
||||||
|
renderLocationMarkers();
|
||||||
|
renderMarkers();
|
||||||
|
|
||||||
|
if (routeGeometry.value) {
|
||||||
|
drawRoute(routeGeometry.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
map.on('click', (event: mapboxgl.MapMouseEvent) => {
|
||||||
|
addPoint({
|
||||||
|
lng: Number(event.lngLat.lng.toFixed(6)),
|
||||||
|
lat: Number(event.lngLat.lat.toFixed(6)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Coordenadas auxiliares para locaciones ───────────────────
|
||||||
|
|
||||||
|
function parseLocationCoordinates(coords: string | null | undefined): { lat: number; lng: number } | null {
|
||||||
|
if (!coords) return null;
|
||||||
|
const normalized = coords.trim();
|
||||||
|
|
||||||
|
// WKT: POINT(lng lat) o SRID=4326;POINT(lng lat)
|
||||||
|
const wkt = normalized.replace(/^SRID=\d+;/i, '');
|
||||||
|
const match = wkt.match(/POINT\s*\(\s*([-\d.]+)\s+([-\d.]+)\s*\)/i);
|
||||||
|
if (match) {
|
||||||
|
return { lng: Number(match[1]), lat: Number(match[2]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hex EWKB Point
|
||||||
|
if (/^[0-9a-fA-F]+$/.test(normalized) && normalized.length >= 42) {
|
||||||
|
return parseHexEwkbPoint(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHexEwkbPoint(hex: string): { lat: number; lng: number } | null {
|
||||||
|
if (hex.length % 2 !== 0) return null;
|
||||||
|
const bytes = new Uint8Array(hex.length / 2);
|
||||||
|
for (let i = 0; i < hex.length; i += 2) {
|
||||||
|
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
|
||||||
|
}
|
||||||
|
if (bytes.length < 21) return null;
|
||||||
|
|
||||||
|
const view = new DataView(bytes.buffer);
|
||||||
|
const le = view.getUint8(0) === 1;
|
||||||
|
const type = view.getUint32(1, le);
|
||||||
|
const hasSrid = (type & 0x20000000) !== 0;
|
||||||
|
const geomType = type & 0x0fffffff;
|
||||||
|
if (geomType !== 1) return null;
|
||||||
|
|
||||||
|
let off = 5;
|
||||||
|
if (hasSrid) off += 4;
|
||||||
|
|
||||||
|
if (bytes.length < off + 16) return null;
|
||||||
|
const lng = view.getFloat64(off, le);
|
||||||
|
const lat = view.getFloat64(off + 8, le);
|
||||||
|
if (isNaN(lat) || isNaN(lng)) return null;
|
||||||
|
return { lat, lng };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Puntos y marcadores ─────────────────────────────────────
|
||||||
|
|
||||||
|
function normalizePoint(point: any): RoutePoint | null {
|
||||||
|
if (!point) return null;
|
||||||
|
const lng = Number(point.lng ?? point.longitude ?? point.lon);
|
||||||
|
const lat = Number(point.lat ?? point.latitude);
|
||||||
|
if (!isFinite(lng) || !isFinite(lat)) return null;
|
||||||
|
return { lng: Number(lng.toFixed(6)), lat: Number(lat.toFixed(6)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLocationForPoint(point: RoutePoint): Location | null {
|
||||||
|
if (!locations.value?.length) return null;
|
||||||
|
|
||||||
|
for (const loc of locations.value) {
|
||||||
|
const locCoords = parseLocationCoordinates(loc.coordinates);
|
||||||
|
if (!locCoords) continue;
|
||||||
|
|
||||||
|
const dist = haversineDistance(point.lat, point.lng, locCoords.lat, locCoords.lng);
|
||||||
|
// Usar radio de la geocerca o un radio por defecto de 500m
|
||||||
|
const radius = 500;
|
||||||
|
if (dist <= radius) return loc;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function haversineDistance(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||||
|
const R = 6378137;
|
||||||
|
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||||
|
const dLng = (lng2 - lng1) * Math.PI / 180;
|
||||||
|
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2;
|
||||||
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPoint(point: RoutePoint) {
|
||||||
|
const normalized = normalizePoint(point);
|
||||||
|
if (!normalized) {
|
||||||
|
routeError.value = 'No se pudo agregar la coordenada. Verifica latitud y longitud.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primer punto: auto-detectar locación de origen
|
||||||
|
if (points.value.length === 0) {
|
||||||
|
const locOrigin = findLocationForPoint(normalized);
|
||||||
|
if (locOrigin) {
|
||||||
|
originLocation.value = locOrigin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Siempre intenta actualizar el destino con el último punto
|
||||||
|
const locDest = findLocationForPoint(normalized);
|
||||||
|
if (locDest && locDest.id !== originLocation.value?.id) {
|
||||||
|
destinationLocation.value = locDest;
|
||||||
|
}
|
||||||
|
|
||||||
|
routeError.value = null;
|
||||||
|
points.value = [...points.value, normalized];
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLastPoint() {
|
||||||
|
if (points.value.length === 0) return;
|
||||||
|
const updated = [...points.value];
|
||||||
|
updated.pop();
|
||||||
|
points.value = updated;
|
||||||
|
|
||||||
|
if (updated.length === 0) {
|
||||||
|
originLocation.value = null;
|
||||||
|
destinationLocation.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPoints() {
|
||||||
|
points.value = [];
|
||||||
|
routeError.value = null;
|
||||||
|
originLocation.value = null;
|
||||||
|
destinationLocation.value = null;
|
||||||
|
routeGeometry.value = null;
|
||||||
|
navigationMetadata.value = null;
|
||||||
|
metersDistance.value = null;
|
||||||
|
secondsDuration.value = null;
|
||||||
|
clearRouteLayer();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleManualSubmit() {
|
||||||
|
const lat = parseFloat(manualLatitude.value);
|
||||||
|
const lng = parseFloat(manualLongitude.value);
|
||||||
|
if (!isFinite(lat) || !isFinite(lng)) return;
|
||||||
|
|
||||||
|
addPoint({ lat, lng });
|
||||||
|
manualLatitude.value = '';
|
||||||
|
manualLongitude.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger route fetch when points change
|
||||||
|
watch(points, (newPoints) => {
|
||||||
|
renderMarkers();
|
||||||
|
if (newPoints.length >= 2) {
|
||||||
|
fetchRoute();
|
||||||
|
} else {
|
||||||
|
routeGeometry.value = null;
|
||||||
|
navigationMetadata.value = null;
|
||||||
|
metersDistance.value = null;
|
||||||
|
secondsDuration.value = null;
|
||||||
|
clearRouteLayer();
|
||||||
|
}
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
// ─── Renderizado de marcadores ───────────────────────────────
|
||||||
|
|
||||||
|
function renderMarkers() {
|
||||||
|
if (!mapReady.value || !mapRef.value) return;
|
||||||
|
|
||||||
|
markersOnMap.value.forEach(m => m.remove());
|
||||||
|
markersOnMap.value = [];
|
||||||
|
|
||||||
|
points.value.forEach((point, index) => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'route-marker';
|
||||||
|
el.textContent = String(index + 1);
|
||||||
|
|
||||||
|
const marker = new mapboxgl.Marker({ element: el, anchor: 'center' })
|
||||||
|
.setLngLat([point.lng, point.lat])
|
||||||
|
.addTo(mapRef.value!);
|
||||||
|
markersOnMap.value.push(marker);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (points.value.length > 0) {
|
||||||
|
const first = points.value[0];
|
||||||
|
mapRef.value.flyTo({ center: [first.lng, first.lat], zoom: Math.max(mapRef.value.getZoom(), 10), speed: 0.8 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLocationMarkers() {
|
||||||
|
if (!mapReady.value || !mapRef.value || !locations.value?.length) return;
|
||||||
|
|
||||||
|
locationMarkersOnMap.value.forEach(m => m.remove());
|
||||||
|
locationMarkersOnMap.value = [];
|
||||||
|
|
||||||
|
locations.value.forEach(loc => {
|
||||||
|
const locCoords = parseLocationCoordinates(loc.coordinates);
|
||||||
|
if (!locCoords) return;
|
||||||
|
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'location-marker';
|
||||||
|
|
||||||
|
const marker = new mapboxgl.Marker({ element: el })
|
||||||
|
.setLngLat([locCoords.lng, locCoords.lat])
|
||||||
|
.addTo(mapRef.value!);
|
||||||
|
|
||||||
|
if (loc.name) {
|
||||||
|
const popup = new mapboxgl.Popup({ offset: 25 }).setText(loc.name);
|
||||||
|
marker.setPopup(popup);
|
||||||
|
el.addEventListener('mouseenter', () => marker.togglePopup());
|
||||||
|
el.addEventListener('mouseleave', () => marker.togglePopup());
|
||||||
|
}
|
||||||
|
|
||||||
|
locationMarkersOnMap.value.push(marker);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Ruta Mapbox Directions ──────────────────────────────────
|
||||||
|
|
||||||
|
async function fetchRoute() {
|
||||||
|
if (!mapToken || points.value.length < 2) return;
|
||||||
|
|
||||||
|
routeError.value = null;
|
||||||
|
const fetchToken = ++activeFetchToken;
|
||||||
|
isLoadingRoute.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const coordsPath = points.value.map(p => `${p.lng},${p.lat}`).join(';');
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
access_token: mapToken,
|
||||||
|
geometries: 'geojson',
|
||||||
|
overview: 'full',
|
||||||
|
steps: 'true',
|
||||||
|
annotations: 'distance,duration',
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = `https://api.mapbox.com/directions/v5/mapbox/driving-traffic/${coordsPath}?${params.toString()}&language=es`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (fetchToken !== activeFetchToken) return;
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Error al obtener la ruta (${response.status})`);
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const route = data?.routes?.[0];
|
||||||
|
|
||||||
|
if (!route?.geometry) {
|
||||||
|
routeGeometry.value = null;
|
||||||
|
clearRouteLayer();
|
||||||
|
routeError.value = 'No se encontró una ruta para los puntos seleccionados.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
routeGeometry.value = route.geometry;
|
||||||
|
metersDistance.value = Math.round(route.distance ?? 0);
|
||||||
|
secondsDuration.value = Math.round(route.duration ?? 0);
|
||||||
|
|
||||||
|
// Extraer solo instrucciones de navegación (legs)
|
||||||
|
navigationMetadata.value = (route.legs ?? []).map((leg: any) => ({
|
||||||
|
steps: (leg.steps ?? []).map((step: any) => ({
|
||||||
|
maneuver: { instruction: step.maneuver?.instruction || '' },
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
drawRoute(route.geometry);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (fetchToken !== activeFetchToken) return;
|
||||||
|
routeGeometry.value = null;
|
||||||
|
clearRouteLayer();
|
||||||
|
routeError.value = error instanceof Error ? error.message : String(error);
|
||||||
|
} finally {
|
||||||
|
if (fetchToken === activeFetchToken) isLoadingRoute.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawRoute(geometry: GeoJSONLineString) {
|
||||||
|
if (!mapReady.value || !mapRef.value || !geometry) return;
|
||||||
|
|
||||||
|
const map = mapRef.value;
|
||||||
|
const sourceId = 'route-source';
|
||||||
|
const layerId = 'route-layer';
|
||||||
|
|
||||||
|
if (map.getLayer(layerId)) map.removeLayer(layerId);
|
||||||
|
if (map.getSource(sourceId)) map.removeSource(sourceId);
|
||||||
|
|
||||||
|
map.addSource(sourceId, {
|
||||||
|
type: 'geojson',
|
||||||
|
data: { type: 'Feature', properties: {}, geometry },
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: layerId,
|
||||||
|
type: 'line',
|
||||||
|
source: sourceId,
|
||||||
|
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
||||||
|
paint: { 'line-color': '#1a73e8', 'line-width': 4, 'line-opacity': 0.8 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRouteLayer() {
|
||||||
|
if (!mapRef.value) return;
|
||||||
|
const map = mapRef.value;
|
||||||
|
if (map.getLayer('route-layer')) map.removeLayer('route-layer');
|
||||||
|
if (map.getSource('route-source')) map.removeSource('route-source');
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusPoint(point: RoutePoint) {
|
||||||
|
if (!mapRef.value) return;
|
||||||
|
mapRef.value.flyTo({
|
||||||
|
center: [point.lng, point.lat],
|
||||||
|
zoom: Math.max(mapRef.value.getZoom(), 13),
|
||||||
|
speed: 0.8,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Submit ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const canSubmit = computed(() => {
|
||||||
|
return originLocation.value && destinationLocation.value && points.value.length >= 2;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
if (!canSubmit.value) return;
|
||||||
|
|
||||||
|
const payload: RouteSegmentCreateRequest = {
|
||||||
|
origin_location_id: originLocation.value!.id,
|
||||||
|
destination_location_id: destinationLocation.value!.id,
|
||||||
|
meters_distance: metersDistance.value,
|
||||||
|
seconds_duration: secondsDuration.value,
|
||||||
|
path_geometry: routeGeometry.value ?? undefined,
|
||||||
|
navigation_metadata: navigationMetadata.value ?? undefined,
|
||||||
|
route_points: points.value,
|
||||||
|
};
|
||||||
|
|
||||||
|
emit('submit', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers formato ─────────────────────────────────────────
|
||||||
|
|
||||||
|
const formatDistanceKm = (meters: number | null): string => {
|
||||||
|
if (!meters || meters <= 0) return '—';
|
||||||
|
return `${(meters / 1000).toFixed(2)} km`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number | null): string => {
|
||||||
|
if (!seconds || seconds <= 0) return '—';
|
||||||
|
const totalMinutes = Math.round(seconds / 60);
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
if (hours === 0) return `${minutes} min`;
|
||||||
|
return `${hours} h ${minutes} min`;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid h-[calc(100vh-10rem)] gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,3fr)]">
|
||||||
|
<!-- ━━━ Panel Lateral ━━━ -->
|
||||||
|
<div class="flex flex-col gap-4 overflow-y-auto pr-1">
|
||||||
|
<!-- Locaciones detectadas -->
|
||||||
|
<Card>
|
||||||
|
<template #content>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-1">Origen</label>
|
||||||
|
<InputText :modelValue="originLocation?.name || '(se detecta del mapa)'" readonly class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-1">Destino</label>
|
||||||
|
<InputText :modelValue="destinationLocation?.name || '(se detecta del mapa)'" readonly class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
label="Guardar"
|
||||||
|
icon="pi pi-check"
|
||||||
|
:disabled="!canSubmit || loading"
|
||||||
|
:loading="loading"
|
||||||
|
@click="handleSubmit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Agregar coordenadas manualmente -->
|
||||||
|
<Card>
|
||||||
|
<template #content>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||||
|
Haz clic en el mapa o agrega coordenadas manualmente para trazar el segmento.
|
||||||
|
</p>
|
||||||
|
<form class="grid grid-cols-2 gap-3" @submit.prevent="handleManualSubmit">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-1">Latitud</label>
|
||||||
|
<InputText v-model="manualLatitude" placeholder="19.4326" class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-1">Longitud</label>
|
||||||
|
<InputText v-model="manualLongitude" placeholder="-99.1332" class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2 flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
label="Limpiar"
|
||||||
|
icon="pi pi-times"
|
||||||
|
severity="danger"
|
||||||
|
text
|
||||||
|
:disabled="points.length === 0"
|
||||||
|
@click="clearPoints"
|
||||||
|
/>
|
||||||
|
<Button label="Agregar" icon="pi pi-plus" type="submit" text />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Lista de puntos -->
|
||||||
|
<Card>
|
||||||
|
<template #content>
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<span class="text-xs font-bold text-gray-500 dark:text-gray-400 uppercase">Coordenadas Trazadas</span>
|
||||||
|
<span class="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||||
|
{{ points.length }} puntos
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="points.length === 0" class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Agrega al menos dos puntos para trazar el segmento.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul v-else class="space-y-2 max-h-[200px] overflow-y-auto">
|
||||||
|
<li
|
||||||
|
v-for="(point, index) in points"
|
||||||
|
:key="`${point.lat}-${point.lng}-${index}`"
|
||||||
|
class="flex items-center justify-between rounded-md border border-surface-200 dark:border-surface-700 bg-surface-50 dark:bg-surface-800 px-3 py-2 text-xs"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span class="font-semibold text-primary">#{{ index + 1 }}</span>
|
||||||
|
<span class="ml-2 font-mono">{{ point.lat }}, {{ point.lng }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Button icon="pi pi-map-marker" text rounded size="small" @click="focusPoint(point)" />
|
||||||
|
<Button
|
||||||
|
v-if="index === points.length - 1"
|
||||||
|
icon="pi pi-trash"
|
||||||
|
text rounded size="small"
|
||||||
|
severity="danger"
|
||||||
|
@click="removeLastPoint"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Datos de ruta -->
|
||||||
|
<Card>
|
||||||
|
<template #content>
|
||||||
|
<h3 class="text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-3">Datos de la Ruta</h3>
|
||||||
|
|
||||||
|
<div v-if="isLoadingRoute" class="text-xs text-primary flex items-center gap-2">
|
||||||
|
<i class="pi pi-spin pi-spinner"></i> Consultando ruta...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="routeError" class="text-xs text-red-500">{{ routeError }}</div>
|
||||||
|
<div v-else-if="metersDistance" class="space-y-2 text-xs text-surface-700 dark:text-surface-200">
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<p><span class="font-semibold">Distancia:</span> {{ formatDistanceKm(metersDistance) }}</p>
|
||||||
|
<p><span class="font-semibold">Duración:</span> {{ formatDuration(secondsDuration) }}</p>
|
||||||
|
</div>
|
||||||
|
<details v-if="navigationMetadata?.length" class="mt-2 rounded-md border border-surface-200 dark:border-surface-700 bg-surface-50 dark:bg-surface-800 p-3 text-[11px]">
|
||||||
|
<summary class="cursor-pointer font-semibold">Ver pasos de navegación</summary>
|
||||||
|
<ol class="mt-2 space-y-2 list-decimal pl-4">
|
||||||
|
<li v-for="(leg, legIndex) in navigationMetadata" :key="legIndex">
|
||||||
|
<p class="font-semibold">Tramo {{ legIndex + 1 }}</p>
|
||||||
|
<ul class="mt-1 space-y-1">
|
||||||
|
<li v-for="(step, stepIndex) in leg.steps" :key="stepIndex">
|
||||||
|
{{ step.maneuver?.instruction }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-xs text-gray-400">Sin datos de ruta.</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ━━━ Mapa ━━━ -->
|
||||||
|
<div class="h-full overflow-hidden rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm relative">
|
||||||
|
<div ref="mapContainer" class="h-full w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:global(.location-marker) {
|
||||||
|
cursor: pointer;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #3b82f6;
|
||||||
|
border: 2px solid white;
|
||||||
|
box-shadow: 0 0 0 2px white, 0 0 0 4px #3b82f6;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.location-marker:hover) {
|
||||||
|
transform: scale(1.3);
|
||||||
|
box-shadow: 0 0 0 2px white, 0 0 0 5px #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.route-marker) {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background-color: #1a73e8;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
text-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,221 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Evaluación de Riesgo de Segmentos de Ruta
|
||||||
|
*
|
||||||
|
* Muestra los factores de riesgo del contexto "ruta" con sus opciones
|
||||||
|
* y permite seleccionar una opción por cada factor para evaluar el segmento.
|
||||||
|
*
|
||||||
|
* @author Raul Rene Velazco Narvaez <raul310882@gmail.com>
|
||||||
|
* @version 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
import Card from 'primevue/card';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import Select from 'primevue/select';
|
||||||
|
import { useToast } from 'primevue/usetoast';
|
||||||
|
|
||||||
|
import { routeSegmentServices } from '../../services/routeSegment.services';
|
||||||
|
import type {
|
||||||
|
RouteSegment,
|
||||||
|
RiskFactor,
|
||||||
|
RiskOption,
|
||||||
|
RiskEvaluationPayload,
|
||||||
|
} from '../../types/routeSegments.interfaces';
|
||||||
|
|
||||||
|
// ─── Props y Emits ───────────────────────────────────────────
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
segment: RouteSegment;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'saved'): void;
|
||||||
|
(e: 'cancel'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
// ─── Estado ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const loadingFactors = ref(true);
|
||||||
|
const saving = ref(false);
|
||||||
|
const riskFactors = ref<RiskFactor[]>([]);
|
||||||
|
const selectedOptions = ref<Record<number, RiskOption | null>>({});
|
||||||
|
|
||||||
|
// ─── Inicialización ──────────────────────────────────────────
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchRiskFactors();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchRiskFactors() {
|
||||||
|
loadingFactors.value = true;
|
||||||
|
try {
|
||||||
|
const response = await routeSegmentServices.getRiskFactors(props.segment.id);
|
||||||
|
riskFactors.value = (response.data ?? []).map((factor: RiskFactor) => ({
|
||||||
|
...factor,
|
||||||
|
options: factor.options ?? [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Pre-seleccionar opciones ya evaluadas
|
||||||
|
riskFactors.value.forEach(factor => {
|
||||||
|
if (factor.selected_option_id && factor.options) {
|
||||||
|
const selected = factor.options.find(o => o.id === factor.selected_option_id) ?? null;
|
||||||
|
selectedOptions.value[factor.id] = selected;
|
||||||
|
} else {
|
||||||
|
selectedOptions.value[factor.id] = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudieron cargar los factores de riesgo.', life: 3000 });
|
||||||
|
} finally {
|
||||||
|
loadingFactors.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Computados ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const isSaveDisabled = computed(() => {
|
||||||
|
if (!riskFactors.value.length) return true;
|
||||||
|
return riskFactors.value.some(factor => !selectedOptions.value[factor.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const evaluationsPayload = computed((): RiskEvaluationPayload[] => {
|
||||||
|
return riskFactors.value
|
||||||
|
.map(factor => {
|
||||||
|
const option = selectedOptions.value[factor.id];
|
||||||
|
if (!option) return null;
|
||||||
|
return {
|
||||||
|
risk_factor_id: factor.id,
|
||||||
|
risk_option_id: option.id,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean) as RiskEvaluationPayload[];
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Guardar ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function saveEvaluations() {
|
||||||
|
if (!evaluationsPayload.value.length) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Advertencia', detail: 'Selecciona una opción para cada factor de riesgo.', life: 3000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
await routeSegmentServices.updateRiskEvaluations(props.segment.id, {
|
||||||
|
evaluations: evaluationsPayload.value,
|
||||||
|
});
|
||||||
|
toast.add({ severity: 'success', summary: 'Guardado', detail: 'Evaluaciones de riesgo actualizadas correctamente.', life: 3000 });
|
||||||
|
emit('saved');
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudieron guardar las evaluaciones.', life: 3000 });
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const formatDistanceKm = (meters: number | null): string => {
|
||||||
|
if (!meters || meters <= 0) return '—';
|
||||||
|
return `${(meters / 1000).toFixed(2)} km`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number | null): string => {
|
||||||
|
if (!seconds || seconds <= 0) return '—';
|
||||||
|
const totalMinutes = Math.round(seconds / 60);
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
if (hours === 0) return `${minutes} min`;
|
||||||
|
return `${hours} h ${minutes} min`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatOptionLabel = (option: RiskOption): string => {
|
||||||
|
return `${option.name} — ${option.value}`;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Datos del segmento -->
|
||||||
|
<Card>
|
||||||
|
<template #content>
|
||||||
|
<h3 class="text-sm font-semibold text-surface-900 dark:text-white mb-3">Datos del Segmento</h3>
|
||||||
|
|
||||||
|
<div v-if="loadingFactors" class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<i class="pi pi-spin pi-spinner mr-2"></i> Cargando...
|
||||||
|
</div>
|
||||||
|
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm text-surface-700 dark:text-surface-200">
|
||||||
|
<p>
|
||||||
|
<span class="font-semibold">Origen:</span>
|
||||||
|
{{ segment.origin?.name ?? '—' }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span class="font-semibold">Destino:</span>
|
||||||
|
{{ segment.destination?.name ?? '—' }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span class="font-semibold">Distancia:</span>
|
||||||
|
{{ formatDistanceKm(segment.meters_distance) }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span class="font-semibold">Duración est.:</span>
|
||||||
|
{{ formatDuration(segment.seconds_duration) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Factores de riesgo -->
|
||||||
|
<Card>
|
||||||
|
<template #content>
|
||||||
|
<h3 class="text-sm font-semibold text-surface-900 dark:text-white mb-4">Factores de Riesgo de Ruta</h3>
|
||||||
|
|
||||||
|
<div v-if="loadingFactors" class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<i class="pi pi-spin pi-spinner mr-2"></i> Cargando factores...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!riskFactors.length" class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
No hay factores de riesgo configurados para el contexto de rutas.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div v-for="factor in riskFactors" :key="factor.id">
|
||||||
|
<label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-1">
|
||||||
|
{{ factor.name }}
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
v-model="selectedOptions[factor.id]"
|
||||||
|
:options="factor.options"
|
||||||
|
:optionLabel="(opt: RiskOption) => formatOptionLabel(opt)"
|
||||||
|
:disabled="saving"
|
||||||
|
placeholder="Seleccionar..."
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 mt-6">
|
||||||
|
<Button
|
||||||
|
label="Cancelar"
|
||||||
|
severity="secondary"
|
||||||
|
text
|
||||||
|
@click="emit('cancel')"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Guardar Evaluación"
|
||||||
|
icon="pi pi-check"
|
||||||
|
:disabled="isSaveDisabled || saving"
|
||||||
|
:loading="saving"
|
||||||
|
@click="saveEvaluations"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Toast />
|
||||||
|
</template>
|
||||||
410
src/modules/catalog/components/route-segments/RouteSegments.vue
Normal file
410
src/modules/catalog/components/route-segments/RouteSegments.vue
Normal file
@ -0,0 +1,410 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import Card from 'primevue/card';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import DataTable from 'primevue/datatable';
|
||||||
|
import Column from 'primevue/column';
|
||||||
|
import InputText from 'primevue/inputtext';
|
||||||
|
import Paginator from 'primevue/paginator';
|
||||||
|
import Tag from 'primevue/tag';
|
||||||
|
import { useConfirm } from 'primevue/useconfirm';
|
||||||
|
import { useToast } from 'primevue/usetoast';
|
||||||
|
|
||||||
|
import { routeSegmentServices } from '../../services/routeSegment.services';
|
||||||
|
import type {
|
||||||
|
RouteSegment,
|
||||||
|
RouteSegmentCreateRequest,
|
||||||
|
RouteSegmentFormErrors,
|
||||||
|
RouteSegmentPaginatedResponse,
|
||||||
|
} from '../../types/routeSegments.interfaces';
|
||||||
|
import RouteSegmentForm from './RouteSegmentForm.vue';
|
||||||
|
import RouteSegmentRiskAssessment from './RouteSegmentRiskAssessment.vue';
|
||||||
|
import { useAuth } from '@/modules/auth/composables/useAuth';
|
||||||
|
|
||||||
|
// ─── Estado ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const segments = ref<RouteSegment[]>([]);
|
||||||
|
const pagination = ref({
|
||||||
|
first: 0,
|
||||||
|
rows: 15,
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
lastPage: 1,
|
||||||
|
});
|
||||||
|
const loading = ref(false);
|
||||||
|
const submitting = ref(false);
|
||||||
|
|
||||||
|
const searchOrigin = ref('');
|
||||||
|
const searchDestination = ref('');
|
||||||
|
|
||||||
|
// Vistas: 'list' | 'form' | 'risk'
|
||||||
|
type ViewMode = 'list' | 'form' | 'risk';
|
||||||
|
const currentView = ref<ViewMode>('list');
|
||||||
|
const isEditMode = ref(false);
|
||||||
|
const currentSegment = ref<RouteSegment | null>(null);
|
||||||
|
const formErrors = ref<RouteSegmentFormErrors>({});
|
||||||
|
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const toast = useToast();
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
|
|
||||||
|
// ─── Permisos ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const canViewSegments = computed(() =>
|
||||||
|
hasPermission([
|
||||||
|
'route-segments.index',
|
||||||
|
'route-segments.show',
|
||||||
|
'route-segments.store',
|
||||||
|
'route-segments.update',
|
||||||
|
'route-segments.destroy',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
const canCreateSegment = computed(() => hasPermission('route-segments.store'));
|
||||||
|
const canUpdateSegment = computed(() => hasPermission('route-segments.update'));
|
||||||
|
const canDeleteSegment = computed(() => hasPermission('route-segments.destroy'));
|
||||||
|
|
||||||
|
// ─── Navegación entre vistas ─────────────────────────────────
|
||||||
|
|
||||||
|
const handleCreateClick = () => {
|
||||||
|
if (!canCreateSegment.value) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Sin permisos', detail: 'No puedes crear segmentos de ruta.', life: 4000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isEditMode.value = false;
|
||||||
|
currentSegment.value = null;
|
||||||
|
formErrors.value = {};
|
||||||
|
currentView.value = 'form';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditClick = async (segment: RouteSegment) => {
|
||||||
|
if (!canUpdateSegment.value) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Sin permisos', detail: 'No puedes editar segmentos de ruta.', life: 4000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isEditMode.value = true;
|
||||||
|
formErrors.value = {};
|
||||||
|
try {
|
||||||
|
const response = await routeSegmentServices.getSegmentById(segment.id);
|
||||||
|
currentSegment.value = response.data;
|
||||||
|
currentView.value = 'form';
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudo cargar el segmento.', life: 3000 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRiskClick = (segment: RouteSegment) => {
|
||||||
|
currentSegment.value = segment;
|
||||||
|
currentView.value = 'risk';
|
||||||
|
};
|
||||||
|
|
||||||
|
const backToList = () => {
|
||||||
|
currentView.value = 'list';
|
||||||
|
isEditMode.value = false;
|
||||||
|
currentSegment.value = null;
|
||||||
|
formErrors.value = {};
|
||||||
|
fetchSegments(pagination.value.page);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── CRUD ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const handleFormSubmit = async (payload: RouteSegmentCreateRequest) => {
|
||||||
|
formErrors.value = {};
|
||||||
|
submitting.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEditMode.value && currentSegment.value) {
|
||||||
|
await routeSegmentServices.updateSegment(currentSegment.value.id, payload, 'patch');
|
||||||
|
toast.add({ severity: 'success', summary: 'Segmento actualizado', detail: 'El segmento se actualizó correctamente.', life: 3000 });
|
||||||
|
} else {
|
||||||
|
await routeSegmentServices.createSegment(payload);
|
||||||
|
toast.add({ severity: 'success', summary: 'Segmento creado', detail: 'El segmento se registró correctamente.', life: 3000 });
|
||||||
|
}
|
||||||
|
backToList();
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.response?.data?.errors) {
|
||||||
|
formErrors.value = e.response.data.errors;
|
||||||
|
}
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Error',
|
||||||
|
detail: e?.response?.data?.message || 'No se pudo guardar el segmento.',
|
||||||
|
life: 3500,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (segmentId: number) => {
|
||||||
|
if (!canDeleteSegment.value) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Sin permisos', detail: 'No puedes eliminar segmentos de ruta.', life: 4000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
confirm.require({
|
||||||
|
message: '¿Seguro que deseas eliminar este segmento de ruta?',
|
||||||
|
header: 'Confirmar eliminación',
|
||||||
|
icon: 'pi pi-exclamation-triangle',
|
||||||
|
acceptLabel: 'Sí, eliminar',
|
||||||
|
rejectLabel: 'Cancelar',
|
||||||
|
accept: async () => {
|
||||||
|
try {
|
||||||
|
await routeSegmentServices.deleteSegment(segmentId);
|
||||||
|
toast.add({ severity: 'success', summary: 'Eliminado', detail: 'Segmento eliminado correctamente.', life: 3000 });
|
||||||
|
fetchSegments(pagination.value.page);
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudo eliminar el segmento.', life: 3000 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Fetch y Paginación ──────────────────────────────────────
|
||||||
|
|
||||||
|
const fetchSegments = async (page = 1) => {
|
||||||
|
if (!canViewSegments.value) {
|
||||||
|
segments.value = [];
|
||||||
|
pagination.value = { ...pagination.value, page: 1, total: 0, first: 0 };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pagination.value.page = page;
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const origin = searchOrigin.value || undefined;
|
||||||
|
const destination = searchDestination.value || undefined;
|
||||||
|
|
||||||
|
const response = await routeSegmentServices.getSegments(true, origin, destination, page, pagination.value.rows);
|
||||||
|
const paginated = response as RouteSegmentPaginatedResponse;
|
||||||
|
|
||||||
|
segments.value = paginated.data;
|
||||||
|
pagination.value.total = paginated.total;
|
||||||
|
pagination.value.page = paginated.current_page;
|
||||||
|
pagination.value.lastPage = paginated.last_page;
|
||||||
|
pagination.value.first = (paginated.current_page - 1) * paginated.per_page;
|
||||||
|
pagination.value.rows = paginated.per_page;
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudieron cargar los segmentos.', life: 3000 });
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => canViewSegments.value,
|
||||||
|
(allowed) => {
|
||||||
|
if (allowed) fetchSegments();
|
||||||
|
else segments.value = [];
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
searchOrigin.value = '';
|
||||||
|
searchDestination.value = '';
|
||||||
|
fetchSegments(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFilter = () => fetchSegments(1);
|
||||||
|
|
||||||
|
const onPageChange = (event: any) => {
|
||||||
|
const newPage = Math.floor(event.first / event.rows) + 1;
|
||||||
|
fetchSegments(newPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Helpers de formato ──────────────────────────────────────
|
||||||
|
|
||||||
|
const formatDistanceKm = (meters: number | null): string => {
|
||||||
|
if (!meters || meters <= 0) return '—';
|
||||||
|
return `${(meters / 1000).toFixed(2)} km`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number | null): string => {
|
||||||
|
if (!seconds || seconds <= 0) return '—';
|
||||||
|
const totalMinutes = Math.round(seconds / 60);
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
if (hours === 0) return `${minutes} min`;
|
||||||
|
return `${hours} h ${minutes} min`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasRiskEvaluations = (segment: RouteSegment): boolean => {
|
||||||
|
return (segment.risk_evaluations?.length ?? 0) > 0;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- ━━━ Vista: Formulario ━━━ -->
|
||||||
|
<template v-if="currentView === 'form'">
|
||||||
|
<div class="flex items-center gap-3 mb-6">
|
||||||
|
<Button icon="pi pi-arrow-left" text rounded @click="backToList" />
|
||||||
|
<div>
|
||||||
|
<h2 class="text-3xl font-black text-surface-900 dark:text-white tracking-tight">
|
||||||
|
{{ isEditMode ? 'Editar Segmento de Ruta' : 'Nuevo Segmento de Ruta' }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-sm mt-1">
|
||||||
|
Traza el recorrido entre dos locaciones usando el mapa interactivo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<RouteSegmentForm
|
||||||
|
:initialData="currentSegment || undefined"
|
||||||
|
:formErrors="formErrors"
|
||||||
|
:isEditing="isEditMode"
|
||||||
|
:loading="submitting"
|
||||||
|
@submit="handleFormSubmit"
|
||||||
|
@cancel="backToList"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ━━━ Vista: Evaluación de Riesgo ━━━ -->
|
||||||
|
<template v-else-if="currentView === 'risk' && currentSegment">
|
||||||
|
<div class="flex items-center gap-3 mb-6">
|
||||||
|
<Button icon="pi pi-arrow-left" text rounded @click="backToList" />
|
||||||
|
<div>
|
||||||
|
<h2 class="text-3xl font-black text-surface-900 dark:text-white tracking-tight">
|
||||||
|
Evaluación de Riesgo
|
||||||
|
</h2>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-sm mt-1">
|
||||||
|
{{ currentSegment.origin?.name ?? '—' }} → {{ currentSegment.destination?.name ?? '—' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<RouteSegmentRiskAssessment
|
||||||
|
:segment="currentSegment"
|
||||||
|
@saved="backToList"
|
||||||
|
@cancel="backToList"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ━━━ Vista: Listado ━━━ -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-3xl font-black text-surface-900 dark:text-white tracking-tight">
|
||||||
|
Segmentos de Ruta
|
||||||
|
</h2>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-sm mt-1">
|
||||||
|
Administra los tramos individuales que componen las rutas de servicio.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
v-if="canCreateSegment"
|
||||||
|
label="Nuevo Segmento"
|
||||||
|
icon="pi pi-plus"
|
||||||
|
@click="handleCreateClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filtros -->
|
||||||
|
<Card v-if="canViewSegments">
|
||||||
|
<template #content>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-2">Origen</label>
|
||||||
|
<InputText v-model="searchOrigin" placeholder="Nombre del origen" class="w-full" @keyup.enter="onFilter" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-2">Destino</label>
|
||||||
|
<InputText v-model="searchDestination" placeholder="Nombre del destino" class="w-full" @keyup.enter="onFilter" />
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button icon="pi pi-search" text rounded @click="onFilter" />
|
||||||
|
<Button icon="pi pi-times" text rounded severity="secondary" label="Limpiar" @click="clearFilters" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Tabla -->
|
||||||
|
<Card v-if="canViewSegments">
|
||||||
|
<template #content>
|
||||||
|
<DataTable :value="segments" :loading="loading" stripedRows responsiveLayout="scroll" class="p-datatable-sm">
|
||||||
|
<Column header="Origen" style="min-width: 180px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="font-semibold">{{ data.origin?.name ?? '—' }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Destino" style="min-width: 180px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="font-semibold">{{ data.destination?.name ?? '—' }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Distancia" style="min-width: 120px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="font-mono text-sm">{{ formatDistanceKm(data.meters_distance) }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Duración" style="min-width: 120px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="font-mono text-sm">{{ formatDuration(data.seconds_duration) }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Riesgo" style="min-width: 100px" bodyStyle="text-align: center">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag
|
||||||
|
:value="hasRiskEvaluations(data) ? 'Evaluado' : 'Pendiente'"
|
||||||
|
:severity="hasRiskEvaluations(data) ? 'success' : 'danger'"
|
||||||
|
class="cursor-pointer"
|
||||||
|
@click="handleRiskClick(data)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Acciones" headerStyle="text-align: right" bodyStyle="text-align: right" style="min-width: 160px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="flex items-center justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
v-if="canUpdateSegment"
|
||||||
|
icon="pi pi-pencil"
|
||||||
|
text rounded size="small"
|
||||||
|
v-tooltip.top="'Editar'"
|
||||||
|
@click="handleEditClick(data)"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-shield"
|
||||||
|
text rounded size="small"
|
||||||
|
:severity="hasRiskEvaluations(data) ? 'success' : 'danger'"
|
||||||
|
v-tooltip.top="'Evaluación de riesgo'"
|
||||||
|
@click="handleRiskClick(data)"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="canDeleteSegment"
|
||||||
|
icon="pi pi-trash"
|
||||||
|
text rounded size="small"
|
||||||
|
severity="danger"
|
||||||
|
v-tooltip.top="'Eliminar'"
|
||||||
|
@click="handleDelete(data.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
<div class="mt-4">
|
||||||
|
<Paginator
|
||||||
|
:first="pagination.first"
|
||||||
|
:rows="pagination.rows"
|
||||||
|
:totalRecords="pagination.total"
|
||||||
|
:rowsPerPageOptions="[5, 10, 15, 25]"
|
||||||
|
@page="onPageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card v-else>
|
||||||
|
<template #content>
|
||||||
|
<div class="text-center py-10 text-surface-500 dark:text-surface-400">
|
||||||
|
<i class="pi pi-lock text-4xl mb-3"></i>
|
||||||
|
<p>No tienes permisos para visualizar este módulo.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ConfirmDialog />
|
||||||
|
<Toast />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
404
src/modules/catalog/components/routes/RouteForm.vue
Normal file
404
src/modules/catalog/components/routes/RouteForm.vue
Normal file
@ -0,0 +1,404 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Formulario de creación/edición de Rutas (Colección de Segmentos)
|
||||||
|
*
|
||||||
|
* Permite encadenar segmentos eligiendo orígenes y destinos.
|
||||||
|
*
|
||||||
|
* @author Raul Rene Velazco Narvaez <raul310882@gmail.com>
|
||||||
|
* @version 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import Card from 'primevue/card';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import Select from 'primevue/select';
|
||||||
|
import Message from 'primevue/message';
|
||||||
|
import ToggleSwitch from 'primevue/toggleswitch';
|
||||||
|
|
||||||
|
import api from '../../../../services/api'; // direct api use for filters
|
||||||
|
import type { Location } from '../../types/locations.interfaces';
|
||||||
|
import type { RouteSegment } from '../../types/routeSegments.interfaces';
|
||||||
|
import type {
|
||||||
|
Route,
|
||||||
|
RouteCreateRequest,
|
||||||
|
RouteFormErrors,
|
||||||
|
} from '../../types/routes.interfaces';
|
||||||
|
|
||||||
|
// ─── Props y Emits ───────────────────────────────────────────
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
initialData?: Route;
|
||||||
|
formErrors?: RouteFormErrors;
|
||||||
|
isEditing?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'submit', payload: RouteCreateRequest): void;
|
||||||
|
(e: 'cancel'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// ─── Estado ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const active = ref(true);
|
||||||
|
const segmentsChain = ref<RouteSegment[]>([]);
|
||||||
|
|
||||||
|
// Selectores para el siguiente segmento
|
||||||
|
const availableOrigins = ref<Location[]>([]);
|
||||||
|
const availableDestinations = ref<Location[]>([]);
|
||||||
|
|
||||||
|
const selectedOrigin = ref<Location | null>(null);
|
||||||
|
const selectedDestination = ref<Location | null>(null);
|
||||||
|
|
||||||
|
const loadingOrigins = ref(false);
|
||||||
|
const loadingDestinations = ref(false);
|
||||||
|
const addingSegment = ref(false);
|
||||||
|
|
||||||
|
const backendError = ref('');
|
||||||
|
|
||||||
|
// ─── Inicialización ──────────────────────────────────────────
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.initialData) {
|
||||||
|
active.value = props.initialData.active;
|
||||||
|
if (props.initialData.segments) {
|
||||||
|
segmentsChain.value = [...props.initialData.segments];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAvailableOrigins();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Lógica de Encadenamiento ────────────────────────────────
|
||||||
|
|
||||||
|
// Actualiza los orígenes disponibles dependiendo de si hay una cadena existente
|
||||||
|
async function updateAvailableOrigins() {
|
||||||
|
loadingOrigins.value = true;
|
||||||
|
selectedOrigin.value = null;
|
||||||
|
selectedDestination.value = null;
|
||||||
|
availableDestinations.value = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (segmentsChain.value.length === 0) {
|
||||||
|
// No hay segmentos, descargar todos los origenes libres
|
||||||
|
const res = await api.get('/api/route-segments-origins');
|
||||||
|
availableOrigins.value = res.data?.data || [];
|
||||||
|
} else {
|
||||||
|
// Ya hay segmentos, el origen obligatorio es el destino del último segmento
|
||||||
|
const lastSegment = segmentsChain.value[segmentsChain.value.length - 1];
|
||||||
|
if (lastSegment.destination) {
|
||||||
|
availableOrigins.value = [lastSegment.destination];
|
||||||
|
selectedOrigin.value = lastSegment.destination; // Auto select
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching origins:', e);
|
||||||
|
} finally {
|
||||||
|
loadingOrigins.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escuchar cambios en selectedOrigin para descargar sus posibles destinos
|
||||||
|
watch(selectedOrigin, async (newOrigin) => {
|
||||||
|
selectedDestination.value = null;
|
||||||
|
availableDestinations.value = [];
|
||||||
|
|
||||||
|
if (!newOrigin) return;
|
||||||
|
|
||||||
|
loadingDestinations.value = true;
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/api/route-segments-destinations/${newOrigin.id}`);
|
||||||
|
availableDestinations.value = res.data?.data || [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching destinations:', e);
|
||||||
|
} finally {
|
||||||
|
loadingDestinations.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Agregar segmento a la cadena
|
||||||
|
async function handleAddSegment() {
|
||||||
|
if (!selectedOrigin.value || !selectedDestination.value) return;
|
||||||
|
|
||||||
|
backendError.value = '';
|
||||||
|
addingSegment.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api.post('/api/route-segments-find', {
|
||||||
|
origin_location_id: selectedOrigin.value.id,
|
||||||
|
destination_location_id: selectedDestination.value.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const segmentFound = res.data?.data;
|
||||||
|
if (segmentFound) {
|
||||||
|
segmentsChain.value.push(segmentFound);
|
||||||
|
await updateAvailableOrigins(); // Resetear para el proximo
|
||||||
|
} else {
|
||||||
|
backendError.value = 'No se encontró un segmento validado entre el origen y destino seleccionados.';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
backendError.value = 'Hubo un error al buscar el segmento en la base de datos.';
|
||||||
|
} finally {
|
||||||
|
addingSegment.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remover segmento de la cadena
|
||||||
|
function removeSegment(index: number) {
|
||||||
|
// Si quitamos uno del medio, todos los subsecuentes tambien deben quitarse
|
||||||
|
// porque la cadena se romperia.
|
||||||
|
segmentsChain.value.splice(index);
|
||||||
|
updateAvailableOrigins();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Totalizadores ───────────────────────────────────────────
|
||||||
|
|
||||||
|
const totalDistance = computed(() => {
|
||||||
|
const meters = segmentsChain.value.reduce((sum, s) => sum + (s.meters_distance || 0), 0);
|
||||||
|
return formatDistanceKm(meters);
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalDuration = computed(() => {
|
||||||
|
const secs = segmentsChain.value.reduce((sum, s) => sum + (s.seconds_duration || 0), 0);
|
||||||
|
return formatDuration(secs);
|
||||||
|
});
|
||||||
|
|
||||||
|
const calculatedCheckpoints = computed(() => {
|
||||||
|
if (segmentsChain.value.length === 0) return [];
|
||||||
|
|
||||||
|
const cps: Location[] = [];
|
||||||
|
segmentsChain.value.forEach((seg, i) => {
|
||||||
|
if (i === 0 && seg.origin) {
|
||||||
|
cps.push(seg.origin);
|
||||||
|
}
|
||||||
|
if (seg.destination) {
|
||||||
|
cps.push(seg.destination);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return cps;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Submit ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
if (segmentsChain.value.length === 0) return;
|
||||||
|
|
||||||
|
const payload: RouteCreateRequest = {
|
||||||
|
active: active.value,
|
||||||
|
route_segments: segmentsChain.value.map(s => s.id),
|
||||||
|
};
|
||||||
|
|
||||||
|
emit('submit', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const formatDistanceKm = (meters: number | null): string => {
|
||||||
|
if (!meters || meters <= 0) return '0 km';
|
||||||
|
return `${(meters / 1000).toFixed(2)} km`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number | null): string => {
|
||||||
|
if (!seconds || seconds <= 0) return '0 min';
|
||||||
|
const totalMinutes = Math.round(seconds / 60);
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
if (hours === 0) return `${minutes} min`;
|
||||||
|
return `${hours} h ${minutes} min`;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid lg:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)] gap-6">
|
||||||
|
|
||||||
|
<!-- Panel Izquierdo: Constructor de Ruta -->
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<Card>
|
||||||
|
<template #content>
|
||||||
|
<h3 class="text-sm font-semibold text-surface-900 dark:text-white mb-4">Añadir Tramo</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 items-end bg-surface-50 dark:bg-surface-800 p-4 rounded-lg border border-surface-200 dark:border-surface-700">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-2">Origen</label>
|
||||||
|
<Select
|
||||||
|
v-model="selectedOrigin"
|
||||||
|
:options="availableOrigins"
|
||||||
|
optionLabel="name"
|
||||||
|
:loading="loadingOrigins"
|
||||||
|
placeholder="Seleccionar origen..."
|
||||||
|
class="w-full"
|
||||||
|
:disabled="segmentsChain.length > 0"
|
||||||
|
filter
|
||||||
|
>
|
||||||
|
<template #value="slotProps">
|
||||||
|
<div v-if="slotProps.value" class="flex items-center">
|
||||||
|
<i class="pi pi-map-marker text-xs mr-2 text-primary"></i>
|
||||||
|
<div>{{ slotProps.value.name }}</div>
|
||||||
|
</div>
|
||||||
|
<span v-else>{{ slotProps.placeholder }}</span>
|
||||||
|
</template>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-2">Destino</label>
|
||||||
|
<Select
|
||||||
|
v-model="selectedDestination"
|
||||||
|
:options="availableDestinations"
|
||||||
|
optionLabel="name"
|
||||||
|
:loading="loadingDestinations"
|
||||||
|
placeholder="Seleccionar destino..."
|
||||||
|
class="w-full"
|
||||||
|
:disabled="!selectedOrigin"
|
||||||
|
filter
|
||||||
|
>
|
||||||
|
<template #value="slotProps">
|
||||||
|
<div v-if="slotProps.value" class="flex items-center">
|
||||||
|
<i class="pi pi-flag text-xs mr-2 text-green-500"></i>
|
||||||
|
<div>{{ slotProps.value.name }}</div>
|
||||||
|
</div>
|
||||||
|
<span v-else>{{ slotProps.placeholder }}</span>
|
||||||
|
</template>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2 flex flex-col items-end gap-2">
|
||||||
|
<Message v-if="backendError" severity="error" size="small" :closable="false" class="w-full m-0">{{ backendError }}</Message>
|
||||||
|
<Button
|
||||||
|
label="Agregar Tramo"
|
||||||
|
icon="pi pi-plus"
|
||||||
|
:disabled="!selectedOrigin || !selectedDestination || addingSegment"
|
||||||
|
:loading="addingSegment"
|
||||||
|
@click="handleAddSegment"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<template #content>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-sm font-semibold text-surface-900 dark:text-white mb-0">Cadena de Tramos (Segmentos)</h3>
|
||||||
|
<span class="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||||
|
{{ segmentsChain.length }} tramos
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Message v-if="formErrors?.route_segments" severity="error" size="small" :closable="false" class="mb-3">
|
||||||
|
{{ formErrors.route_segments[0] }}
|
||||||
|
</Message>
|
||||||
|
|
||||||
|
<div v-if="segmentsChain.length === 0" class="text-center py-6 text-gray-400 text-sm border-2 border-dashed border-surface-200 dark:border-surface-700 rounded-lg">
|
||||||
|
La ruta está vacía. Selecciona y agrega un tramo para comenzar.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-0">
|
||||||
|
<div
|
||||||
|
v-for="(seg, index) in segmentsChain"
|
||||||
|
:key="index"
|
||||||
|
class="relative flex items-center justify-between p-3 bg-white dark:bg-surface-900 border border-surface-200 dark:border-surface-700 hover:border-primary transition-colors hover:shadow-sm"
|
||||||
|
:class="[
|
||||||
|
index === 0 ? 'rounded-t-lg' : '',
|
||||||
|
index === segmentsChain.length - 1 ? 'rounded-b-lg' : 'border-b-0'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3 w-full">
|
||||||
|
<div class="flex flex-col items-center justify-center w-6 text-surface-400 font-bold text-xs">
|
||||||
|
{{ index + 1 }}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col flex-grow text-sm">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<span class="font-semibold">{{ seg.origin?.name }}</span>
|
||||||
|
<i class="pi pi-arrow-right text-xs text-gray-400"></i>
|
||||||
|
<span class="font-semibold">{{ seg.destination?.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4 text-xs text-gray-500 font-mono">
|
||||||
|
<span><i class="pi pi-code text-[10px] mr-1"></i>ID {{ seg.id }}</span>
|
||||||
|
<span><i class="pi pi-map-marker text-[10px] mr-1"></i>{{ formatDistanceKm(seg.meters_distance) }}</span>
|
||||||
|
<span><i class="pi pi-clock text-[10px] mr-1"></i>{{ formatDuration(seg.seconds_duration) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-times"
|
||||||
|
text rounded size="small" severity="danger"
|
||||||
|
v-tooltip.top="'Quitar tramo y subsecuentes'"
|
||||||
|
@click="removeSegment(index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Panel Derecho: Checkpoints y Guardar -->
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<Card>
|
||||||
|
<template #content>
|
||||||
|
<h3 class="text-sm font-semibold text-surface-900 dark:text-white mb-4">Resumen y Guardado</h3>
|
||||||
|
|
||||||
|
<div class="mb-5 flex flex-col gap-2">
|
||||||
|
<div class="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700">
|
||||||
|
<div>
|
||||||
|
<span class="block text-xs font-bold text-gray-500 uppercase">Distancia Total</span>
|
||||||
|
<span class="font-mono font-semibold">{{ totalDistance }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<span class="block text-xs font-bold text-gray-500 uppercase">Duración Total</span>
|
||||||
|
<span class="font-mono font-semibold">{{ totalDuration }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<label class="text-sm font-semibold text-surface-700 dark:text-surface-200 mb-0">Ruta Activa</label>
|
||||||
|
<ToggleSwitch v-model="active" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
label="Cancelar"
|
||||||
|
severity="secondary"
|
||||||
|
text
|
||||||
|
@click="emit('cancel')"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Guardar Ruta"
|
||||||
|
icon="pi pi-save"
|
||||||
|
:disabled="segmentsChain.length === 0 || loading"
|
||||||
|
:loading="loading"
|
||||||
|
@click="handleSubmit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card v-if="calculatedCheckpoints.length > 0">
|
||||||
|
<template #content>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-sm font-semibold text-surface-900 dark:text-white mb-0">Ruta de Checkpoints</h3>
|
||||||
|
<i class="pi pi-info-circle text-gray-400" v-tooltip.top="'Estos checkpoints se generarán automáticamente al guardar.'"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative pl-3 border-l-2 border-surface-200 dark:border-surface-700 ml-2 space-y-4">
|
||||||
|
<div v-for="(cp, i) in calculatedCheckpoints" :key="i" class="relative">
|
||||||
|
<span
|
||||||
|
class="absolute -left-[18px] top-0.5 w-3 h-3 rounded-full border-2 border-white dark:border-surface-900 shadow"
|
||||||
|
:class="[
|
||||||
|
i === 0 ? 'bg-primary' :
|
||||||
|
i === calculatedCheckpoints.length - 1 ? 'bg-green-500' : 'bg-surface-400'
|
||||||
|
]"
|
||||||
|
></span>
|
||||||
|
<div class="text-sm -mt-0.5">
|
||||||
|
<span class="font-semibold block">{{ cp.name }}</span>
|
||||||
|
<span class="text-xs text-gray-500" v-if="i === 0">Punto de Partida (Origen)</span>
|
||||||
|
<span class="text-xs text-gray-500" v-else-if="i === calculatedCheckpoints.length - 1">Punto de Entrega (Destino)</span>
|
||||||
|
<span class="text-xs text-gray-500" v-else>Parada / Checkpoint</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
343
src/modules/catalog/components/routes/Routes.vue
Normal file
343
src/modules/catalog/components/routes/Routes.vue
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import Card from 'primevue/card';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import DataTable from 'primevue/datatable';
|
||||||
|
import Column from 'primevue/column';
|
||||||
|
import Paginator from 'primevue/paginator';
|
||||||
|
import Tag from 'primevue/tag';
|
||||||
|
import { useConfirm } from 'primevue/useconfirm';
|
||||||
|
import { useToast } from 'primevue/usetoast';
|
||||||
|
|
||||||
|
import { routeServices } from '../../services/route.services';
|
||||||
|
import type {
|
||||||
|
Route,
|
||||||
|
RouteCreateRequest,
|
||||||
|
RouteFormErrors,
|
||||||
|
RoutePaginatedResponse,
|
||||||
|
} from '../../types/routes.interfaces';
|
||||||
|
import RouteForm from './RouteForm.vue';
|
||||||
|
import { useAuth } from '@/modules/auth/composables/useAuth';
|
||||||
|
|
||||||
|
// ─── Estado ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const routes = ref<Route[]>([]);
|
||||||
|
const pagination = ref({
|
||||||
|
first: 0,
|
||||||
|
rows: 15,
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
lastPage: 1,
|
||||||
|
});
|
||||||
|
const loading = ref(false);
|
||||||
|
const submitting = ref(false);
|
||||||
|
|
||||||
|
const filterActive = ref<boolean | undefined>(undefined);
|
||||||
|
|
||||||
|
// Vistas: 'list' | 'form'
|
||||||
|
type ViewMode = 'list' | 'form';
|
||||||
|
const currentView = ref<ViewMode>('list');
|
||||||
|
const isEditMode = ref(false);
|
||||||
|
const currentRoute = ref<Route | null>(null);
|
||||||
|
const formErrors = ref<RouteFormErrors>({});
|
||||||
|
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const toast = useToast();
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
|
|
||||||
|
// ─── Permisos ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const canViewRoutes = computed(() =>
|
||||||
|
hasPermission([
|
||||||
|
'routes.index',
|
||||||
|
'routes.show',
|
||||||
|
'routes.store',
|
||||||
|
'routes.update',
|
||||||
|
'routes.destroy',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
const canCreateRoute = computed(() => hasPermission('routes.store'));
|
||||||
|
const canUpdateRoute = computed(() => hasPermission('routes.update'));
|
||||||
|
const canDeleteRoute = computed(() => hasPermission('routes.destroy'));
|
||||||
|
|
||||||
|
// ─── Navegación entre vistas ─────────────────────────────────
|
||||||
|
|
||||||
|
const handleCreateClick = () => {
|
||||||
|
if (!canCreateRoute.value) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Sin permisos', detail: 'No puedes crear rutas.', life: 4000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isEditMode.value = false;
|
||||||
|
currentRoute.value = null;
|
||||||
|
formErrors.value = {};
|
||||||
|
currentView.value = 'form';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditClick = async (routeObj: Route) => {
|
||||||
|
if (!canUpdateRoute.value) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Sin permisos', detail: 'No puedes editar rutas.', life: 4000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isEditMode.value = true;
|
||||||
|
formErrors.value = {};
|
||||||
|
try {
|
||||||
|
const response = await routeServices.getRouteById(routeObj.id);
|
||||||
|
currentRoute.value = response.data;
|
||||||
|
currentView.value = 'form';
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudo cargar la ruta.', life: 3000 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const backToList = () => {
|
||||||
|
currentView.value = 'list';
|
||||||
|
isEditMode.value = false;
|
||||||
|
currentRoute.value = null;
|
||||||
|
formErrors.value = {};
|
||||||
|
fetchRoutes(pagination.value.page);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── CRUD ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const handleFormSubmit = async (payload: RouteCreateRequest) => {
|
||||||
|
formErrors.value = {};
|
||||||
|
submitting.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEditMode.value && currentRoute.value) {
|
||||||
|
await routeServices.updateRoute(currentRoute.value.id, payload, 'patch');
|
||||||
|
toast.add({ severity: 'success', summary: 'Ruta actualizada', detail: 'La ruta se actualizó correctamente.', life: 3000 });
|
||||||
|
} else {
|
||||||
|
await routeServices.createRoute(payload);
|
||||||
|
toast.add({ severity: 'success', summary: 'Ruta creada', detail: 'La ruta se originó correctamente.', life: 3000 });
|
||||||
|
}
|
||||||
|
backToList();
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.response?.data?.errors) {
|
||||||
|
formErrors.value = e.response.data.errors;
|
||||||
|
}
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Error',
|
||||||
|
detail: e?.response?.data?.message || 'No se pudo guardar la ruta.',
|
||||||
|
life: 3500,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (routeId: number) => {
|
||||||
|
if (!canDeleteRoute.value) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Sin permisos', detail: 'No puedes eliminar rutas.', life: 4000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
confirm.require({
|
||||||
|
message: '¿Seguro que deseas eliminar esta ruta?',
|
||||||
|
header: 'Confirmar eliminación',
|
||||||
|
icon: 'pi pi-exclamation-triangle',
|
||||||
|
acceptLabel: 'Sí, eliminar',
|
||||||
|
rejectLabel: 'Cancelar',
|
||||||
|
accept: async () => {
|
||||||
|
try {
|
||||||
|
await routeServices.deleteRoute(routeId);
|
||||||
|
toast.add({ severity: 'success', summary: 'Eliminada', detail: 'Ruta eliminada correctamente.', life: 3000 });
|
||||||
|
fetchRoutes(pagination.value.page);
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudo eliminar la ruta.', life: 3000 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Fetch y Paginación ──────────────────────────────────────
|
||||||
|
|
||||||
|
const fetchRoutes = async (page = 1) => {
|
||||||
|
if (!canViewRoutes.value) {
|
||||||
|
routes.value = [];
|
||||||
|
pagination.value = { ...pagination.value, page: 1, total: 0, first: 0 };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pagination.value.page = page;
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await routeServices.getRoutes(true, filterActive.value, page, pagination.value.rows);
|
||||||
|
const paginated = response as RoutePaginatedResponse;
|
||||||
|
|
||||||
|
routes.value = paginated.data;
|
||||||
|
pagination.value.total = paginated.total;
|
||||||
|
pagination.value.page = paginated.current_page;
|
||||||
|
pagination.value.lastPage = paginated.last_page;
|
||||||
|
pagination.value.first = (paginated.current_page - 1) * paginated.per_page;
|
||||||
|
pagination.value.rows = paginated.per_page;
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudieron cargar las rutas.', life: 3000 });
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => canViewRoutes.value,
|
||||||
|
(allowed) => {
|
||||||
|
if (allowed) fetchRoutes();
|
||||||
|
else routes.value = [];
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPageChange = (event: any) => {
|
||||||
|
const newPage = Math.floor(event.first / event.rows) + 1;
|
||||||
|
fetchRoutes(newPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Helpers de formato ──────────────────────────────────────
|
||||||
|
|
||||||
|
const formatDistanceKm = (meters: number | null): string => {
|
||||||
|
if (!meters || meters <= 0) return '—';
|
||||||
|
return `${(meters / 1000).toFixed(2)} km`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number | null): string => {
|
||||||
|
if (!seconds || seconds <= 0) return '—';
|
||||||
|
const totalMinutes = Math.round(seconds / 60);
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
if (hours === 0) return `${minutes} min`;
|
||||||
|
return `${hours} h ${minutes} min`;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- ━━━ Vista: Formulario ━━━ -->
|
||||||
|
<template v-if="currentView === 'form'">
|
||||||
|
<div class="flex items-center gap-3 mb-6">
|
||||||
|
<Button icon="pi pi-arrow-left" text rounded @click="backToList" />
|
||||||
|
<div>
|
||||||
|
<h2 class="text-3xl font-black text-surface-900 dark:text-white tracking-tight">
|
||||||
|
{{ isEditMode ? 'Editar Ruta Maestro' : 'Nueva Ruta Maestro' }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-sm mt-1">
|
||||||
|
Encadena segmentos de ruta individuales para formar una ruta transaccional completa.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<RouteForm
|
||||||
|
:initialData="currentRoute || undefined"
|
||||||
|
:formErrors="formErrors"
|
||||||
|
:isEditing="isEditMode"
|
||||||
|
:loading="submitting"
|
||||||
|
@submit="handleFormSubmit"
|
||||||
|
@cancel="backToList"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ━━━ Vista: Listado ━━━ -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-3xl font-black text-surface-900 dark:text-white tracking-tight">
|
||||||
|
Catálogo de Rutas
|
||||||
|
</h2>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-sm mt-1">
|
||||||
|
Administra maestras de trayectos predefinidos compuestos por uno o más segmentos secuenciales.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
v-if="canCreateRoute"
|
||||||
|
label="Nueva Ruta"
|
||||||
|
icon="pi pi-plus"
|
||||||
|
@click="handleCreateClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabla -->
|
||||||
|
<Card v-if="canViewRoutes">
|
||||||
|
<template #content>
|
||||||
|
<DataTable :value="routes" :loading="loading" stripedRows responsiveLayout="scroll" class="p-datatable-sm">
|
||||||
|
<Column header="ID Ruta" style="width: 100px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="font-bold text-primary">#{{ data.id }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Origen Global">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="font-semibold">{{ data.origin?.name ?? '—' }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Destino Global">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="font-semibold">{{ data.destination?.name ?? '—' }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Dist. Total">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="font-mono text-sm">{{ formatDistanceKm(data.meters_distance) }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Dur. Total">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="font-mono text-sm">{{ formatDuration(data.seconds_duration) }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Estado" bodyStyle="text-align: center">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag
|
||||||
|
:value="data.active ? 'Activa' : 'Inactiva'"
|
||||||
|
:severity="data.active ? 'success' : 'danger'"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Acciones" headerStyle="text-align: right" bodyStyle="text-align: right">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="flex items-center justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
v-if="canUpdateRoute"
|
||||||
|
icon="pi pi-pencil"
|
||||||
|
text rounded size="small"
|
||||||
|
v-tooltip.top="'Editar'"
|
||||||
|
@click="handleEditClick(data)"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="canDeleteRoute"
|
||||||
|
icon="pi pi-trash"
|
||||||
|
text rounded size="small"
|
||||||
|
severity="danger"
|
||||||
|
v-tooltip.top="'Eliminar'"
|
||||||
|
@click="handleDelete(data.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
<div class="mt-4">
|
||||||
|
<Paginator
|
||||||
|
:first="pagination.first"
|
||||||
|
:rows="pagination.rows"
|
||||||
|
:totalRecords="pagination.total"
|
||||||
|
:rowsPerPageOptions="[5, 10, 15, 25]"
|
||||||
|
@page="onPageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card v-else>
|
||||||
|
<template #content>
|
||||||
|
<div class="text-center py-10 text-surface-500 dark:text-surface-400">
|
||||||
|
<i class="pi pi-lock text-4xl mb-3"></i>
|
||||||
|
<p>No tienes permisos para visualizar este módulo.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ConfirmDialog />
|
||||||
|
<Toast />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -19,7 +19,7 @@ const locationServices = {
|
|||||||
): Promise<LocationPaginatedResponse | LocationListResponse> {
|
): Promise<LocationPaginatedResponse | LocationListResponse> {
|
||||||
try {
|
try {
|
||||||
const params: any = {};
|
const params: any = {};
|
||||||
if (paginated === false) params.paginated = false;
|
if (paginated === false) params.paginate = false;
|
||||||
if (name) params.name = name;
|
if (name) params.name = name;
|
||||||
if (city) params.city = city;
|
if (city) params.city = city;
|
||||||
if (state) params.state = state;
|
if (state) params.state = state;
|
||||||
|
|||||||
69
src/modules/catalog/services/risk-factors.services.ts
Normal file
69
src/modules/catalog/services/risk-factors.services.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import api from '../../../services/api';
|
||||||
|
import type {
|
||||||
|
RiskFactor,
|
||||||
|
RiskFactorPaginatedResponse
|
||||||
|
} from '../types/risk-factors.interfaces';
|
||||||
|
|
||||||
|
const riskFactorServices = {
|
||||||
|
async getRiskFactors(params?: any): Promise<RiskFactorPaginatedResponse> {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/api/risk-factors', { params });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching risk factors:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getRiskFactorById(id: number): Promise<{ data: RiskFactor }> {
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/api/risk-factors/${id}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching risk factor ${id}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async createRiskFactor(data: RiskFactor): Promise<{ data: RiskFactor; message: string }> {
|
||||||
|
try {
|
||||||
|
const response = await api.post('/api/risk-factors', data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating risk factor:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateRiskFactor(id: number, data: RiskFactor): Promise<{ data: RiskFactor; message: string }> {
|
||||||
|
try {
|
||||||
|
const response = await api.put(`/api/risk-factors/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error updating risk factor ${id}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async duplicateRiskFactor(id: number): Promise<{ data: RiskFactor; message: string }> {
|
||||||
|
try {
|
||||||
|
const response = await api.post(`/api/risk-factors/${id}/duplicate`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error duplicating risk factor ${id}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteRiskFactor(id: number): Promise<{ message: string }> {
|
||||||
|
try {
|
||||||
|
const response = await api.delete(`/api/risk-factors/${id}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error deleting risk factor ${id}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { riskFactorServices };
|
||||||
94
src/modules/catalog/services/route.services.ts
Normal file
94
src/modules/catalog/services/route.services.ts
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import api from '../../../services/api';
|
||||||
|
import type {
|
||||||
|
RouteCreateRequest,
|
||||||
|
RouteCreateResponse,
|
||||||
|
RouteDeleteResponse,
|
||||||
|
RouteListResponse,
|
||||||
|
RoutePaginatedResponse,
|
||||||
|
RouteUpdateRequest,
|
||||||
|
RouteUpdateResponse,
|
||||||
|
} from '../types/routes.interfaces';
|
||||||
|
|
||||||
|
const routeServices = {
|
||||||
|
/**
|
||||||
|
* Listar rutas (paginadas o todas)
|
||||||
|
*/
|
||||||
|
async getRoutes(
|
||||||
|
paginated?: boolean,
|
||||||
|
active?: boolean,
|
||||||
|
page?: number,
|
||||||
|
perPage?: number,
|
||||||
|
): Promise<RoutePaginatedResponse | RouteListResponse> {
|
||||||
|
try {
|
||||||
|
const params: Record<string, any> = {};
|
||||||
|
if (paginated === false) params.paginate = false;
|
||||||
|
if (active !== undefined) params.active = active;
|
||||||
|
if (page) params.page = page;
|
||||||
|
if (perPage) params.per_page = perPage;
|
||||||
|
|
||||||
|
const response = await api.get('/api/routes', { params });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching routes:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener una ruta por ID
|
||||||
|
*/
|
||||||
|
async getRouteById(routeId: number): Promise<RouteCreateResponse> {
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/api/routes/${routeId}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching route with ID ${routeId}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear una nueva ruta
|
||||||
|
*/
|
||||||
|
async createRoute(data: RouteCreateRequest): Promise<RouteCreateResponse> {
|
||||||
|
try {
|
||||||
|
const response = await api.post('/api/routes', data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating route:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualizar una ruta existente
|
||||||
|
*/
|
||||||
|
async updateRoute(
|
||||||
|
routeId: number,
|
||||||
|
data: RouteUpdateRequest,
|
||||||
|
method: 'patch' | 'put' = 'patch',
|
||||||
|
): Promise<RouteUpdateResponse> {
|
||||||
|
try {
|
||||||
|
const response = await api[method](`/api/routes/${routeId}`, data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error updating route with ID ${routeId}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eliminar una ruta (soft delete)
|
||||||
|
*/
|
||||||
|
async deleteRoute(routeId: number): Promise<RouteDeleteResponse> {
|
||||||
|
try {
|
||||||
|
const response = await api.delete(`/api/routes/${routeId}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error deleting route with ID ${routeId}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export { routeServices };
|
||||||
127
src/modules/catalog/services/routeSegment.services.ts
Normal file
127
src/modules/catalog/services/routeSegment.services.ts
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import api from '../../../services/api';
|
||||||
|
import type {
|
||||||
|
RouteSegmentCreateRequest,
|
||||||
|
RouteSegmentCreateResponse,
|
||||||
|
RouteSegmentDeleteResponse,
|
||||||
|
RouteSegmentListResponse,
|
||||||
|
RouteSegmentPaginatedResponse,
|
||||||
|
RouteSegmentUpdateRequest,
|
||||||
|
RouteSegmentUpdateResponse,
|
||||||
|
UpdateRiskEvaluationsRequest,
|
||||||
|
RiskFactorsResponse,
|
||||||
|
} from '../types/routeSegments.interfaces';
|
||||||
|
|
||||||
|
const routeSegmentServices = {
|
||||||
|
/**
|
||||||
|
* Listar segmentos de ruta (paginados o todos)
|
||||||
|
*/
|
||||||
|
async getSegments(
|
||||||
|
paginated?: boolean,
|
||||||
|
originName?: string,
|
||||||
|
destinationName?: string,
|
||||||
|
page?: number,
|
||||||
|
perPage?: number,
|
||||||
|
): Promise<RouteSegmentPaginatedResponse | RouteSegmentListResponse> {
|
||||||
|
try {
|
||||||
|
const params: Record<string, any> = {};
|
||||||
|
if (paginated === false) params.paginate = false;
|
||||||
|
if (originName) params.origin_name = originName;
|
||||||
|
if (destinationName) params.destination_name = destinationName;
|
||||||
|
if (page) params.page = page;
|
||||||
|
if (perPage) params.per_page = perPage;
|
||||||
|
|
||||||
|
const response = await api.get('/api/route-segments', { params });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching route segments:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener un segmento por ID
|
||||||
|
*/
|
||||||
|
async getSegmentById(segmentId: number): Promise<RouteSegmentCreateResponse> {
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/api/route-segments/${segmentId}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching route segment with ID ${segmentId}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear un nuevo segmento
|
||||||
|
*/
|
||||||
|
async createSegment(data: RouteSegmentCreateRequest): Promise<RouteSegmentCreateResponse> {
|
||||||
|
try {
|
||||||
|
const response = await api.post('/api/route-segments', data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating route segment:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualizar un segmento existente
|
||||||
|
*/
|
||||||
|
async updateSegment(
|
||||||
|
segmentId: number,
|
||||||
|
data: RouteSegmentUpdateRequest,
|
||||||
|
method: 'patch' | 'put' = 'patch',
|
||||||
|
): Promise<RouteSegmentUpdateResponse> {
|
||||||
|
try {
|
||||||
|
const response = await api[method](`/api/route-segments/${segmentId}`, data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error updating route segment with ID ${segmentId}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eliminar un segmento (soft delete)
|
||||||
|
*/
|
||||||
|
async deleteSegment(segmentId: number): Promise<RouteSegmentDeleteResponse> {
|
||||||
|
try {
|
||||||
|
const response = await api.delete(`/api/route-segments/${segmentId}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error deleting route segment with ID ${segmentId}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener factores de riesgo para un segmento (con evaluación actual)
|
||||||
|
*/
|
||||||
|
async getRiskFactors(segmentId: number): Promise<RiskFactorsResponse> {
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/api/route-segments/${segmentId}/risk-factors`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching risk factors for segment ${segmentId}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guardar/actualizar evaluaciones de riesgo de un segmento
|
||||||
|
*/
|
||||||
|
async updateRiskEvaluations(
|
||||||
|
segmentId: number,
|
||||||
|
data: UpdateRiskEvaluationsRequest,
|
||||||
|
): Promise<RouteSegmentCreateResponse> {
|
||||||
|
try {
|
||||||
|
const response = await api.post(`/api/route-segments/${segmentId}/risk-evaluations`, data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error updating risk evaluations for segment ${segmentId}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export { routeSegmentServices };
|
||||||
45
src/modules/catalog/types/risk-factors.interfaces.ts
Normal file
45
src/modules/catalog/types/risk-factors.interfaces.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface RiskOption {
|
||||||
|
id?: number;
|
||||||
|
risk_factor_id?: number;
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RiskFactor {
|
||||||
|
id?: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
evaluation_context: number;
|
||||||
|
tenant_id?: number;
|
||||||
|
options?: RiskOption[];
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RiskFactorFormErrors {
|
||||||
|
name?: string[];
|
||||||
|
description?: string[];
|
||||||
|
evaluation_context?: string[];
|
||||||
|
options?: string[];
|
||||||
|
[key: string]: string[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RiskFactorPaginatedResponse {
|
||||||
|
current_page: number;
|
||||||
|
data: RiskFactor[];
|
||||||
|
first_page_url: string;
|
||||||
|
from: number;
|
||||||
|
last_page: number;
|
||||||
|
last_page_url: string;
|
||||||
|
links: { url: string | null; label: string; active: boolean }[];
|
||||||
|
next_page_url: string | null;
|
||||||
|
path: string;
|
||||||
|
per_page: number;
|
||||||
|
prev_page_url: string | null;
|
||||||
|
to: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
150
src/modules/catalog/types/routeSegments.interfaces.ts
Normal file
150
src/modules/catalog/types/routeSegments.interfaces.ts
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
import type { Location } from './locations.interfaces';
|
||||||
|
|
||||||
|
// ─── Entidades principales ───────────────────────────────────
|
||||||
|
|
||||||
|
export interface RoutePoint {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavigationStep {
|
||||||
|
maneuver: {
|
||||||
|
instruction: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavigationLeg {
|
||||||
|
steps: NavigationStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RiskOption {
|
||||||
|
id: number;
|
||||||
|
risk_factor_id: number;
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RiskFactor {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
evaluation_context: number;
|
||||||
|
options?: RiskOption[];
|
||||||
|
selected_option_id?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteRiskEvaluation {
|
||||||
|
id: number;
|
||||||
|
route_segment_id: number;
|
||||||
|
risk_factor_id: number;
|
||||||
|
risk_option_id: number;
|
||||||
|
risk_factor?: RiskFactor;
|
||||||
|
risk_option?: RiskOption;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeoJSONLineString {
|
||||||
|
type: 'LineString';
|
||||||
|
coordinates: [number, number][];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteSegment {
|
||||||
|
id: number;
|
||||||
|
origin_location_id: number;
|
||||||
|
destination_location_id: number;
|
||||||
|
sales_service_type_id: number | null;
|
||||||
|
meters_distance: number | null;
|
||||||
|
seconds_duration: number | null;
|
||||||
|
path_geojson: GeoJSONLineString | null;
|
||||||
|
navigation_metadata: NavigationLeg[] | null;
|
||||||
|
route_points: RoutePoint[] | null;
|
||||||
|
tenant_id: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
deleted_at: string | null;
|
||||||
|
// Relaciones cargadas
|
||||||
|
origin?: Location;
|
||||||
|
destination?: Location;
|
||||||
|
risk_evaluations?: RouteRiskEvaluation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Errores de formulario ───────────────────────────────────
|
||||||
|
|
||||||
|
export interface RouteSegmentFormErrors {
|
||||||
|
origin_location_id?: string[];
|
||||||
|
destination_location_id?: string[];
|
||||||
|
sales_service_type_id?: string[];
|
||||||
|
meters_distance?: string[];
|
||||||
|
seconds_duration?: string[];
|
||||||
|
path_geometry?: string[];
|
||||||
|
navigation_metadata?: string[];
|
||||||
|
route_points?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Paginación ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PaginationLink {
|
||||||
|
url: string | null;
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteSegmentPaginatedResponse {
|
||||||
|
current_page: number;
|
||||||
|
data: RouteSegment[];
|
||||||
|
first_page_url: string;
|
||||||
|
from: number;
|
||||||
|
last_page: number;
|
||||||
|
last_page_url: string;
|
||||||
|
links: PaginationLink[];
|
||||||
|
next_page_url: string | null;
|
||||||
|
path: string;
|
||||||
|
per_page: number;
|
||||||
|
prev_page_url: string | null;
|
||||||
|
to: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteSegmentListResponse {
|
||||||
|
data: RouteSegment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Request/Response ────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface RouteSegmentCreateRequest {
|
||||||
|
origin_location_id: number;
|
||||||
|
destination_location_id: number;
|
||||||
|
sales_service_type_id?: number | null;
|
||||||
|
meters_distance?: number | null;
|
||||||
|
seconds_duration?: number | null;
|
||||||
|
path_geometry?: GeoJSONLineString | null;
|
||||||
|
navigation_metadata?: NavigationLeg[] | null;
|
||||||
|
route_points?: RoutePoint[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RouteSegmentUpdateRequest = Partial<RouteSegmentCreateRequest>;
|
||||||
|
|
||||||
|
export interface RouteSegmentCreateResponse {
|
||||||
|
data: RouteSegment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RouteSegmentUpdateResponse = RouteSegmentCreateResponse;
|
||||||
|
|
||||||
|
export interface RouteSegmentDeleteResponse {
|
||||||
|
message: string;
|
||||||
|
data: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Evaluaciones de Riesgo ──────────────────────────────────
|
||||||
|
|
||||||
|
export interface RiskEvaluationPayload {
|
||||||
|
risk_factor_id: number;
|
||||||
|
risk_option_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateRiskEvaluationsRequest {
|
||||||
|
evaluations: RiskEvaluationPayload[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RiskFactorsResponse {
|
||||||
|
data: RiskFactor[];
|
||||||
|
}
|
||||||
77
src/modules/catalog/types/routes.interfaces.ts
Normal file
77
src/modules/catalog/types/routes.interfaces.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import type { Location } from './locations.interfaces';
|
||||||
|
import type { RouteSegment } from './routeSegments.interfaces';
|
||||||
|
|
||||||
|
export interface RouteCheckpoint {
|
||||||
|
id: number;
|
||||||
|
route_id: number;
|
||||||
|
location_id: number;
|
||||||
|
sequence: number;
|
||||||
|
location?: Location;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Route {
|
||||||
|
id: number;
|
||||||
|
sales_service_type_id: number | null;
|
||||||
|
meters_distance: number | null;
|
||||||
|
seconds_duration: number | null;
|
||||||
|
active: boolean;
|
||||||
|
tenant_id: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
deleted_at: string | null;
|
||||||
|
origin?: Location;
|
||||||
|
destination?: Location;
|
||||||
|
checkpoints?: RouteCheckpoint[];
|
||||||
|
segments?: RouteSegment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteCreateRequest {
|
||||||
|
sales_service_type_id?: number | null;
|
||||||
|
active?: boolean;
|
||||||
|
route_segments: number[]; // Array of RouteSegment IDs
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RouteUpdateRequest = Partial<RouteCreateRequest>;
|
||||||
|
|
||||||
|
export interface RouteFormErrors {
|
||||||
|
sales_service_type_id?: string[];
|
||||||
|
active?: string[];
|
||||||
|
route_segments?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationLink {
|
||||||
|
url: string | null;
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoutePaginatedResponse {
|
||||||
|
current_page: number;
|
||||||
|
data: Route[];
|
||||||
|
first_page_url: string;
|
||||||
|
from: number;
|
||||||
|
last_page: number;
|
||||||
|
last_page_url: string;
|
||||||
|
links: PaginationLink[];
|
||||||
|
next_page_url: string | null;
|
||||||
|
path: string;
|
||||||
|
per_page: number;
|
||||||
|
prev_page_url: string | null;
|
||||||
|
to: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteListResponse {
|
||||||
|
data: Route[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteCreateResponse {
|
||||||
|
data: Route;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RouteUpdateResponse = RouteCreateResponse;
|
||||||
|
|
||||||
|
export interface RouteDeleteResponse {
|
||||||
|
message: string;
|
||||||
|
data: null;
|
||||||
|
}
|
||||||
@ -32,6 +32,8 @@ import Companies from '../modules/catalog/components/companies/Companies.vue';
|
|||||||
import '../modules/catalog/components/suppliers/Suppliers.vue';
|
import '../modules/catalog/components/suppliers/Suppliers.vue';
|
||||||
import Suppliers from '../modules/catalog/components/suppliers/Suppliers.vue';
|
import Suppliers from '../modules/catalog/components/suppliers/Suppliers.vue';
|
||||||
import Locations from '../modules/catalog/components/locations/Locations.vue';
|
import Locations from '../modules/catalog/components/locations/Locations.vue';
|
||||||
|
import RouteSegments from '../modules/catalog/components/route-segments/RouteSegments.vue';
|
||||||
|
import Routes from '../modules/catalog/components/routes/Routes.vue';
|
||||||
import Purchases from '../modules/purchases/components/Purchases.vue';
|
import Purchases from '../modules/purchases/components/Purchases.vue';
|
||||||
import PurchaseDetails from '../modules/purchases/components/PurchaseDetails.vue';
|
import PurchaseDetails from '../modules/purchases/components/PurchaseDetails.vue';
|
||||||
import PurchaseForm from '../modules/purchases/components/PurchaseForm.vue';
|
import PurchaseForm from '../modules/purchases/components/PurchaseForm.vue';
|
||||||
@ -205,6 +207,24 @@ const routes: RouteRecordRaw[] = [
|
|||||||
requiresAuth: true
|
requiresAuth: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'route-segments',
|
||||||
|
name: 'RouteSegments',
|
||||||
|
component: RouteSegments,
|
||||||
|
meta: {
|
||||||
|
title: 'Segmentos de Ruta',
|
||||||
|
requiresAuth: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'routes',
|
||||||
|
name: 'Routes',
|
||||||
|
component: Routes,
|
||||||
|
meta: {
|
||||||
|
title: 'Catálogo de Rutas',
|
||||||
|
requiresAuth: true
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'model-documents',
|
path: 'model-documents',
|
||||||
name: 'ModelDocuments',
|
name: 'ModelDocuments',
|
||||||
@ -224,6 +244,16 @@ const routes: RouteRecordRaw[] = [
|
|||||||
permission: 'document_concepts.index'
|
permission: 'document_concepts.index'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'risk-factors',
|
||||||
|
name: 'RiskFactors',
|
||||||
|
component: () => import('@/modules/catalog/components/risk-factors/RiskFactors.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Factores de Riesgo',
|
||||||
|
requiresAuth: true,
|
||||||
|
permission: 'risk-factors.index'
|
||||||
|
}
|
||||||
|
},
|
||||||
companiesRouter
|
companiesRouter
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user