-
-
-
Cargando empleados activos...{{ activeEmployees }} empleados activos de este proyecto se usarán para planificar.
-
-
-
-
-
-
- {{ uploadError }}
-
-
+
-
-
@@ -112,6 +60,16 @@
+
+
diff --git a/route-planner/src/app/private/home/home.component.ts b/route-planner/src/app/private/home/home.component.ts
index b5286a8..4b108d3 100644
--- a/route-planner/src/app/private/home/home.component.ts
+++ b/route-planner/src/app/private/home/home.component.ts
@@ -8,6 +8,7 @@ import { ApiResponse } from '../../core/models/route.model';
import { ExcelPreviewService, PreviewResult } from '../../shared/services/excel-preview.service';
import { Subscription } from 'rxjs';
import { PlanningKpisComponent } from './planning-kpis/planning-kpis.component';
+import { RouteOverviewMapComponent } from './route-overview-map/route-overview-map.component';
import { PlanningInputService } from '../../core/services/planning-input.service';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { EmployeeService } from '../../core/services/employee.service';
@@ -19,17 +20,14 @@ import { PlanningFilterKey, PlanningFilters, PlanningFilterService } from '../..
@Component({
standalone: true,
selector: 'app-home',
- imports: [CommonModule, FormsModule, RouterLink, NoAssignedRouteComponent, PlanningKpisComponent],
+ imports: [CommonModule, FormsModule, RouterLink, NoAssignedRouteComponent, PlanningKpisComponent, RouteOverviewMapComponent],
templateUrl: './home.component.html',
styleUrls: ['./home.component.css']
})
export class HomeComponent implements OnInit {
pdvFile: File | null = null;
- usuariosFile: File | null = null;
pdvPreview: PreviewResult | null = null;
- usuariosPreview: PreviewResult | null = null;
pdvPreviewLoading = false;
- usuariosPreviewLoading = false;
activeEmployees = 0;
employeesLoading = true;
uploadError: string | null = null;
@@ -40,12 +38,35 @@ export class HomeComponent implements OnInit {
projectDescription = '';
projectCountry = '';
projectSaving = false;
- inputsExpanded = true;
+ fuelRendimiento: number | null = null;
+ fuelPrecio: number | null = null;
filtersExpanded = true;
- unassignedExpanded = false;
+ unassignedExpanded = true;
planningFilters: PlanningFilters = { cobertura: '', zona: '', region: '', estado: '', ciudad: '' };
+ submitDisabled = true;
+ submitHint: string | null = 'Sube el archivo PDV';
private calcSub: Subscription | null = null;
+ private safeDetectChanges(): void {
+ setTimeout(() => {
+ try { this.cdr.detectChanges(); } catch { /* ignore destroyed view */ }
+ });
+ }
+
+ private updateSubmitState(): void {
+ this.submitDisabled = !this.canSubmit;
+ this.submitHint = this.computeSubmitHint();
+ }
+
+ private computeSubmitHint(): string | null {
+ if (!this.pdvFile) return 'Sube el archivo PDV';
+ if (this.employeesLoading) return 'Cargando empleados...';
+ if (!this.activeEmployees) return 'No hay empleados activos. Regístralos en Gestionar empleados.';
+ if (this.pdvPreviewLoading) return 'Validando archivo PDV...';
+ if (this.pdvPreview && !this.pdvPreview.valid) return 'Corrige los errores del archivo PDV';
+ return null;
+ }
+
constructor(
private rutasService: RutasService,
private routeState: RouteState,
@@ -82,13 +103,15 @@ export class HomeComponent implements OnInit {
this.projectName = project.nombre;
this.projectDescription = project.descripcion ?? '';
this.projectCountry = input.country;
+ this.fuelRendimiento = project.rendimiento_km_l ?? null;
+ this.fuelPrecio = project.precio_por_litro ?? null;
this.planningFilter.setPdvs(input.rows);
this.pdvFile = input.file;
this.pdvPreview = await this.previewService.previewFile(input.file, 'PDV');
} catch (error: any) {
this.uploadError = error?.message ?? 'No se pudo cargar el proyecto.';
} finally {
- this.cdr.detectChanges();
+ this.updateSubmitState(); this.safeDetectChanges();
}
}
@@ -98,9 +121,22 @@ export class HomeComponent implements OnInit {
async saveProject(): Promise {
if (!this.projectId || !this.projectName.trim()) return;
+ if (this.fuelRendimiento != null && !(this.fuelRendimiento > 0)) {
+ this.uploadError = 'El rendimiento debe ser mayor a 0 km/L.';
+ return;
+ }
+ if (this.fuelPrecio != null && !(this.fuelPrecio >= 0)) {
+ this.uploadError = 'El precio por litro no puede ser negativo.';
+ return;
+ }
this.projectSaving = true;
try {
- await this.projectsService.update(this.projectId, { nombre: this.projectName.trim(), descripcion: this.projectDescription.trim() || null });
+ await this.projectsService.update(this.projectId, {
+ nombre: this.projectName.trim(),
+ descripcion: this.projectDescription.trim() || null,
+ rendimiento_km_l: this.fuelRendimiento,
+ precio_por_litro: this.fuelPrecio
+ });
this.editingProject = false;
} catch (error: any) { this.uploadError = error?.message ?? 'No se pudo actualizar el proyecto.'; }
finally { this.projectSaving = false; this.cdr.detectChanges(); }
@@ -111,7 +147,6 @@ export class HomeComponent implements OnInit {
filterOptions(key: PlanningFilterKey): string[] { return this.planningFilter.options(key); }
applyPlanningFilters(): void { this.planningFilter.set(this.planningFilters); }
clearPlanningFilters(): void { this.planningFilters = { cobertura: '', zona: '', region: '', estado: '', ciudad: '' }; this.applyPlanningFilters(); }
- toggleInputs(): void { this.inputsExpanded = !this.inputsExpanded; }
toggleFilters(): void { this.filtersExpanded = !this.filtersExpanded; }
toggleUnassigned(): void { this.unassignedExpanded = !this.unassignedExpanded; }
@@ -123,104 +158,10 @@ export class HomeComponent implements OnInit {
this.activeEmployees = 0;
} finally {
this.employeesLoading = false;
- this.cdr.detectChanges();
+ this.updateSubmitState(); this.safeDetectChanges();
}
}
- async onPdvFileChange(event: Event) {
- const input = event.target as HTMLInputElement;
- if (input.files && input.files.length > 0) {
- const file = input.files[0];
- const basicError = this.validateFileBasic(file, 'PDV');
- if (basicError) {
- this.pdvFile = null;
- this.pdvPreview = null;
- this.uploadError = basicError;
- return;
- }
- this.pdvFile = file;
- if (this.usuariosFile) this.planningInput.setFiles(this.projectId, file, this.usuariosFile);
- this.uploadError = null;
- this.pdvPreviewLoading = true;
- this.pdvPreview = null;
- this.cdr.detectChanges();
- try {
- this.pdvPreview = await this.previewService.previewFile(file, 'PDV');
- } catch (e: unknown) {
- const msg = e instanceof Error ? e.message : 'Error al leer el archivo PDV';
- this.pdvPreview = {
- fileName: file.name,
- type: 'PDV',
- valid: false,
- totalRows: 0,
- validRows: 0,
- headers: [],
- normalizedHeaders: [],
- missingHeaders: [],
- errors: [{ row: 1, field: 'file', message: msg }],
- duplicateIds: [],
- sampleValidRows: [],
- };
- } finally {
- this.pdvPreviewLoading = false;
- this.cdr.detectChanges();
- }
- }
- }
-
- async onUsuariosFileChange(event: Event) {
- const input = event.target as HTMLInputElement;
- if (input.files && input.files.length > 0) {
- const file = input.files[0];
- const basicError = this.validateFileBasic(file, 'Usuarios');
- if (basicError) {
- this.usuariosFile = null;
- this.usuariosPreview = null;
- this.uploadError = basicError;
- return;
- }
- this.usuariosFile = file;
- if (this.pdvFile) this.planningInput.setFiles(this.projectId, this.pdvFile, file);
- this.uploadError = null;
- this.usuariosPreviewLoading = true;
- this.usuariosPreview = null;
- this.cdr.detectChanges();
- try {
- this.usuariosPreview = await this.previewService.previewFile(file, 'Usuarios');
- } catch (e: unknown) {
- const msg = e instanceof Error ? e.message : 'Error al leer el archivo Usuarios';
- this.usuariosPreview = {
- fileName: file.name,
- type: 'Usuarios',
- valid: false,
- totalRows: 0,
- validRows: 0,
- headers: [],
- normalizedHeaders: [],
- missingHeaders: [],
- errors: [{ row: 1, field: 'file', message: msg }],
- duplicateIds: [],
- sampleValidRows: [],
- };
- } finally {
- this.usuariosPreviewLoading = false;
- this.cdr.detectChanges();
- }
- }
- }
-
- private validateFileBasic(file: File, type: string): string | null {
- const validExtensions = ['.xlsx', '.xls'];
- const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
- if (!validExtensions.includes(ext)) {
- return `El archivo ${type} debe ser .xlsx o .xls`;
- }
- if (file.size > 10 * 1024 * 1024) {
- return `El archivo ${type} supera 10MB`;
- }
- return null;
- }
-
get canSubmit(): boolean {
if (!this.pdvFile || !this.activeEmployees) return false;
if (this.pdvPreviewLoading || this.employeesLoading) return false;
@@ -254,7 +195,7 @@ export class HomeComponent implements OnInit {
this.uploadError = 'No se pudo preparar el archivo PDV para el optimizador.';
this.routeState.setLoading(false);
this.isLoading = false;
- this.cdr.detectChanges();
+ this.updateSubmitState(); this.safeDetectChanges();
return;
}
let employeesFile: File;
@@ -282,7 +223,7 @@ export class HomeComponent implements OnInit {
this.uploadError = String((anyRes as { error: unknown }).error);
this.routeState.setLoading(false);
this.isLoading = false;
- this.cdr.detectChanges();
+ this.updateSubmitState(); this.safeDetectChanges();
return;
}
const payload = Array.isArray(res)
@@ -294,7 +235,7 @@ export class HomeComponent implements OnInit {
this.uploadError = 'El optimizador devolvió una respuesta vacía. Verifica que el workflow de n8n esté publicado.';
this.routeState.setLoading(false);
this.isLoading = false;
- this.cdr.detectChanges();
+ this.updateSubmitState(); this.safeDetectChanges();
return;
}
const assigned = payload.find(r => r && typeof r === 'object' && 'assigned' in r) as { assigned: unknown } | undefined;
@@ -303,7 +244,7 @@ export class HomeComponent implements OnInit {
this.uploadError = 'El optimizador devolvió un formato de respuesta no válido. Verifica el workflow de n8n.';
this.routeState.setLoading(false);
this.isLoading = false;
- this.cdr.detectChanges();
+ this.updateSubmitState(); this.safeDetectChanges();
return;
}
if (assigned) {
@@ -314,13 +255,13 @@ export class HomeComponent implements OnInit {
}
this.routeState.setLoading(false);
this.isLoading = false;
- this.cdr.detectChanges();
+ this.updateSubmitState(); this.safeDetectChanges();
},
error: (err) => {
this.uploadError = this.mapBackendError(err);
this.routeState.setLoading(false);
this.isLoading = false;
- this.cdr.detectChanges();
+ this.updateSubmitState(); this.safeDetectChanges();
}
});
}
@@ -347,17 +288,12 @@ export class HomeComponent implements OnInit {
this.planningInput.clear(this.projectId);
this.routeState.clearAll();
this.pdvFile = null;
- this.usuariosFile = null;
this.pdvPreview = null;
- this.usuariosPreview = null;
this.pdvPreviewLoading = false;
- this.usuariosPreviewLoading = false;
this.uploadError = null;
this.isLoading = false;
this.routeState.setLoading(false);
- const pdvInput = document.getElementById('pdvFile') as HTMLInputElement;
- const usuariosInput = document.getElementById('usuariosFile') as HTMLInputElement;
- if (pdvInput) pdvInput.value = '';
- if (usuariosInput) usuariosInput.value = '';
+ this.updateSubmitState();
+ this.safeDetectChanges();
}
}
diff --git a/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.ts b/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.ts
index f16a89a..e0c3db8 100644
--- a/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.ts
+++ b/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.ts
@@ -27,6 +27,19 @@ export class NoAssignedRouteComponent implements OnDestroy {
assignmentDays: Record = {};
assigningId: number | null = null;
actionError: string | null = null;
+ userOptions: { id: string; name: string }[] = [];
+ dayOptions: string[] = [];
+
+ private safeDetectChanges(): void {
+ setTimeout(() => {
+ try { this.cdr.detectChanges(); } catch { /* ignore destroyed view */ }
+ });
+ }
+
+ private refreshOptions(): void {
+ this.userOptions = this.routesState.getUsuariosUnicos().map(user => ({ id: user.id, name: user.nombre }));
+ this.dayOptions = this.routesState.getDiasUnicos();
+ }
constructor(
private routesState: RouteState,
@@ -36,22 +49,24 @@ export class NoAssignedRouteComponent implements OnDestroy {
) {}
ngOnInit() {
+ this.refreshOptions();
this.subs.push(
this.routesState.loading$.subscribe(value => {
this.isLoading = value;
- this.cdr.detectChanges();
+ this.safeDetectChanges();
})
);
this.subs.push(
this.routesState.noAssignedRoutes$.subscribe(data => {
this.allNoAssignedRoutes = data.map(route => ({ ...route, recomendacion: route.recomendacion ?? recommendationForUnassigned(route) }));
this.noAssignedRoutes = this.filters.filterUnassigned(this.allNoAssignedRoutes);
- this.cdr.detectChanges();
+ this.refreshOptions();
+ this.safeDetectChanges();
})
);
this.subs.push(this.filters.filters$.subscribe(() => {
this.noAssignedRoutes = this.filters.filterUnassigned(this.allNoAssignedRoutes);
- this.cdr.detectChanges();
+ this.safeDetectChanges();
}));
this.cols = [
{ field: 'pdv_id', header: 'PDV ID' },
@@ -64,13 +79,7 @@ export class NoAssignedRouteComponent implements OnDestroy {
];
}
- get userOptions(): { id: string; name: string }[] {
- return this.routesState.getUsuariosUnicos().map(user => ({ id: user.id, name: user.nombre }));
- }
- get dayOptions(): string[] {
- return this.routesState.getDiasUnicos();
- }
toggleDay(pdvId: number, day: string, checked: boolean): void {
const key = String(pdvId);
@@ -113,7 +122,8 @@ export class NoAssignedRouteComponent implements OnDestroy {
this.actionError = `No se pudo calcular la ruta vial para ${route.pdv_nombre}.`;
} finally {
this.assigningId = null;
- this.cdr.detectChanges();
+ this.refreshOptions();
+ this.safeDetectChanges();
}
}
diff --git a/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.css b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.css
index 6492f8a..846cc10 100644
--- a/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.css
+++ b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.css
@@ -88,6 +88,7 @@
.kpi-warning { border-left-color: var(--glm-naranja); }
.kpi-blue { border-left-color: var(--glm-acero); }
.kpi-orange { border-left-color: var(--glm-naranja); }
+.kpi-fuel { border-left-color: var(--glm-verde); }
.kpi-icon {
display: grid;
diff --git a/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.html b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.html
index 12f7ba5..02cd8fd 100644
--- a/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.html
+++ b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.html
@@ -23,9 +23,9 @@
class="export-button"
*ngIf="hasData"
(click)="downloadRoutes()"
- [disabled]="pdfLoading"
+ [disabled]="exportLoading"
>
- {{ pdfLoading ? "Generando PDF..." : "Descargar rutas" }} {{ exportLoading ? "Generando Excel..." : "Descargar rutas" }}
-
- {{ reoptError || editError || pdfError }}
+
+ {{ reoptError || editError || exportError }}
+
+
+
+ Combustible{{ summary.fuelLiters | number: "1.0-1" }} L—{{ fuelCurrency }} {{ summary.fuelCost | number: "1.2-2" }}Sin configurar en el proyecto
+
+
@@ -119,6 +130,7 @@
Visitas |
Distancia |
Trabajo |
+ Costo comb. |
Carga |
|
@@ -142,6 +154,8 @@
{{ load.visits }} |
{{ load.distance | number: "1.0-1" }} km |
{{ load.workHours | number: "1.0-1" }} h |
+ {{ fuelCurrency }} {{ load.fuelCost | number: "1.2-2" }} |
+ — |
- |
+ |
diff --git a/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.ts b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.ts
index e7f3cd8..95b097c 100644
--- a/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.ts
+++ b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.ts
@@ -4,17 +4,19 @@ import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { Subscription } from 'rxjs';
import { RouteState } from '../../../core/route-state/route';
-import { RoutePdfReportService } from '../../../shared/services/route-pdf-report.service';
+import { PlanningExportService } from '../../../shared/services/planning-export.service';
import { summarizeDailyRoutes } from '../../../shared/utils/route-sequencing.utils';
import { RouteAssignedResponse } from '../../../core/models/route.model';
import { PlanningFilterService } from '../../../core/services/planning-filter.service';
import { RutasService } from '../../../core/services/router.service';
import { PlanningInputService } from '../../../core/services/planning-input.service';
+import { ProjectService } from '../../../core/services/project.service';
+import { currencyForCountry, fuelCost, isFuelConfigured, type FuelConfig } from '../../../shared/utils/fuel-estimation.utils';
-interface KpiSummary { totalPdvs: number; assignedPdvs: number; unassignedPdvs: number; coverage: number; users: number; days: number; totalDistance: number; averageDistance: number; totalWorkHours: number; }
+interface KpiSummary { totalPdvs: number; assignedPdvs: number; unassignedPdvs: number; coverage: number; users: number; days: number; totalDistance: number; averageDistance: number; totalWorkHours: number; fuelLiters: number; fuelCost: number; }
interface ReasonSummary { reason: string; count: number; }
interface UserLoad {
- id: string; name: string; uniquePdvs: number; visits: number; routes: RouteAssignedResponse[]; distance: number; workHours: number;
+ id: string; name: string; uniquePdvs: number; visits: number; routes: RouteAssignedResponse[]; distance: number; workHours: number; fuelLiters: number; fuelCost: number;
capacityHours: number; utilization: number; status: 'ok' | 'warning' | 'over-capacity'; alertMessage: string;
}
@@ -35,44 +37,64 @@ export class PlanningKpisComponent implements OnInit, OnDestroy {
reoptLoading = false;
reoptError = '';
hasLocalChanges = false;
- pdfLoading = false;
- pdfError = '';
+ exportLoading = false;
+ exportError = '';
readonly userLoadPageSize = 14;
userLoadPage = 1;
+ fuelConfig: FuelConfig = { rendimientoKmL: null, precioPorLitro: null };
+ fuelCurrency = '$';
+ fuelConfigured = false;
private readonly subs: Subscription[] = [];
constructor(
private routeState: RouteState,
private filters: PlanningFilterService,
- private pdfReport: RoutePdfReportService,
+ private excelExport: PlanningExportService,
private rutasService: RutasService,
private planningInput: PlanningInputService,
+ private projects: ProjectService,
private router: Router,
private activatedRoute: ActivatedRoute,
private cdr: ChangeDetectorRef
) {}
ngOnInit(): void {
+ void this.loadFuelConfig();
this.subs.push(
this.routeState.assignedRoutes$.subscribe(() => this.refresh()),
this.routeState.noAssignedRoutes$.subscribe(() => this.refresh()),
this.filters.filters$.subscribe(() => this.refresh())
);
}
+
+ private async loadFuelConfig(): Promise {
+ try {
+ const project = await this.projects.get(this.projectId);
+ this.fuelConfig = { rendimientoKmL: project.rendimiento_km_l ?? null, precioPorLitro: project.precio_por_litro ?? null };
+ this.fuelCurrency = currencyForCountry(project.pais);
+ } catch {
+ this.fuelConfig = { rendimientoKmL: null, precioPorLitro: null };
+ this.fuelCurrency = '$';
+ } finally {
+ this.fuelConfigured = isFuelConfigured(this.fuelConfig);
+ this.refresh();
+ this.cdr.detectChanges();
+ }
+ }
ngOnDestroy(): void { this.subs.forEach(subscription => subscription.unsubscribe()); }
barWidth(value: number): number { return Math.min(Math.max(value, 0), 100); }
utilizationClass(load: UserLoad): string { return load.status; }
async downloadRoutes(): Promise {
- if (this.pdfLoading || !this.hasData) return;
- this.pdfLoading = true;
- this.pdfError = '';
+ if (this.exportLoading || !this.hasData) return;
+ this.exportLoading = true;
+ this.exportError = '';
try {
- await this.pdfReport.download(this.projectId);
+ await this.excelExport.export(this.projectId);
} catch (error: any) {
- this.pdfError = error?.message ?? 'No se pudo generar el PDF de rutas.';
+ this.exportError = error?.message ?? 'No se pudo generar el Excel de rutas.';
} finally {
- this.pdfLoading = false;
+ this.exportLoading = false;
this.cdr.detectChanges();
}
}
@@ -138,7 +160,7 @@ export class PlanningKpisComponent implements OnInit, OnDestroy {
const loads = new Map();
for (const route of assigned) {
const id = String(route.json.usuario_id);
- const load = loads.get(id) ?? { id, name: route.json.nombre_usuario, uniquePdvs: 0, visits: 0, routes: [], distance: 0, workHours: 0, capacityHours: this.numberValue(route.json.hora_laboral_semanal_usuario), utilization: 0, status: 'ok', alertMessage: '' };
+ const load = loads.get(id) ?? { id, name: route.json.nombre_usuario, uniquePdvs: 0, visits: 0, routes: [], distance: 0, workHours: 0, fuelLiters: 0, fuelCost: 0, capacityHours: this.numberValue(route.json.hora_laboral_semanal_usuario), utilization: 0, status: 'ok', alertMessage: '' };
load.routes.push(route); load.visits++;
loads.set(id, load);
}
@@ -148,13 +170,18 @@ export class PlanningKpisComponent implements OnInit, OnDestroy {
const userDays = dailyRoutes.filter(day => day.userId === load.id);
load.distance = userDays.reduce((sum, day) => sum + day.distanceKm, 0);
load.workHours = userDays.reduce((sum, day) => sum + day.workHours, 0);
+ const fuel = fuelCost(load.distance, this.fuelConfig);
+ load.fuelLiters = fuel.liters;
+ load.fuelCost = fuel.cost;
load.utilization = load.capacityHours ? load.workHours / load.capacityHours * 100 : 0;
load.status = load.utilization > 100 ? 'over-capacity' : load.utilization > 90 ? 'warning' : 'ok';
load.alertMessage = `${load.name}: ${load.workHours.toFixed(1)} h de ${load.capacityHours.toFixed(1)} h.`;
return load;
}).sort((a, b) => b.visits - a.visits || b.workHours - a.workHours);
this.userLoadPage = Math.min(this.userLoadPage, this.userLoadPages);
- this.summary = { totalPdvs, assignedPdvs: assignedPdvIds.size, unassignedPdvs: unassignedPdvIds.size, coverage: totalPdvs ? assignedPdvIds.size / totalPdvs * 100 : 0, users: loads.size, days: new Set(assigned.map(route => route.json.dia)).size, totalDistance, averageDistance: assignedPdvIds.size ? totalDistance / assignedPdvIds.size : 0, totalWorkHours };
+ const fuelTotal = fuelCost(totalDistance, this.fuelConfig);
+ this.fuelConfigured = isFuelConfigured(this.fuelConfig);
+ this.summary = { totalPdvs, assignedPdvs: assignedPdvIds.size, unassignedPdvs: unassignedPdvIds.size, coverage: totalPdvs ? assignedPdvIds.size / totalPdvs * 100 : 0, users: loads.size, days: new Set(assigned.map(route => route.json.dia)).size, totalDistance, averageDistance: assignedPdvIds.size ? totalDistance / assignedPdvIds.size : 0, totalWorkHours, fuelLiters: fuelTotal.liters, fuelCost: fuelTotal.cost };
const reasonByPdv = new Map();
for (const route of unassigned) if (!assignedPdvIds.has(String(route.pdv_id)) && !reasonByPdv.has(String(route.pdv_id))) reasonByPdv.set(String(route.pdv_id), route.motivo?.trim() || 'Sin motivo especificado');
const counts = new Map(); for (const reason of reasonByPdv.values()) counts.set(reason, (counts.get(reason) ?? 0) + 1);
@@ -162,5 +189,5 @@ export class PlanningKpisComponent implements OnInit, OnDestroy {
this.hasData = totalPdvs > 0;
}
private numberValue(value: number | string | null | undefined): number { const result = Number(value); return Number.isFinite(result) ? result : 0; }
- private emptySummary(): KpiSummary { return { totalPdvs: 0, assignedPdvs: 0, unassignedPdvs: 0, coverage: 0, users: 0, days: 0, totalDistance: 0, averageDistance: 0, totalWorkHours: 0 }; }
+ private emptySummary(): KpiSummary { return { totalPdvs: 0, assignedPdvs: 0, unassignedPdvs: 0, coverage: 0, users: 0, days: 0, totalDistance: 0, averageDistance: 0, totalWorkHours: 0, fuelLiters: 0, fuelCost: 0 }; }
}
diff --git a/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.css b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.css
index 97d0ae3..e4b1880 100644
--- a/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.css
+++ b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.css
@@ -115,6 +115,41 @@
box-shadow: 0 0 0 2px rgba(108, 194, 74, 0.15);
}
+.segmented-control {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ gap: 0;
+ overflow: hidden;
+ border: 1px solid var(--glm-gris-medio);
+ border-radius: 4px;
+ background: #fff;
+}
+
+.segmented-control button {
+ padding: 8px 4px;
+ border: 0;
+ border-right: 1px solid var(--glm-gris-medio);
+ background: #fff;
+ color: var(--glm-acero);
+ cursor: pointer;
+ font: inherit;
+ font-size: 0.78rem;
+ font-weight: 700;
+}
+
+.segmented-control button:last-child {
+ border-right: 0;
+}
+
+.segmented-control button:hover {
+ background: var(--glm-verde-bg);
+}
+
+.segmented-control button.active {
+ background: var(--glm-verde);
+ color: #fff;
+}
+
.filter-stats {
display: flex;
gap: 10px;
@@ -310,6 +345,13 @@
text-transform: uppercase;
}
+.visit-group-title {
+ margin: 8px 0 4px;
+ color: var(--glm-azul);
+ font-size: 0.76rem;
+ font-weight: 700;
+}
+
.reassign-form {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -506,3 +548,62 @@
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
font-family: Arial, sans-serif;
}
+
+:host.embedded {
+ height: 620px;
+ min-height: 480px;
+ overflow: hidden;
+ border: 1px solid var(--glm-gris-medio);
+ border-radius: 4px;
+ background: #fff;
+}
+
+:host.embedded #map-overview {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 340px;
+ height: 100%;
+}
+
+:host.embedded .overview-sidebar {
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ width: 340px;
+ z-index: 500;
+}
+
+:host.embedded .unassigned-bottom-drawer {
+ position: absolute;
+ left: 352px;
+ right: 12px;
+ bottom: 12px;
+ z-index: 600;
+}
+
+@media (max-width: 1100px) {
+ :host.embedded {
+ height: auto;
+ min-height: 0;
+ }
+ :host.embedded #map-overview {
+ position: relative;
+ left: 0;
+ height: 420px;
+ }
+ :host.embedded .overview-sidebar {
+ position: relative;
+ width: 100%;
+ max-height: none;
+ }
+ :host.embedded .unassigned-bottom-drawer {
+ position: relative;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ margin: 0 12px 12px;
+ }
+}
diff --git a/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.html b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.html
index 129d7d1..546989a 100644
--- a/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.html
+++ b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.html
@@ -6,12 +6,12 @@
| |