Compare commits
8 Commits
e9cb1edf59
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| bb6757b492 | |||
| 8f34462876 | |||
| 540bc46fc2 | |||
| 7d696e6f16 | |||
| 65fe788281 | |||
| 9f42175afc | |||
| cd5c57c5ee | |||
| a0ea3fea48 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -18,16 +18,27 @@
|
||||
"polyfills": ["zone.js"],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"assets": [{ "glob": "**/*", "input": "public" }],
|
||||
"styles": ["src/styles.css"],
|
||||
"styles": [
|
||||
"src/styles.css",
|
||||
"node_modules/leaflet/dist/leaflet.css",
|
||||
"node_modules/leaflet.markercluster/dist/MarkerCluster.css",
|
||||
"node_modules/leaflet.markercluster/dist/MarkerCluster.Default.css"
|
||||
],
|
||||
"scripts": []
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{ "type": "initial", "maximumWarning": "1.5MB", "maximumError": "2MB" },
|
||||
{ "type": "anyComponentStyle", "maximumWarning": "4kB", "maximumError": "8kB" }
|
||||
{ "type": "initial", "maximumWarning": "2.1MB", "maximumError": "3MB" },
|
||||
{ "type": "anyComponentStyle", "maximumWarning": "6kB", "maximumError": "12kB" }
|
||||
],
|
||||
"outputHashing": "all"
|
||||
"outputHashing": "all",
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.prod.ts"
|
||||
}
|
||||
]
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
|
||||
Generated
+16
@@ -17,6 +17,8 @@
|
||||
"@primeng/themes": "^21.0.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet.heat": "^0.2.0",
|
||||
"leaflet.markercluster": "^1.5.3",
|
||||
"primeicons": "^7.0.0",
|
||||
"primeng": "^21.0.0",
|
||||
"rxjs": "~7.8.2",
|
||||
@@ -6226,6 +6228,20 @@
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/leaflet.heat": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/leaflet.heat/-/leaflet.heat-0.2.0.tgz",
|
||||
"integrity": "sha512-Cd5PbAA/rX3X3XKxfDoUGi9qp78FyhWYurFg3nsfhntcM/MCNK08pRkf4iEenO1KNqwVPKCmkyktjW3UD+h9bQ=="
|
||||
},
|
||||
"node_modules/leaflet.markercluster": {
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz",
|
||||
"integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/listr2": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz",
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
"@primeng/themes": "^21.0.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet.heat": "^0.2.0",
|
||||
"leaflet.markercluster": "^1.5.3",
|
||||
"primeicons": "^7.0.0",
|
||||
"primeng": "^21.0.0",
|
||||
"rxjs": "~7.8.2",
|
||||
|
||||
@@ -2,11 +2,13 @@ import { Routes } from '@angular/router';
|
||||
import { LoginComponent } from './public/login/login.component';
|
||||
import { HomeComponent } from './private/home/home.component';
|
||||
import { AssignedRouteDetailComponent } from './private/home/assigned-route-detail/assigned-route-detail.component';
|
||||
import { RouteOverviewMapComponent } from './private/home/route-overview-map/route-overview-map.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', redirectTo: 'login', pathMatch: 'full' },
|
||||
{ path: 'login', component: LoginComponent },
|
||||
{ path: 'home', component: HomeComponent, pathMatch: 'full' },
|
||||
{ path: 'home/assigned-route', component: AssignedRouteDetailComponent },
|
||||
{ path: 'home/mapa', component: RouteOverviewMapComponent },
|
||||
{ path: '**', redirectTo: 'login' }
|
||||
];
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface RouteAssignedJson {
|
||||
nombre_pdv: string;
|
||||
usuario_id: string;
|
||||
pdv_id: number;
|
||||
visit_order?: number;
|
||||
}
|
||||
export interface RouteNoAssignedResponse {
|
||||
pdv_id: number;
|
||||
|
||||
@@ -4,6 +4,20 @@ import {
|
||||
RouteAssignedResponse,
|
||||
RouteNoAssignedResponse
|
||||
} from '../models/route.model';
|
||||
import { summarizeDailyRoutes } from '../../shared/utils/route-sequencing.utils';
|
||||
|
||||
const STORAGE_ASSIGNED = 'rp_assigned_routes';
|
||||
const STORAGE_ASSIGNED_ORIGINAL = 'rp_assigned_routes_original';
|
||||
const STORAGE_NO_ASSIGNED = 'rp_no_assigned_routes';
|
||||
const STORAGE_NO_ASSIGNED_ORIGINAL = 'rp_no_assigned_routes_original';
|
||||
const STORAGE_SELECTED = 'rp_selected_route';
|
||||
const DAY_ORDER = ['Lunes', 'Martes', 'Miércoles', 'Miercoles', 'Jueves', 'Viernes'];
|
||||
export interface UnassignedAssignment {
|
||||
userId: string;
|
||||
day: string;
|
||||
distance: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RouteState {
|
||||
@@ -11,13 +25,13 @@ export class RouteState {
|
||||
private loading = new BehaviorSubject<boolean>(false);
|
||||
loading$ = this.loading.asObservable();
|
||||
|
||||
private assignedRoutes = new BehaviorSubject<RouteAssignedResponse[]>([]);
|
||||
private assignedRoutes = new BehaviorSubject<RouteAssignedResponse[]>(this.loadAssigned());
|
||||
assignedRoutes$ = this.assignedRoutes.asObservable();
|
||||
|
||||
private noAssignedRoutes = new BehaviorSubject<RouteNoAssignedResponse[]>([]);
|
||||
private noAssignedRoutes = new BehaviorSubject<RouteNoAssignedResponse[]>(this.loadNoAssigned());
|
||||
noAssignedRoutes$ = this.noAssignedRoutes.asObservable();
|
||||
|
||||
private selectedRoute = new BehaviorSubject<RouteAssignedResponse | null>(null);
|
||||
private selectedRoute = new BehaviorSubject<RouteAssignedResponse | null>(this.loadSelected());
|
||||
selectedRoute$ = this.selectedRoute.asObservable();
|
||||
|
||||
resetLoading() {
|
||||
@@ -30,13 +44,272 @@ export class RouteState {
|
||||
|
||||
setAssignedRoutes(data: RouteAssignedResponse[]) {
|
||||
this.assignedRoutes.next(data);
|
||||
this.persist(STORAGE_ASSIGNED, data);
|
||||
this.persist(STORAGE_ASSIGNED_ORIGINAL, data);
|
||||
}
|
||||
|
||||
updateAssignedRoute(
|
||||
route: RouteAssignedResponse,
|
||||
userId: string,
|
||||
day: string,
|
||||
metrics?: { distance: number; duration: number }
|
||||
): void {
|
||||
const profile = this.assignedRoutes.getValue().find(item => String(item.json.usuario_id) === String(userId));
|
||||
if (!profile) return;
|
||||
const updated = this.assignedRoutes.getValue().map(item => {
|
||||
if (item !== route) return item;
|
||||
return {
|
||||
json: {
|
||||
...item.json,
|
||||
usuario_id: profile.json.usuario_id,
|
||||
nombre_usuario: profile.json.nombre_usuario,
|
||||
latitud_usuario: profile.json.latitud_usuario,
|
||||
longitud_usuario: profile.json.longitud_usuario,
|
||||
hora_laboral_semanal_usuario: profile.json.hora_laboral_semanal_usuario,
|
||||
dia: day,
|
||||
distancia: metrics?.distance ?? item.json.distancia,
|
||||
horas_desplazamiento: metrics?.duration ?? item.json.horas_desplazamiento
|
||||
}
|
||||
};
|
||||
});
|
||||
this.setDraftAssignedRoutes(updated);
|
||||
}
|
||||
|
||||
addAssignedFromUnassigned(route: RouteNoAssignedResponse, userId: string, day: string, distance: number, duration: number): void {
|
||||
this.addAssignedFromUnassignedMany(route, [{ userId, day, distance, duration }]);
|
||||
}
|
||||
|
||||
addAssignedFromUnassignedMany(route: RouteNoAssignedResponse, assignments: UnassignedAssignment[]): void {
|
||||
const routes = this.assignedRoutes.getValue();
|
||||
const newRoutes = assignments.map(assignment => {
|
||||
const profile = routes.find(item => String(item.json.usuario_id) === String(assignment.userId));
|
||||
if (!profile) return null;
|
||||
return { json: {
|
||||
dia: assignment.day,
|
||||
distancia: assignment.distance,
|
||||
horas_desplazamiento: assignment.duration,
|
||||
hora_laboral_semanal_usuario: profile.json.hora_laboral_semanal_usuario,
|
||||
hora_minima_semanal_pdv: route.minimo_horas_semana,
|
||||
horas_trabajo: String(route.minimo_horas_semana),
|
||||
latitud_usuario: profile.json.latitud_usuario,
|
||||
longitud_usuario: profile.json.longitud_usuario,
|
||||
latitud_pdv: route.latitud,
|
||||
longitud_pdv: route.longitud,
|
||||
nombre_usuario: profile.json.nombre_usuario,
|
||||
nombre_pdv: route.pdv_nombre,
|
||||
usuario_id: profile.json.usuario_id,
|
||||
pdv_id: route.pdv_id
|
||||
} } as RouteAssignedResponse;
|
||||
});
|
||||
if (newRoutes.some(item => !item)) return;
|
||||
this.setDraftAssignedRoutes([...routes, ...(newRoutes as RouteAssignedResponse[])]);
|
||||
this.setDraftNoAssignedRoutes(this.noAssignedRoutes.getValue().filter(item => item.pdv_id !== route.pdv_id));
|
||||
}
|
||||
|
||||
updateNoAssignedRoute(route: RouteNoAssignedResponse, changes: Partial<RouteNoAssignedResponse>): void {
|
||||
const updated = this.noAssignedRoutes.getValue().map(item => item.pdv_id === route.pdv_id ? { ...item, ...changes } : item);
|
||||
this.setDraftNoAssignedRoutes(updated);
|
||||
}
|
||||
|
||||
canAssignToDay(
|
||||
userId: string,
|
||||
day: string,
|
||||
requiredHours: number,
|
||||
excluding?: RouteAssignedResponse,
|
||||
additionalTravelHours = 0
|
||||
): { allowed: boolean; totalHours: number; capacityHours: number } {
|
||||
const userRoute = this.assignedRoutes.getValue().find(route => String(route.json.usuario_id) === String(userId));
|
||||
if (!userRoute) return { allowed: false, totalHours: 0, capacityHours: 0 };
|
||||
const weeklyCapacity = Number(userRoute.json.hora_laboral_semanal_usuario || 0);
|
||||
const weeklyLoad = this.assignedRoutes.getValue()
|
||||
.filter(route => String(route.json.usuario_id) === String(userId) && route !== excluding)
|
||||
.reduce((sum, route) => sum + Number(route.json.horas_trabajo || route.json.hora_minima_semanal_pdv || 0), 0) + requiredHours;
|
||||
if (weeklyLoad > weeklyCapacity) {
|
||||
return { allowed: false, totalHours: weeklyLoad, capacityHours: weeklyCapacity };
|
||||
}
|
||||
const existing = this.assignedRoutes.getValue().filter(route =>
|
||||
String(route.json.usuario_id) === String(userId) && route.json.dia === day && route !== excluding
|
||||
);
|
||||
const capacityHours = Number(userRoute.json.hora_laboral_semanal_usuario || 0) / 5;
|
||||
const workHours = existing.reduce((sum, route) => sum + Number(route.json.horas_trabajo || route.json.hora_minima_semanal_pdv || 0), 0) + requiredHours;
|
||||
const existingTravelHours = summarizeDailyRoutes(existing).reduce((sum, item) => sum + item.travelHours, 0);
|
||||
const totalHours = workHours + existingTravelHours + additionalTravelHours;
|
||||
return { allowed: totalHours <= capacityHours, totalHours, capacityHours };
|
||||
}
|
||||
|
||||
moveRouteVisit(route: RouteAssignedResponse, direction: -1 | 1): void {
|
||||
const group = this.assignedRoutes.getValue().filter(item =>
|
||||
String(item.json.usuario_id) === String(route.json.usuario_id) && item.json.dia === route.json.dia
|
||||
);
|
||||
const sequence = summarizeDailyRoutes(group)[0]?.routes ?? [];
|
||||
const index = sequence.findIndex(item => item.route === route);
|
||||
const target = index + direction;
|
||||
if (index < 0 || target < 0 || target >= sequence.length) return;
|
||||
const ordered = [...sequence];
|
||||
[ordered[index], ordered[target]] = [ordered[target], ordered[index]];
|
||||
const orderByRoute = new Map<RouteAssignedResponse, number>(ordered.map((item, position) => [item.route, position + 1]));
|
||||
this.setDraftAssignedRoutes(this.assignedRoutes.getValue().map(item => {
|
||||
const order = orderByRoute.get(item);
|
||||
return order ? { json: { ...item.json, visit_order: order } } : item;
|
||||
}));
|
||||
}
|
||||
|
||||
resetRouteVisitOrder(userId: string, day: string): void {
|
||||
this.setDraftAssignedRoutes(this.assignedRoutes.getValue().map(route =>
|
||||
String(route.json.usuario_id) === String(userId) && route.json.dia === day
|
||||
? { json: { ...route.json, visit_order: undefined } }
|
||||
: route
|
||||
));
|
||||
}
|
||||
|
||||
restoreOriginalAssignedRoutes(): void {
|
||||
const original = this.loadOriginalAssigned();
|
||||
this.setDraftAssignedRoutes(original);
|
||||
}
|
||||
|
||||
reoptimizeAssignedRoutes(): void {
|
||||
const current = this.assignedRoutes.getValue();
|
||||
const profiles = Array.from(new Map(current.map(route => [String(route.json.usuario_id), route])).values());
|
||||
if (profiles.length < 2) return;
|
||||
|
||||
const load = new Map<string, number>(profiles.map(profile => [String(profile.json.usuario_id), 0]));
|
||||
const optimized = [...current]
|
||||
.sort((a, b) => Number(b.json.hora_minima_semanal_pdv) - Number(a.json.hora_minima_semanal_pdv))
|
||||
.map(route => {
|
||||
const routeHours = Number(route.json.horas_trabajo || route.json.hora_minima_semanal_pdv || 0);
|
||||
const user = profiles
|
||||
.slice()
|
||||
.filter(profile => {
|
||||
const userId = String(profile.json.usuario_id);
|
||||
const capacity = Number(profile.json.hora_laboral_semanal_usuario || 0);
|
||||
return (load.get(userId) ?? 0) + routeHours <= capacity;
|
||||
})
|
||||
.sort((a, b) => (load.get(String(a.json.usuario_id)) ?? 0) - (load.get(String(b.json.usuario_id)) ?? 0))[0]
|
||||
?? profiles.slice().sort((a, b) => (load.get(String(a.json.usuario_id)) ?? 0) - (load.get(String(b.json.usuario_id)) ?? 0))[0];
|
||||
const userId = String(user.json.usuario_id);
|
||||
load.set(userId, (load.get(userId) ?? 0) + routeHours);
|
||||
return {
|
||||
json: {
|
||||
...route.json,
|
||||
usuario_id: user.json.usuario_id,
|
||||
nombre_usuario: user.json.nombre_usuario,
|
||||
latitud_usuario: user.json.latitud_usuario,
|
||||
longitud_usuario: user.json.longitud_usuario,
|
||||
hora_laboral_semanal_usuario: user.json.hora_laboral_semanal_usuario
|
||||
}
|
||||
};
|
||||
});
|
||||
this.setDraftAssignedRoutes(optimized);
|
||||
}
|
||||
|
||||
setNoAssignedRoutes(data: RouteNoAssignedResponse[]) {
|
||||
this.noAssignedRoutes.next(data);
|
||||
this.persist(STORAGE_NO_ASSIGNED, data);
|
||||
this.persist(STORAGE_NO_ASSIGNED_ORIGINAL, data);
|
||||
}
|
||||
|
||||
sendRoute(route: RouteAssignedResponse) {
|
||||
this.selectedRoute.next(route);
|
||||
this.persist(STORAGE_SELECTED, route);
|
||||
}
|
||||
|
||||
getAssignedSnapshot(): RouteAssignedResponse[] {
|
||||
return this.assignedRoutes.getValue();
|
||||
}
|
||||
|
||||
getNoAssignedSnapshot(): RouteNoAssignedResponse[] {
|
||||
return this.noAssignedRoutes.getValue();
|
||||
}
|
||||
|
||||
findRoute(pdvId: string | number, usuarioId?: string): RouteAssignedResponse | null {
|
||||
const pid = String(pdvId);
|
||||
const routes = this.getAssignedSnapshot();
|
||||
const found = routes.find(r => {
|
||||
const jp = r.json;
|
||||
if (String(jp.pdv_id) !== pid) return false;
|
||||
if (usuarioId && String(jp.usuario_id) !== String(usuarioId)) return false;
|
||||
return true;
|
||||
});
|
||||
return found ?? null;
|
||||
}
|
||||
|
||||
getUsuariosUnicos(): { id: string; nombre: string }[] {
|
||||
const map = new Map<string, string>();
|
||||
for (const r of this.getAssignedSnapshot()) {
|
||||
const id = String(r.json.usuario_id);
|
||||
if (!map.has(id)) map.set(id, r.json.nombre_usuario);
|
||||
}
|
||||
return Array.from(map.entries()).map(([id, nombre]) => ({ id, nombre }));
|
||||
}
|
||||
|
||||
getDiasUnicos(): string[] {
|
||||
const set = new Set<string>();
|
||||
for (const r of this.getAssignedSnapshot()) set.add(r.json.dia);
|
||||
return Array.from(set).sort((a, b) => {
|
||||
const indexA = DAY_ORDER.indexOf(a);
|
||||
const indexB = DAY_ORDER.indexOf(b);
|
||||
return (indexA < 0 ? DAY_ORDER.length : indexA) - (indexB < 0 ? DAY_ORDER.length : indexB);
|
||||
});
|
||||
}
|
||||
|
||||
clearAll() {
|
||||
this.assignedRoutes.next([]);
|
||||
this.noAssignedRoutes.next([]);
|
||||
this.selectedRoute.next(null);
|
||||
try {
|
||||
sessionStorage.removeItem(STORAGE_ASSIGNED);
|
||||
sessionStorage.removeItem(STORAGE_ASSIGNED_ORIGINAL);
|
||||
sessionStorage.removeItem(STORAGE_NO_ASSIGNED);
|
||||
sessionStorage.removeItem(STORAGE_NO_ASSIGNED_ORIGINAL);
|
||||
sessionStorage.removeItem(STORAGE_SELECTED);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
private persist(key: string, data: unknown) {
|
||||
try {
|
||||
sessionStorage.setItem(key, JSON.stringify(data));
|
||||
} catch { /* ignore storage errors */ }
|
||||
}
|
||||
|
||||
private setDraftAssignedRoutes(data: RouteAssignedResponse[]): void {
|
||||
this.assignedRoutes.next(data);
|
||||
this.persist(STORAGE_ASSIGNED, data);
|
||||
}
|
||||
|
||||
private setDraftNoAssignedRoutes(data: RouteNoAssignedResponse[]): void {
|
||||
this.noAssignedRoutes.next(data);
|
||||
this.persist(STORAGE_NO_ASSIGNED, data);
|
||||
}
|
||||
|
||||
private loadAssigned(): RouteAssignedResponse[] {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_ASSIGNED);
|
||||
if (raw) return JSON.parse(raw) as RouteAssignedResponse[];
|
||||
} catch { /* ignore */ }
|
||||
return [];
|
||||
}
|
||||
|
||||
private loadOriginalAssigned(): RouteAssignedResponse[] {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_ASSIGNED_ORIGINAL);
|
||||
if (raw) return JSON.parse(raw) as RouteAssignedResponse[];
|
||||
} catch { /* ignore */ }
|
||||
return this.loadAssigned();
|
||||
}
|
||||
|
||||
private loadNoAssigned(): RouteNoAssignedResponse[] {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_NO_ASSIGNED);
|
||||
if (raw) return JSON.parse(raw) as RouteNoAssignedResponse[];
|
||||
} catch { /* ignore */ }
|
||||
return [];
|
||||
}
|
||||
|
||||
private loadSelected(): RouteAssignedResponse | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_SELECTED);
|
||||
if (raw) return JSON.parse(raw) as RouteAssignedResponse;
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,15 @@ import {
|
||||
catchError,
|
||||
map,
|
||||
toArray,
|
||||
timeout
|
||||
timeout,
|
||||
Observable,
|
||||
Subject
|
||||
} from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
const ENDPOINT = 'http://localhost:18080/ors/v2/directions/driving-car';
|
||||
const ENDPOINTJSON = 'http://localhost:18080/ors/v2/directions/driving-car/geojson';
|
||||
const ENDPOINT = `${environment.orsBaseUrl}/directions/driving-car`;
|
||||
const ENDPOINTJSON = `${environment.orsBaseUrl}/directions/driving-car/geojson`;
|
||||
const MAX_CONCURRENT_REQUESTS = 2;
|
||||
|
||||
@Injectable({
|
||||
@@ -21,8 +25,14 @@ const MAX_CONCURRENT_REQUESTS = 2;
|
||||
})
|
||||
export class OrsService {
|
||||
|
||||
private geoCache = new Map<string, unknown>();
|
||||
|
||||
constructor(private http: HttpClient) { }
|
||||
|
||||
private cacheKey(origin: number[], dest: number[]): string {
|
||||
return `${origin[0].toFixed(5)},${origin[1].toFixed(5)}->${dest[0].toFixed(5)},${dest[1].toFixed(5)}`;
|
||||
}
|
||||
|
||||
private fetchRoute(
|
||||
origin: number[],
|
||||
destination: number[],
|
||||
@@ -32,7 +42,7 @@ export class OrsService {
|
||||
coordinates: [origin, destination]
|
||||
};
|
||||
|
||||
return this.http.post<any>(isJson ? ENDPOINTJSON : ENDPOINT, body).pipe(
|
||||
return this.http.post<unknown>(isJson ? ENDPOINTJSON : ENDPOINT, body).pipe(
|
||||
timeout(8000),
|
||||
catchError(error => {
|
||||
throw error;
|
||||
@@ -48,37 +58,91 @@ export class OrsService {
|
||||
}[],
|
||||
concurrency = MAX_CONCURRENT_REQUESTS,
|
||||
isJson = false
|
||||
): Promise<any[]> {
|
||||
return lastValueFrom(
|
||||
from(pares).pipe(
|
||||
mergeMap(
|
||||
pair =>
|
||||
this.fetchRoute(pair.origin, pair.destination, isJson).pipe(
|
||||
delay(300),
|
||||
): Promise<unknown[]> {
|
||||
return this.processPairsWithProgress(pares, concurrency, isJson).promise;
|
||||
}
|
||||
|
||||
processPairsWithProgress(
|
||||
pares: {
|
||||
pdv_id: number;
|
||||
origin: number[];
|
||||
destination: number[];
|
||||
}[],
|
||||
concurrency = MAX_CONCURRENT_REQUESTS,
|
||||
isJson = false,
|
||||
abort$?: Observable<void>
|
||||
): { promise: Promise<unknown[]>; progress$: Observable<{ completed: number; total: number; item: unknown }> } {
|
||||
const progressSubject = new Subject<{ completed: number; total: number; item: unknown }>();
|
||||
let completed = 0;
|
||||
|
||||
let source$: Observable<unknown[]>;
|
||||
const base$ = from(pares).pipe(
|
||||
mergeMap(
|
||||
pair => {
|
||||
const key = this.cacheKey(pair.origin, pair.destination);
|
||||
if (isJson && this.geoCache.has(key)) {
|
||||
const cached = this.geoCache.get(key);
|
||||
const wrapped = { pdv_id: pair.pdv_id, geojson: cached };
|
||||
return of(wrapped).pipe(
|
||||
map(data => {
|
||||
if (!isJson) {
|
||||
const summary = data.routes[0].summary;
|
||||
return {
|
||||
pdv_id: pair.pdv_id,
|
||||
distance: summary.distance,
|
||||
duration: summary.duration,
|
||||
raw: data
|
||||
};
|
||||
} else {
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
catchError(error =>
|
||||
of({
|
||||
completed++;
|
||||
progressSubject.next({ completed, total: pares.length, item: data as unknown });
|
||||
if (completed === pares.length) progressSubject.complete();
|
||||
return data as unknown;
|
||||
})
|
||||
);
|
||||
}
|
||||
return this.fetchRoute(pair.origin, pair.destination, isJson).pipe(
|
||||
delay(300),
|
||||
map(data => {
|
||||
if (isJson) this.geoCache.set(key, data);
|
||||
if (!isJson) {
|
||||
const summary = (data as { routes: { summary: { distance: number; duration: number } }[] }).routes[0].summary;
|
||||
const out = {
|
||||
pdv_id: pair.pdv_id,
|
||||
error: error.message || 'Error ORS'
|
||||
})
|
||||
)
|
||||
),
|
||||
concurrency
|
||||
),
|
||||
toArray()
|
||||
)
|
||||
);
|
||||
distance: summary.distance,
|
||||
duration: summary.duration,
|
||||
raw: data
|
||||
};
|
||||
completed++;
|
||||
progressSubject.next({ completed, total: pares.length, item: out });
|
||||
if (completed === pares.length) progressSubject.complete();
|
||||
return out;
|
||||
} else {
|
||||
const wrapped = { pdv_id: pair.pdv_id, geojson: data };
|
||||
completed++;
|
||||
progressSubject.next({ completed, total: pares.length, item: wrapped as unknown });
|
||||
if (completed === pares.length) progressSubject.complete();
|
||||
return wrapped as unknown;
|
||||
}
|
||||
}),
|
||||
catchError(error => {
|
||||
const errOut = {
|
||||
pdv_id: pair.pdv_id,
|
||||
error: (error as Error).message || 'Error ORS'
|
||||
};
|
||||
completed++;
|
||||
progressSubject.next({ completed, total: pares.length, item: errOut });
|
||||
if (completed === pares.length) progressSubject.complete();
|
||||
return of(errOut);
|
||||
})
|
||||
);
|
||||
},
|
||||
concurrency
|
||||
),
|
||||
toArray()
|
||||
) as Observable<unknown[]>;
|
||||
source$ = abort$ ? (base$.pipe(takeUntil(abort$)) as Observable<unknown[]>) : base$;
|
||||
|
||||
const promise = lastValueFrom(source$).catch(() => {
|
||||
progressSubject.complete();
|
||||
return [];
|
||||
});
|
||||
|
||||
return { promise, progress$: progressSubject.asObservable() };
|
||||
}
|
||||
|
||||
clearCache() {
|
||||
this.geoCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlanningInputService {
|
||||
private pdvFile: File | null = null;
|
||||
private usuariosFile: File | null = null;
|
||||
|
||||
setFiles(pdvFile: File, usuariosFile: File): void {
|
||||
this.pdvFile = pdvFile;
|
||||
this.usuariosFile = usuariosFile;
|
||||
}
|
||||
|
||||
getFormData(): FormData | null {
|
||||
if (!this.pdvFile || !this.usuariosFile) return null;
|
||||
const data = new FormData();
|
||||
data.append('pdv_file', this.pdvFile, this.pdvFile.name);
|
||||
data.append('usuarios_file', this.usuariosFile, this.usuariosFile.name);
|
||||
return data;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.pdvFile = null;
|
||||
this.usuariosFile = null;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { ApiResponse } from '../../core/models/route.model';
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { ApiResponse } from '../models/route.model';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { catchError, timeout, throwError } from 'rxjs';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RutasService {
|
||||
@@ -9,8 +11,20 @@ export class RutasService {
|
||||
|
||||
calcularRutas(data: FormData) {
|
||||
return this.http.post<ApiResponse>(
|
||||
'https://agenteit.digitalcompass.agency/webhook/calcular-camino',
|
||||
environment.n8nWebhookUrl,
|
||||
data
|
||||
).pipe(
|
||||
timeout(environment.requestTimeoutMs),
|
||||
catchError((err: HttpErrorResponse | Error) => {
|
||||
if (err instanceof Error && err.name === 'TimeoutError') {
|
||||
return throwError(() => Object.assign(new Error('Timeout al calcular rutas (30s). Intenta con menos filas o reintenta.'), { name: 'TimeoutError' }));
|
||||
}
|
||||
return throwError(() => err);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
reoptimizarRutas(data: FormData) {
|
||||
return this.calcularRutas(data);
|
||||
}
|
||||
}
|
||||
|
||||
+28
@@ -196,3 +196,31 @@
|
||||
background: var(--glm-verde);
|
||||
border: 3px solid #fff;
|
||||
}
|
||||
|
||||
.location-pin {
|
||||
position: relative;
|
||||
width: 30px;
|
||||
height: 38px;
|
||||
background: #c9252d;
|
||||
clip-path: polygon(50% 100%, 8% 43%, 5% 31%, 10% 18%, 22% 7%, 36% 2%, 50% 0, 64% 2%, 78% 7%, 90% 18%, 95% 31%, 92% 43%);
|
||||
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.42));
|
||||
}
|
||||
|
||||
.location-pin::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 3px 3px 7px;
|
||||
background: #ff424a;
|
||||
clip-path: polygon(50% 100%, 8% 43%, 5% 31%, 10% 18%, 22% 7%, 36% 2%, 50% 0, 64% 2%, 78% 7%, 90% 18%, 95% 31%, 92% 43%);
|
||||
}
|
||||
|
||||
.location-pin::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #620000;
|
||||
}
|
||||
|
||||
+2
@@ -25,6 +25,8 @@
|
||||
<div><dt>Usuario</dt><dd>{{ routeData.json.nombre_usuario }}</dd></div>
|
||||
<div><dt>PDV</dt><dd>{{ routeData.json.nombre_pdv }}</dd></div>
|
||||
<div><dt>Día</dt><dd>{{ routeData.json.dia }}</dd></div>
|
||||
<div><dt>Orden</dt><dd>{{ visitOrder }} de {{ totalStops }}</dd></div>
|
||||
<div><dt>Origen del tramo</dt><dd>{{ segmentOriginName }}</dd></div>
|
||||
<div><dt>Distancia</dt><dd>{{ routeData.json.distancia }} km</dd></div>
|
||||
<div><dt>Duración</dt><dd>{{ (routeData.json.horas_desplazamiento * 60).toFixed(0) }} min</dd></div>
|
||||
<div><dt>Horas trabajo</dt><dd>{{ routeData.json.horas_trabajo }}</dd></div>
|
||||
|
||||
+89
-35
@@ -1,11 +1,12 @@
|
||||
import { Component, OnInit, OnDestroy, AfterViewInit, ChangeDetectorRef } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
import { Subscription } from 'rxjs';
|
||||
import * as L from 'leaflet';
|
||||
import { RouteState } from '../../../core/route-state/route';
|
||||
import { OrsService } from '../../../core/services/ors.service';
|
||||
import { RouteAssignedResponse } from '../../../core/models/route.model';
|
||||
import { sequenceRoutes } from '../../../shared/utils/route-sequencing.utils';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
@@ -26,9 +27,13 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
routeData: RouteAssignedResponse | null = null;
|
||||
isLoading = false;
|
||||
error: string | null = null;
|
||||
segmentOriginName = '';
|
||||
visitOrder = 0;
|
||||
totalStops = 0;
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private routeState: RouteState,
|
||||
private orsService: OrsService,
|
||||
private cdr: ChangeDetectorRef
|
||||
@@ -36,11 +41,30 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
|
||||
ngOnInit() {
|
||||
this.sub = this.routeState.selectedRoute$.subscribe((route: RouteAssignedResponse | null) => {
|
||||
this.routeData = route;
|
||||
if (route && this.map) {
|
||||
this.loadAndShowRoute(route);
|
||||
} else if (!route) {
|
||||
this.error = 'No hay ruta seleccionada';
|
||||
if (route) {
|
||||
this.routeData = route;
|
||||
if (this.map) {
|
||||
this.loadAndShowRoute(route);
|
||||
}
|
||||
this.error = null;
|
||||
} else {
|
||||
// Try to resolve from queryParams + sessionStorage snapshot
|
||||
const qp = this.activatedRoute.snapshot.queryParams;
|
||||
const pdvId = qp['pdv_id'];
|
||||
const usuarioId = qp['usuario_id'];
|
||||
if (pdvId) {
|
||||
const found = this.routeState.findRoute(pdvId, usuarioId);
|
||||
if (found) {
|
||||
this.routeData = found;
|
||||
if (this.map) this.loadAndShowRoute(found);
|
||||
this.error = null;
|
||||
} else {
|
||||
// No data at all (e.g., direct URL after hard refresh with empty storage)
|
||||
this.error = 'No hay ruta seleccionada. Vuelve al listado y selecciona una ruta.';
|
||||
}
|
||||
} else if (!this.routeData) {
|
||||
this.error = 'No hay ruta seleccionada';
|
||||
}
|
||||
}
|
||||
this.cdr.detectChanges();
|
||||
});
|
||||
@@ -50,6 +74,18 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
this.initMap();
|
||||
if (this.routeData) {
|
||||
this.loadAndShowRoute(this.routeData);
|
||||
} else {
|
||||
// Re-attempt resolution after map init (queryParams case)
|
||||
const qp = this.activatedRoute.snapshot.queryParams;
|
||||
if (qp['pdv_id']) {
|
||||
const found = this.routeState.findRoute(qp['pdv_id'], qp['usuario_id']);
|
||||
if (found) {
|
||||
this.routeData = found;
|
||||
this.error = null;
|
||||
this.loadAndShowRoute(found);
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +100,8 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
|
||||
private initMap() {
|
||||
this.map = L.map('map').setView([18.4861, -69.9312], 13);
|
||||
const pdvPane = this.map.createPane('pdvPane');
|
||||
pdvPane.style.zIndex = '650';
|
||||
|
||||
this.tileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
@@ -77,13 +115,20 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
this.cdr.detectChanges();
|
||||
|
||||
const json = route.json;
|
||||
const origin: [number, number] = [Number(json.longitud_usuario), Number(json.latitud_usuario)];
|
||||
const sequence = sequenceRoutes(this.routeState.getAssignedSnapshot().filter(item =>
|
||||
String(item.json.usuario_id) === String(json.usuario_id) && item.json.dia === json.dia
|
||||
));
|
||||
const segment = sequence.find(item => item.route === route) ?? sequence.find(item => item.route.json.pdv_id === json.pdv_id);
|
||||
const origin: [number, number] = segment?.origin ?? [Number(json.longitud_usuario), Number(json.latitud_usuario)];
|
||||
const destination: [number, number] = [Number(json.longitud_pdv), Number(json.latitud_pdv)];
|
||||
this.segmentOriginName = segment?.previousPdvName ?? `Casa de ${json.nombre_usuario}`;
|
||||
this.visitOrder = segment?.order ?? 1;
|
||||
this.totalStops = segment?.totalStops ?? 1;
|
||||
|
||||
this.centerMapOnPoints(origin, destination);
|
||||
|
||||
try {
|
||||
const geojson = await this.orsService.processPairs(
|
||||
const results = await this.orsService.processPairs(
|
||||
[{ pdv_id: json.pdv_id, origin, destination }],
|
||||
1,
|
||||
true
|
||||
@@ -91,13 +136,14 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
|
||||
if (this.destroyed) return;
|
||||
|
||||
if (geojson[0] && !geojson[0].error) {
|
||||
this.showRouteOnMap(geojson[0], origin, destination, json);
|
||||
const result = results[0] as { error?: string; geojson?: unknown } | undefined;
|
||||
if (result && !result.error && result.geojson) {
|
||||
this.showRouteOnMap(result.geojson, origin, destination, json, this.segmentOriginName);
|
||||
} else {
|
||||
this.showFallbackRoute(origin, destination, json);
|
||||
this.showFallbackRoute(origin, destination, json, this.segmentOriginName);
|
||||
}
|
||||
} catch {
|
||||
this.showFallbackRoute(origin, destination, json);
|
||||
this.showFallbackRoute(origin, destination, json, this.segmentOriginName);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
this.cdr.detectChanges();
|
||||
@@ -112,31 +158,38 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
this.map.fitBounds(bounds.pad(0.3));
|
||||
}
|
||||
|
||||
private extractCoords(geojson: any): L.LatLngExpression[] {
|
||||
if (geojson.type === 'FeatureCollection' && geojson.features?.length) {
|
||||
return geojson.features[0].geometry.coordinates.map(
|
||||
private extractCoords(geojson: unknown): L.LatLngExpression[] {
|
||||
const g = geojson as { type: string; features?: { geometry: { coordinates: number[][] } }[]; coordinates?: number[][] };
|
||||
if (g.type === 'FeatureCollection' && g.features?.length) {
|
||||
return g.features[0].geometry.coordinates.map(
|
||||
(c: number[]) => [c[1], c[0]] as L.LatLngExpression
|
||||
);
|
||||
}
|
||||
if (geojson.type === 'LineString') {
|
||||
return geojson.coordinates.map(
|
||||
if (g.type === 'LineString' && g.coordinates) {
|
||||
return g.coordinates.map(
|
||||
(c: number[]) => [c[1], c[0]] as L.LatLngExpression
|
||||
);
|
||||
}
|
||||
// ORS geojson response wrapped
|
||||
const maybeFeature = (geojson as { features?: { geometry: { coordinates: number[][] } }[] })?.features;
|
||||
if (maybeFeature?.length) {
|
||||
return maybeFeature[0].geometry.coordinates.map(c => [c[1], c[0]] as L.LatLngExpression);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private showRouteOnMap(geojson: any, origin: [number, number], destination: [number, number], json: any) {
|
||||
private showRouteOnMap(geojson: unknown, origin: [number, number], destination: [number, number], json: RouteAssignedResponse['json'], originName: string) {
|
||||
const coords = this.extractCoords(geojson);
|
||||
if (!coords.length) {
|
||||
this.showFallbackRoute(origin, destination, json);
|
||||
this.showFallbackRoute(origin, destination, json, originName);
|
||||
return;
|
||||
}
|
||||
|
||||
const distance = geojson.features?.[0]?.properties?.summary?.distance
|
||||
? (geojson.features[0].properties.summary.distance / 1000).toFixed(2) : 'N/A';
|
||||
const duration = geojson.features?.[0]?.properties?.summary?.duration
|
||||
? (geojson.features[0].properties.summary.duration / 60).toFixed(0) : 'N/A';
|
||||
const g = geojson as { features?: { properties?: { summary?: { distance: number; duration: number } } }[] };
|
||||
const distance = g.features?.[0]?.properties?.summary?.distance
|
||||
? (g.features[0].properties.summary.distance / 1000).toFixed(2) : 'N/A';
|
||||
const duration = g.features?.[0]?.properties?.summary?.duration
|
||||
? (g.features[0].properties.summary.duration / 60).toFixed(0) : 'N/A';
|
||||
|
||||
this.routeGlow = L.polyline(coords, {
|
||||
color: '#6CC24A',
|
||||
@@ -163,11 +216,11 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
lineCap: 'round'
|
||||
}).addTo(this.map);
|
||||
|
||||
this.routeLine.bindPopup(
|
||||
`<strong style="color:#4F758B">${json.nombre_usuario} → ${json.nombre_pdv}</strong><br>Distancia: ${distance} km<br>Duración: ${duration} min`
|
||||
(this.routeLine as L.Polyline).bindPopup(
|
||||
`<strong style="color:#4F758B">${originName} → ${json.nombre_pdv}</strong><br>Distancia: ${distance} km<br>Duración: ${duration} min`
|
||||
);
|
||||
|
||||
this.addMarkers(origin, destination, json, distance, duration);
|
||||
this.addMarkers(origin, destination, json, distance, duration, originName);
|
||||
const group = new L.FeatureGroup([
|
||||
...(this.routeGlow ? [this.routeGlow] : []),
|
||||
...(this.routeLine ? [this.routeLine] : []),
|
||||
@@ -176,13 +229,13 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
this.map.fitBounds(group.getBounds().pad(0.1));
|
||||
}
|
||||
|
||||
private showFallbackRoute(origin: [number, number], destination: [number, number], json: any) {
|
||||
private showFallbackRoute(origin: [number, number], destination: [number, number], json: RouteAssignedResponse['json'], originName: string) {
|
||||
this.routeLine = L.polyline(
|
||||
[[origin[1], origin[0]], [destination[1], destination[0]]],
|
||||
{ color: '#FF6A13', weight: 5, dashArray: '12, 10', opacity: 0.9 }
|
||||
).addTo(this.map);
|
||||
|
||||
this.addMarkers(origin, destination, json, 'N/A', 'N/A');
|
||||
this.addMarkers(origin, destination, json, 'N/A', 'N/A', originName);
|
||||
const group = new L.FeatureGroup([this.routeLine, ...this.markers]);
|
||||
this.map.fitBounds(group.getBounds().pad(0.1));
|
||||
}
|
||||
@@ -190,9 +243,10 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
private addMarkers(
|
||||
origin: [number, number],
|
||||
destination: [number, number],
|
||||
json: any,
|
||||
json: RouteAssignedResponse['json'],
|
||||
distance: string,
|
||||
duration: string
|
||||
duration: string,
|
||||
originName: string
|
||||
) {
|
||||
const startIcon = L.divIcon({
|
||||
className: 'custom-marker marker-start',
|
||||
@@ -204,17 +258,17 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
|
||||
const endIcon = L.divIcon({
|
||||
className: 'custom-marker marker-end',
|
||||
html: '<div class="marker-inner">B</div>',
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 15],
|
||||
html: '<div class="location-pin"><span></span></div>',
|
||||
iconSize: [30, 38],
|
||||
iconAnchor: [15, 38],
|
||||
popupAnchor: [0, -18]
|
||||
});
|
||||
|
||||
this.markers.push(
|
||||
L.marker([origin[1], origin[0]], { icon: startIcon })
|
||||
.addTo(this.map)
|
||||
.bindPopup(`<strong>Origen:</strong> ${json.nombre_usuario}`),
|
||||
L.marker([destination[1], destination[0]], { icon: endIcon })
|
||||
.bindPopup(`<strong>Origen del tramo:</strong> ${originName}`),
|
||||
L.marker([destination[1], destination[0]], { icon: endIcon, pane: 'pdvPane', zIndexOffset: 1000 })
|
||||
.addTo(this.map)
|
||||
.bindPopup(`<strong>Destino:</strong> ${json.nombre_pdv}<br><strong>Distancia:</strong> ${distance} km<br><strong>Duración:</strong> ${duration} min`)
|
||||
);
|
||||
|
||||
@@ -69,3 +69,31 @@
|
||||
color: var(--glm-acero);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.link-usuario {
|
||||
color: var(--glm-azul);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: transparent;
|
||||
transition: text-decoration-color 0.15s;
|
||||
}
|
||||
|
||||
.link-usuario:hover {
|
||||
text-decoration-color: var(--glm-verde);
|
||||
color: var(--glm-verde);
|
||||
}
|
||||
.inline-edit {
|
||||
width: 100%;
|
||||
min-width: 100px;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--glm-verde);
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
color: var(--glm-gris-oscuro);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -7,9 +7,19 @@
|
||||
(input)="dt.filterGlobal($any($event.target).value, 'contains')"
|
||||
/>
|
||||
<div class="actions-right">
|
||||
<button type="button" class="btn-secondary" (click)="goToOverviewMap()" pTooltip="Ver mapa filtrado por usuario" tooltipPosition="top">
|
||||
<i class="pi pi-map"></i> Ver mapa
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="downloadCSV()">
|
||||
<i class="pi pi-download"></i> Descargar CSV
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="reoptimizeLocally()" [disabled]="!canReoptimize" title="Vuelve a ejecutar el optimizador con los archivos cargados">
|
||||
<span *ngIf="reoptLoading" class="spinner-inline"></span>
|
||||
<i *ngIf="!reoptLoading" class="pi pi-sync"></i> {{ reoptLoading ? 'Reoptimizando...' : 'Reoptimizar rutas' }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" *ngIf="hasLocalChanges" (click)="restoreOriginal()">
|
||||
<i class="pi pi-undo"></i> Deshacer cambios
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
@@ -22,6 +32,8 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-error" *ngIf="reoptError"><i class="pi pi-exclamation-triangle"></i> {{ reoptError }}</div>
|
||||
<div class="table-error" *ngIf="editError"><i class="pi pi-exclamation-triangle"></i> {{ editError }}</div>
|
||||
|
||||
<p-table
|
||||
#dt
|
||||
@@ -60,20 +72,41 @@
|
||||
|
||||
<ng-template pTemplate="body" let-row>
|
||||
<tr>
|
||||
<td>{{ row.json.nombre_usuario }}</td>
|
||||
<td>
|
||||
<ng-container *ngIf="editingRoute === row; else userValue">
|
||||
<select class="inline-edit" [(ngModel)]="editUserId">
|
||||
<option *ngFor="let user of userOptions" [value]="user.id">{{ user.name }}</option>
|
||||
</select>
|
||||
</ng-container>
|
||||
<ng-template #userValue><a class="link-usuario" (click)="goToUserMap(row.json.usuario_id)" pTooltip="Ver todas las rutas de este usuario" tooltipPosition="top">{{ row.json.nombre_usuario }}</a></ng-template>
|
||||
</td>
|
||||
<td>{{ row.json.nombre_pdv }}</td>
|
||||
<td>{{ row.json.hora_laboral_semanal_usuario }}</td>
|
||||
<td>{{ row.json.hora_minima_semanal_pdv }}</td>
|
||||
<td>{{ row.json.horas_trabajo }}</td>
|
||||
<td>{{ row.json.dia }}</td>
|
||||
<td>
|
||||
<ng-container *ngIf="editingRoute === row; else dayValue">
|
||||
<select class="inline-edit" [(ngModel)]="editDay">
|
||||
<option *ngFor="let day of dayOptions" [value]="day">{{ day }}</option>
|
||||
</select>
|
||||
</ng-container>
|
||||
<ng-template #dayValue>{{ row.json.dia }}</ng-template>
|
||||
</td>
|
||||
<td>{{ row.json.distancia }}</td>
|
||||
<td>{{ row.json.horas_desplazamiento }}</td>
|
||||
<td class="text-center">
|
||||
<td class="text-center edit-actions">
|
||||
<ng-container *ngIf="editingRoute === row; else editButton">
|
||||
<button type="button" class="btn-icon" (click)="saveEdit()" title="Guardar"><i class="pi pi-check"></i></button>
|
||||
<button type="button" class="btn-icon" (click)="cancelEdit()" title="Cancelar"><i class="pi pi-times"></i></button>
|
||||
</ng-container>
|
||||
<ng-template #editButton>
|
||||
<button type="button" class="btn-icon" (click)="startEdit(row)" title="Editar asignación"><i class="pi pi-pencil"></i></button>
|
||||
</ng-template>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-icon"
|
||||
(click)="onShowMap(row)"
|
||||
pTooltip="Ver en mapa"
|
||||
pTooltip="Ver en mapa (1 ruta)"
|
||||
tooltipPosition="top"
|
||||
>
|
||||
<i class="pi pi-map-marker"></i>
|
||||
@@ -84,7 +117,7 @@
|
||||
|
||||
<ng-template pTemplate="emptymessage">
|
||||
<tr>
|
||||
<td colspan="9" class="text-center">No hay rutas asignadas</td>
|
||||
<td colspan="9" class="text-center">No hay rutas asignadas</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</p-table>
|
||||
|
||||
@@ -9,6 +9,8 @@ import { TableModule } from 'primeng/table';
|
||||
import { InputTextModule } from 'primeng/inputtext';
|
||||
import { TooltipModule } from 'primeng/tooltip';
|
||||
import { RouteState } from '../../../core/route-state/route';
|
||||
import { RutasService } from '../../../core/services/router.service';
|
||||
import { PlanningInputService } from '../../../core/services/planning-input.service';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
@@ -30,23 +32,38 @@ export class AssignedRouteComponent {
|
||||
assignedRoutes: RouteAssignedResponse[] = [];
|
||||
isLoading = false;
|
||||
cols: any[] = [];
|
||||
editingRoute: RouteAssignedResponse | null = null;
|
||||
editUserId = '';
|
||||
editDay = '';
|
||||
hasLocalChanges = false;
|
||||
editError: string | null = null;
|
||||
reoptLoading = false;
|
||||
reoptError: string | null = null;
|
||||
|
||||
constructor(
|
||||
private routesState: RouteState,
|
||||
private cdr: ChangeDetectorRef,
|
||||
private router: Router
|
||||
private router: Router,
|
||||
private rutasService: RutasService,
|
||||
private planningInput: PlanningInputService
|
||||
) { }
|
||||
|
||||
private subs: import('rxjs').Subscription[] = [];
|
||||
|
||||
ngOnInit() {
|
||||
this.routesState.resetLoading();
|
||||
this.routesState.loading$.subscribe(value => {
|
||||
this.isLoading = value;
|
||||
this.cdr.detectChanges();
|
||||
});
|
||||
this.routesState.assignedRoutes$.subscribe(data => {
|
||||
this.assignedRoutes = [...data];
|
||||
this.cdr.detectChanges();
|
||||
});
|
||||
this.subs.push(
|
||||
this.routesState.loading$.subscribe(value => {
|
||||
this.isLoading = value;
|
||||
this.cdr.detectChanges();
|
||||
})
|
||||
);
|
||||
this.subs.push(
|
||||
this.routesState.assignedRoutes$.subscribe(data => {
|
||||
this.assignedRoutes = [...data];
|
||||
this.cdr.detectChanges();
|
||||
})
|
||||
);
|
||||
this.cols = [
|
||||
{ field: 'json.nombre_usuario', header: 'Usuario' },
|
||||
{ field: 'json.nombre_pdv', header: 'PDV' },
|
||||
@@ -62,12 +79,110 @@ export class AssignedRouteComponent {
|
||||
}
|
||||
onShowMap(row: RouteAssignedResponse) {
|
||||
this.routesState.sendRoute(row);
|
||||
this.router.navigate(['/home/assigned-route']);
|
||||
this.router.navigate(['/home/assigned-route'], {
|
||||
queryParams: { pdv_id: row.json.pdv_id, usuario_id: row.json.usuario_id, dia: row.json.dia }
|
||||
});
|
||||
}
|
||||
|
||||
goToOverviewMap() {
|
||||
// If table has filtered view, use first visible row's usuario as default; else first assigned
|
||||
const target = (this.table?.filteredValue?.[0] as RouteAssignedResponse | undefined) ?? this.assignedRoutes[0];
|
||||
if (target) {
|
||||
this.router.navigate(['/home/mapa'], {
|
||||
queryParams: { usuario_id: target.json.usuario_id, dia: 'Todos' }
|
||||
});
|
||||
} else {
|
||||
this.router.navigate(['/home/mapa']);
|
||||
}
|
||||
}
|
||||
|
||||
goToUserMap(usuarioId: string) {
|
||||
this.router.navigate(['/home/mapa'], { queryParams: { usuario_id: usuarioId, dia: 'Todos' } });
|
||||
}
|
||||
|
||||
get userOptions(): { id: string; name: string }[] {
|
||||
const users = new Map<string, string>();
|
||||
for (const route of this.assignedRoutes) users.set(String(route.json.usuario_id), route.json.nombre_usuario);
|
||||
return Array.from(users, ([id, name]) => ({ id, name }));
|
||||
}
|
||||
|
||||
get dayOptions(): string[] {
|
||||
return this.routesState.getDiasUnicos();
|
||||
}
|
||||
|
||||
startEdit(route: RouteAssignedResponse): void {
|
||||
this.editingRoute = route;
|
||||
this.editUserId = String(route.json.usuario_id);
|
||||
this.editDay = route.json.dia;
|
||||
this.editError = null;
|
||||
}
|
||||
|
||||
cancelEdit(): void {
|
||||
this.editingRoute = null;
|
||||
}
|
||||
|
||||
saveEdit(): void {
|
||||
if (!this.editingRoute || !this.editUserId || !this.editDay) return;
|
||||
const capacity = this.routesState.canAssignToDay(
|
||||
this.editUserId,
|
||||
this.editDay,
|
||||
Number(this.editingRoute.json.horas_trabajo || this.editingRoute.json.hora_minima_semanal_pdv || 0),
|
||||
this.editingRoute
|
||||
);
|
||||
if (!capacity.allowed) {
|
||||
this.editError = `La asignación excede la capacidad diaria (${capacity.totalHours.toFixed(1)} h de ${capacity.capacityHours.toFixed(1)} h).`;
|
||||
return;
|
||||
}
|
||||
this.routesState.updateAssignedRoute(this.editingRoute, this.editUserId, this.editDay);
|
||||
this.hasLocalChanges = true;
|
||||
this.editingRoute = null;
|
||||
}
|
||||
|
||||
get canReoptimize(): boolean {
|
||||
return this.assignedRoutes.length > 0 && !!this.planningInput.getFormData() && !this.reoptLoading;
|
||||
}
|
||||
|
||||
reoptimizeLocally(): void {
|
||||
const data = this.planningInput.getFormData();
|
||||
if (!data || !this.assignedRoutes.length || this.reoptLoading) return;
|
||||
this.reoptLoading = true;
|
||||
this.reoptError = null;
|
||||
this.routesState.setLoading(true);
|
||||
this.rutasService.reoptimizarRutas(data).subscribe({
|
||||
next: response => {
|
||||
const items = response as unknown as unknown[];
|
||||
const assigned = items.find(item => item && typeof item === 'object' && 'assigned' in item) as { assigned?: unknown } | undefined;
|
||||
const noAssigned = items.find(item => item && typeof item === 'object' && 'noAssigned' in item) as { noAssigned?: unknown } | undefined;
|
||||
if (assigned?.assigned) this.routesState.setAssignedRoutes(assigned.assigned as never);
|
||||
if (noAssigned?.noAssigned) this.routesState.setNoAssignedRoutes(noAssigned.noAssigned as never);
|
||||
this.hasLocalChanges = false;
|
||||
this.reoptLoading = false;
|
||||
this.routesState.setLoading(false);
|
||||
this.cdr.detectChanges();
|
||||
},
|
||||
error: err => {
|
||||
this.reoptError = err?.error?.error || err?.message || 'No se pudo reoptimizar la planificación';
|
||||
this.reoptLoading = false;
|
||||
this.routesState.setLoading(false);
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
});
|
||||
this.editingRoute = null;
|
||||
}
|
||||
|
||||
restoreOriginal(): void {
|
||||
this.routesState.restoreOriginalAssignedRoutes();
|
||||
this.hasLocalChanges = false;
|
||||
this.editingRoute = null;
|
||||
}
|
||||
toggleRutas() {
|
||||
this.rutasExpandido = !this.rutasExpandido;
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.subs.forEach(s => s.unsubscribe());
|
||||
}
|
||||
|
||||
downloadCSV() {
|
||||
const headers = [
|
||||
'Usuario ID',
|
||||
|
||||
@@ -233,6 +233,161 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.file-label.has-error {
|
||||
border-color: #F5A6A0;
|
||||
background: var(--glm-rojo-ok);
|
||||
}
|
||||
|
||||
.file-label.has-success {
|
||||
border-color: var(--glm-verde);
|
||||
background: var(--glm-verde-bg);
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--glm-gris-medio);
|
||||
background: var(--glm-surface);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.preview-success {
|
||||
border-color: var(--glm-verde-claro);
|
||||
background: var(--glm-verde-bg);
|
||||
}
|
||||
|
||||
.preview-success .preview-header {
|
||||
color: var(--glm-verde);
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.preview-error {
|
||||
border-color: #F5A6A0;
|
||||
background: var(--glm-rojo-ok);
|
||||
}
|
||||
|
||||
.preview-error .preview-header {
|
||||
color: #D32F2F;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.preview-stats {
|
||||
margin-top: 6px;
|
||||
color: var(--glm-acero);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.preview-errors {
|
||||
margin: 8px 0 0;
|
||||
padding: 0 0 0 18px;
|
||||
color: var(--glm-gris-oscuro);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.preview-errors li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.error-value {
|
||||
color: var(--glm-acero);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.more-errors {
|
||||
color: var(--glm-acero);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.preview-loading {
|
||||
color: var(--glm-acero);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.preview-loading-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--glm-acero);
|
||||
}
|
||||
|
||||
.params-card {
|
||||
margin-top: 16px;
|
||||
padding: 14px;
|
||||
background: var(--glm-surface);
|
||||
border: 1px solid var(--glm-gris-medio);
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid var(--glm-azul);
|
||||
}
|
||||
|
||||
.params-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--glm-azul);
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.params-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.params-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.param-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.param-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--glm-azul);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.param-select {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--glm-gris-medio);
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
font-family: Arial, sans-serif;
|
||||
background: #fff;
|
||||
color: var(--glm-gris-oscuro);
|
||||
}
|
||||
|
||||
.param-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--glm-verde);
|
||||
box-shadow: 0 0 0 2px rgba(108, 194, 74, 0.15);
|
||||
}
|
||||
|
||||
.param-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--glm-acero);
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
color: var(--glm-acero);
|
||||
font-size: 0.82rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.spinner-inline {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
|
||||
<div class="upload-grid">
|
||||
<div class="file-input-wrapper">
|
||||
<label class="file-label" for="pdvFile">
|
||||
<label class="file-label" for="pdvFile" [class.has-error]="pdvPreview && !pdvPreview.valid" [class.has-success]="pdvPreview?.valid">
|
||||
<i class="pi pi-file-excel"></i>
|
||||
<span>Archivo PDVs (Excel)</span>
|
||||
<span class="file-hint">.xlsx / .xls • máx 10MB</span>
|
||||
<span class="file-hint">.xlsx / .xls • máx 10MB • máx 2000 filas</span>
|
||||
</label>
|
||||
<input
|
||||
id="pdvFile"
|
||||
@@ -29,16 +29,39 @@
|
||||
[class.has-file]="pdvFile"
|
||||
/>
|
||||
<div class="file-name" *ngIf="pdvFile">
|
||||
<i class="pi pi-check-circle"></i>
|
||||
<i class="pi" [class.pi-check-circle]="pdvPreview?.valid" [class.pi-exclamation-circle]="pdvPreview && !pdvPreview.valid" [class.pi-spin]="pdvPreviewLoading"></i>
|
||||
{{ pdvFile.name }}
|
||||
<span *ngIf="pdvPreviewLoading" class="preview-loading">Validando...</span>
|
||||
</div>
|
||||
|
||||
<div class="preview-card" *ngIf="pdvPreviewLoading">
|
||||
<div class="preview-loading-row"><span class="spinner-inline"></span> Analizando archivo PDV...</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-card preview-success" *ngIf="pdvPreview?.valid">
|
||||
<div class="preview-header"><i class="pi pi-check-circle"></i> PDV válido — {{ pdvPreview!.totalRows }} filas</div>
|
||||
<div class="preview-stats">{{ pdvPreview!.validRows }} filas válidas • Columnas: {{ pdvPreview!.headers.join(', ') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-card preview-error" *ngIf="pdvPreview && !pdvPreview.valid">
|
||||
<div class="preview-header"><i class="pi pi-exclamation-triangle"></i> Errores en PDV ({{ pdvPreview.errors.length }})</div>
|
||||
<div class="preview-stats" *ngIf="pdvPreview.missingHeaders.length">Faltan: {{ pdvPreview.missingHeaders.join(', ') }}</div>
|
||||
<div class="preview-stats">{{ pdvPreview.totalRows }} filas leídas • {{ pdvPreview.validRows }} válidas</div>
|
||||
<ul class="preview-errors">
|
||||
<li *ngFor="let e of pdvPreview.errors.slice(0, 8)">
|
||||
<strong>Fila {{ e.row }} — {{ e.field }}:</strong> {{ e.message }}
|
||||
<span *ngIf="e.value !== undefined && e.value !== null" class="error-value">({{ e.value }})</span>
|
||||
</li>
|
||||
<li *ngIf="pdvPreview.errors.length > 8" class="more-errors">+ {{ pdvPreview.errors.length - 8 }} errores más...</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-input-wrapper">
|
||||
<label class="file-label" for="usuariosFile">
|
||||
<label class="file-label" for="usuariosFile" [class.has-error]="usuariosPreview && !usuariosPreview.valid" [class.has-success]="usuariosPreview?.valid">
|
||||
<i class="pi pi-file-excel"></i>
|
||||
<span>Archivo Usuarios (Excel)</span>
|
||||
<span class="file-hint">.xlsx / .xls • máx 10MB</span>
|
||||
<span class="file-hint">.xlsx / .xls • máx 10MB • máx 50 filas</span>
|
||||
</label>
|
||||
<input
|
||||
id="usuariosFile"
|
||||
@@ -49,8 +72,31 @@
|
||||
[class.has-file]="usuariosFile"
|
||||
/>
|
||||
<div class="file-name" *ngIf="usuariosFile">
|
||||
<i class="pi pi-check-circle"></i>
|
||||
<i class="pi" [class.pi-check-circle]="usuariosPreview?.valid" [class.pi-exclamation-circle]="usuariosPreview && !usuariosPreview.valid"></i>
|
||||
{{ usuariosFile.name }}
|
||||
<span *ngIf="usuariosPreviewLoading" class="preview-loading">Validando...</span>
|
||||
</div>
|
||||
|
||||
<div class="preview-card" *ngIf="usuariosPreviewLoading">
|
||||
<div class="preview-loading-row"><span class="spinner-inline"></span> Analizando archivo Usuarios...</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-card preview-success" *ngIf="usuariosPreview?.valid">
|
||||
<div class="preview-header"><i class="pi pi-check-circle"></i> Usuarios válido — {{ usuariosPreview!.totalRows }} filas</div>
|
||||
<div class="preview-stats">{{ usuariosPreview!.validRows }} filas válidas • Columnas: {{ usuariosPreview!.headers.join(', ') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-card preview-error" *ngIf="usuariosPreview && !usuariosPreview.valid">
|
||||
<div class="preview-header"><i class="pi pi-exclamation-triangle"></i> Errores en Usuarios ({{ usuariosPreview.errors.length }})</div>
|
||||
<div class="preview-stats" *ngIf="usuariosPreview.missingHeaders.length">Faltan: {{ usuariosPreview.missingHeaders.join(', ') }}</div>
|
||||
<div class="preview-stats">{{ usuariosPreview.totalRows }} filas leídas • {{ usuariosPreview.validRows }} válidas</div>
|
||||
<ul class="preview-errors">
|
||||
<li *ngFor="let e of usuariosPreview.errors.slice(0, 8)">
|
||||
<strong>Fila {{ e.row }} — {{ e.field }}:</strong> {{ e.message }}
|
||||
<span *ngIf="e.value !== undefined && e.value !== null" class="error-value">({{ e.value }})</span>
|
||||
</li>
|
||||
<li *ngIf="usuariosPreview.errors.length > 8" class="more-errors">+ {{ usuariosPreview.errors.length - 8 }} errores más...</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,12 +111,21 @@
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
(click)="cargarRutas()"
|
||||
[disabled]="!canSubmit || isLoading"
|
||||
[disabled]="!canSubmit"
|
||||
[title]="submitDisabledReason || ''"
|
||||
>
|
||||
<span *ngIf="isLoading" class="spinner-inline"></span>
|
||||
<i *ngIf="!isLoading" class="pi pi-upload"></i>
|
||||
{{ isLoading ? 'Procesando...' : 'Cargar rutas' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
*ngIf="isLoading"
|
||||
(click)="cancelarCarga()"
|
||||
>
|
||||
<i class="pi pi-times"></i> Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
@@ -80,9 +135,14 @@
|
||||
<i class="pi pi-times"></i> Limpiar
|
||||
</button>
|
||||
</div>
|
||||
<small class="hint-text" *ngIf="submitDisabledReason && !isLoading">{{ submitDisabledReason }}</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card kpis-card">
|
||||
<app-planning-kpis></app-planning-kpis>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<app-assigned-route></app-assigned-route>
|
||||
</section>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, ChangeDetectorRef } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AssignedRouteComponent } from './assigned-route/assigned-route.component';
|
||||
@@ -6,98 +6,238 @@ import { NoAssignedRouteComponent } from './no-assigned-route/no-assigned-route.
|
||||
import { RutasService } from '../../core/services/router.service';
|
||||
import { RouteState } from '../../core/route-state/route';
|
||||
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 { PlanningInputService } from '../../core/services/planning-input.service';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-home',
|
||||
imports: [CommonModule, FormsModule, AssignedRouteComponent, NoAssignedRouteComponent],
|
||||
imports: [CommonModule, FormsModule, AssignedRouteComponent, NoAssignedRouteComponent, PlanningKpisComponent],
|
||||
templateUrl: './home.component.html',
|
||||
styleUrls: ['./home.component.css']
|
||||
})
|
||||
export class HomeComponent {
|
||||
pdvFile: File | null = null;
|
||||
usuariosFile: File | null = null;
|
||||
pdvPreview: PreviewResult | null = null;
|
||||
usuariosPreview: PreviewResult | null = null;
|
||||
pdvPreviewLoading = false;
|
||||
usuariosPreviewLoading = false;
|
||||
uploadError: string | null = null;
|
||||
isLoading = false;
|
||||
private calcSub: Subscription | null = null;
|
||||
|
||||
constructor(
|
||||
private rutasService: RutasService,
|
||||
private routeState: RouteState
|
||||
private routeState: RouteState,
|
||||
private previewService: ExcelPreviewService,
|
||||
private planningInput: PlanningInputService,
|
||||
private cdr: ChangeDetectorRef
|
||||
) {}
|
||||
|
||||
onPdvFileChange(event: Event) {
|
||||
async onPdvFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) {
|
||||
this.pdvFile = input.files[0];
|
||||
this.validateFile(this.pdvFile, 'PDV');
|
||||
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(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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onUsuariosFileChange(event: Event) {
|
||||
async onUsuariosFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) {
|
||||
this.usuariosFile = input.files[0];
|
||||
this.validateFile(this.usuariosFile, 'Usuarios');
|
||||
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.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 validateFile(file: File, type: string) {
|
||||
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)) {
|
||||
this.uploadError = `El archivo ${type} debe ser .xlsx o .xls`;
|
||||
if (type === 'PDV') this.pdvFile = null;
|
||||
else this.usuariosFile = null;
|
||||
return `El archivo ${type} debe ser .xlsx o .xls`;
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
this.uploadError = `El archivo ${type} supera 10MB`;
|
||||
if (type === 'PDV') this.pdvFile = null;
|
||||
else this.usuariosFile = null;
|
||||
return `El archivo ${type} supera 10MB`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
get canSubmit(): boolean {
|
||||
return !!this.pdvFile && !!this.usuariosFile && !this.uploadError;
|
||||
if (!this.pdvFile || !this.usuariosFile) return false;
|
||||
if (this.pdvPreviewLoading || this.usuariosPreviewLoading) return false;
|
||||
if (this.isLoading) return false;
|
||||
if (this.uploadError) return false;
|
||||
// Require valid previews before enabling submit
|
||||
if (this.pdvPreview && !this.pdvPreview.valid) return false;
|
||||
if (this.usuariosPreview && !this.usuariosPreview.valid) return false;
|
||||
// Previews must exist (file was parsed)
|
||||
if (!this.pdvPreview || !this.usuariosPreview) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
cargarRutas() {
|
||||
get submitDisabledReason(): string | null {
|
||||
if (!this.pdvFile || !this.usuariosFile) return 'Sube ambos archivos';
|
||||
if (this.pdvPreviewLoading || this.usuariosPreviewLoading) return 'Validando archivos...';
|
||||
if (this.pdvPreview && !this.pdvPreview.valid) return 'Corrige los errores del archivo PDV';
|
||||
if (this.usuariosPreview && !this.usuariosPreview.valid) return 'Corrige los errores del archivo Usuarios';
|
||||
return null;
|
||||
}
|
||||
|
||||
async cargarRutas() {
|
||||
if (!this.canSubmit) return;
|
||||
|
||||
this.isLoading = true;
|
||||
this.uploadError = null;
|
||||
this.routeState.setLoading(true);
|
||||
let pdvForOptimizer: File;
|
||||
try {
|
||||
pdvForOptimizer = await this.previewService.preparePdvForOptimizer(this.pdvFile!);
|
||||
} catch {
|
||||
this.uploadError = 'No se pudo preparar el archivo PDV para el optimizador.';
|
||||
this.routeState.setLoading(false);
|
||||
this.isLoading = false;
|
||||
this.cdr.detectChanges();
|
||||
return;
|
||||
}
|
||||
this.planningInput.setFiles(pdvForOptimizer, this.usuariosFile!);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('pdv_file', this.pdvFile!, this.pdvFile!.name);
|
||||
formData.append('pdv_file', pdvForOptimizer, pdvForOptimizer.name);
|
||||
formData.append('usuarios_file', this.usuariosFile!, this.usuariosFile!.name);
|
||||
|
||||
this.rutasService.calcularRutas(formData).subscribe({
|
||||
this.calcSub?.unsubscribe();
|
||||
this.calcSub = this.rutasService.calcularRutas(formData).subscribe({
|
||||
next: (res: ApiResponse) => {
|
||||
const assigned = res.find(r => 'assigned' in r);
|
||||
const noAssigned = res.find(r => 'noAssigned' in r);
|
||||
// ApiResponse is [AssignedResponse, NoAssignedResponse] but backend may return object with error
|
||||
const anyRes = res as unknown as Record<string, unknown>;
|
||||
if (anyRes && 'error' in anyRes) {
|
||||
this.uploadError = String((anyRes as { error: unknown }).error);
|
||||
this.routeState.setLoading(false);
|
||||
this.isLoading = false;
|
||||
this.cdr.detectChanges();
|
||||
return;
|
||||
}
|
||||
const arr = res as unknown as unknown[];
|
||||
const assigned = (arr as ApiResponse).find(r => r && typeof r === 'object' && 'assigned' in r) as unknown as { assigned: unknown } | undefined;
|
||||
const noAssigned = (arr as ApiResponse).find(r => r && typeof r === 'object' && 'noAssigned' in r) as unknown as { noAssigned: unknown } | undefined;
|
||||
if (assigned) {
|
||||
this.routeState.setAssignedRoutes(assigned.assigned);
|
||||
this.routeState.setAssignedRoutes(assigned.assigned as never);
|
||||
}
|
||||
if (noAssigned) {
|
||||
this.routeState.setNoAssignedRoutes(noAssigned.noAssigned);
|
||||
this.routeState.setNoAssignedRoutes(noAssigned.noAssigned as never);
|
||||
}
|
||||
this.routeState.setLoading(false);
|
||||
this.isLoading = false;
|
||||
this.cdr.detectChanges();
|
||||
},
|
||||
error: (err) => {
|
||||
this.uploadError = err.error?.message || 'Error al procesar los archivos';
|
||||
this.uploadError = this.mapBackendError(err);
|
||||
this.routeState.setLoading(false);
|
||||
this.isLoading = false;
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cancelarCarga() {
|
||||
this.calcSub?.unsubscribe();
|
||||
this.calcSub = null;
|
||||
this.isLoading = false;
|
||||
this.routeState.setLoading(false);
|
||||
this.uploadError = 'Cálculo cancelado';
|
||||
}
|
||||
|
||||
private mapBackendError(err: unknown): string {
|
||||
const anyErr = err as { error?: { error?: string; message?: string }; message?: string; status?: number; name?: string };
|
||||
if (anyErr?.name === 'TimeoutError') return 'El cálculo tardó demasiado (timeout 30s). Intenta de nuevo con menos filas.';
|
||||
if (anyErr?.error?.error) return anyErr.error.error;
|
||||
if (anyErr?.error?.message) return anyErr.error.message;
|
||||
if (anyErr?.message) return anyErr.message;
|
||||
return 'Error al procesar los archivos';
|
||||
}
|
||||
|
||||
clearFiles() {
|
||||
this.calcSub?.unsubscribe();
|
||||
this.planningInput.clear();
|
||||
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 = '';
|
||||
|
||||
@@ -11,3 +11,58 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.assignment-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 230px;
|
||||
}
|
||||
|
||||
.inline-select {
|
||||
max-width: 115px;
|
||||
padding: 5px;
|
||||
border: 1px solid var(--glm-gris-medio);
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
color: var(--glm-gris-oscuro);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.day-checkboxes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px 5px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.day-checkboxes label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
color: var(--glm-acero);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.spinner-inline {
|
||||
display: inline-block;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border: 2px solid transparent;
|
||||
border-top-color: currentColor;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.table-error {
|
||||
margin-top: 10px;
|
||||
padding: 9px 12px;
|
||||
background: var(--glm-rojo-ok);
|
||||
border: 1px solid #F5A6A0;
|
||||
border-radius: 4px;
|
||||
color: var(--glm-gris-oscuro);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
+15
-1
@@ -20,13 +20,27 @@
|
||||
<td class="text-left">{{ row.latitud }}</td>
|
||||
<td class="text-left">{{ row.longitud }}</td>
|
||||
<td class="text-left">{{ row.motivo }}</td>
|
||||
<td class="assignment-actions">
|
||||
<select [(ngModel)]="assignmentUser[row.pdv_id]" class="inline-select">
|
||||
<option value="">Usuario</option>
|
||||
<option *ngFor="let user of userOptions" [value]="user.id">{{ user.name }}</option>
|
||||
</select>
|
||||
<div class="day-checkboxes">
|
||||
<label *ngFor="let day of dayOptions"><input type="checkbox" [checked]="(assignmentDays[row.pdv_id] || []).includes(day)" (change)="toggleDay(row.pdv_id, day, $any($event.target).checked)">{{ day.substring(0, 3) }}</label>
|
||||
</div>
|
||||
<button type="button" class="btn-icon" (click)="assignRoute(row)" [disabled]="assigningId === row.pdv_id" title="Asignar y calcular ruta">
|
||||
<span *ngIf="assigningId === row.pdv_id" class="spinner-inline"></span>
|
||||
<i *ngIf="assigningId !== row.pdv_id" class="pi pi-plus"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
<ng-template pTemplate="emptymessage">
|
||||
<tr>
|
||||
<td colspan="8" class="text-center">
|
||||
<td colspan="7" class="text-center">
|
||||
No hay registros asignados
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</p-table>
|
||||
<div class="table-error" *ngIf="actionError"><i class="pi pi-exclamation-triangle"></i> {{ actionError }}</div>
|
||||
|
||||
+84
-14
@@ -1,36 +1,49 @@
|
||||
import { Component, ChangeDetectorRef } from '@angular/core';
|
||||
import { Component, ChangeDetectorRef, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { TableModule } from 'primeng/table';
|
||||
import { RouteNoAssignedResponse } from '../../../core/models/route.model';
|
||||
import { RouteState } from '../../../core/route-state/route';
|
||||
import { RouteState, UnassignedAssignment } from '../../../core/route-state/route';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { OrsService } from '../../../core/services/ors.service';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-no-assigned-route',
|
||||
imports: [CommonModule, TableModule],
|
||||
imports: [CommonModule, FormsModule, TableModule],
|
||||
templateUrl: './no-assigned-route.component.html',
|
||||
styleUrls: ['./no-assigned-route.component.css']
|
||||
})
|
||||
export class NoAssignedRouteComponent {
|
||||
export class NoAssignedRouteComponent implements OnDestroy {
|
||||
|
||||
noAssignedRoutes: RouteNoAssignedResponse[] = [];
|
||||
isLoading = false;
|
||||
cols: any[] = [];
|
||||
cols: { field: string; header: string }[] = [];
|
||||
private subs: Subscription[] = [];
|
||||
assignmentUser: Record<string, string> = {};
|
||||
assignmentDays: Record<string, string[]> = {};
|
||||
assigningId: number | null = null;
|
||||
actionError: string | null = null;
|
||||
|
||||
constructor(
|
||||
private routesState: RouteState,
|
||||
private cdr: ChangeDetectorRef
|
||||
private cdr: ChangeDetectorRef,
|
||||
private orsService: OrsService
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.routesState.loading$.subscribe(value => {
|
||||
this.isLoading = value;
|
||||
this.cdr.detectChanges();
|
||||
});
|
||||
this.routesState.noAssignedRoutes$.subscribe(data => {
|
||||
this.noAssignedRoutes = [...data];
|
||||
this.cdr.detectChanges();
|
||||
});
|
||||
this.subs.push(
|
||||
this.routesState.loading$.subscribe(value => {
|
||||
this.isLoading = value;
|
||||
this.cdr.detectChanges();
|
||||
})
|
||||
);
|
||||
this.subs.push(
|
||||
this.routesState.noAssignedRoutes$.subscribe(data => {
|
||||
this.noAssignedRoutes = [...data];
|
||||
this.cdr.detectChanges();
|
||||
})
|
||||
);
|
||||
this.cols = [
|
||||
{ field: 'pdv_id', header: 'PDV ID' },
|
||||
{ field: 'pdv_nombre', header: 'Nombre' },
|
||||
@@ -40,4 +53,61 @@ export class NoAssignedRouteComponent {
|
||||
{ field: 'motivo', header: 'Motivo' }
|
||||
];
|
||||
}
|
||||
|
||||
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);
|
||||
const days = new Set(this.assignmentDays[key] ?? []);
|
||||
checked ? days.add(day) : days.delete(day);
|
||||
this.assignmentDays[key] = Array.from(days);
|
||||
}
|
||||
|
||||
async assignRoute(route: RouteNoAssignedResponse): Promise<void> {
|
||||
const userId = this.assignmentUser[String(route.pdv_id)];
|
||||
const days = this.assignmentDays[String(route.pdv_id)] ?? [];
|
||||
if (!userId || !days.length) {
|
||||
this.actionError = 'Selecciona usuario y al menos un día antes de asignar.';
|
||||
return;
|
||||
}
|
||||
const user = this.routesState.getAssignedSnapshot().find(item => String(item.json.usuario_id) === userId);
|
||||
if (!user) return;
|
||||
this.assigningId = route.pdv_id;
|
||||
this.actionError = null;
|
||||
try {
|
||||
const origin = [Number(user.json.longitud_usuario), Number(user.json.latitud_usuario)];
|
||||
const destination = [Number(route.longitud), Number(route.latitud)];
|
||||
const assignments: UnassignedAssignment[] = [];
|
||||
for (const day of days) {
|
||||
const result = await this.orsService.processPairs([{ pdv_id: route.pdv_id, origin, destination }], 1, true);
|
||||
const geo = (result[0] as { geojson?: { features?: { properties?: { summary?: { distance: number; duration: number } } }[] } }).geojson;
|
||||
const summary = geo?.features?.[0]?.properties?.summary;
|
||||
if (!summary) throw new Error('ORS no devolvió una ruta vial');
|
||||
const capacity = this.routesState.canAssignToDay(userId, day, route.minimo_horas_semana, undefined, summary.duration / 3600);
|
||||
if (!capacity.allowed) {
|
||||
this.actionError = `${day}: excede la capacidad diaria (${capacity.totalHours.toFixed(1)} h de ${capacity.capacityHours.toFixed(1)} h).`;
|
||||
return;
|
||||
}
|
||||
assignments.push({ userId, day, distance: summary.distance / 1000, duration: summary.duration / 3600 });
|
||||
}
|
||||
this.routesState.addAssignedFromUnassignedMany(route, assignments);
|
||||
delete this.assignmentUser[String(route.pdv_id)];
|
||||
delete this.assignmentDays[String(route.pdv_id)];
|
||||
} catch {
|
||||
this.actionError = `No se pudo calcular la ruta vial para ${route.pdv_nombre}.`;
|
||||
} finally {
|
||||
this.assigningId = null;
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.subs.forEach(s => s.unsubscribe());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
.kpis-panel {
|
||||
border-top: 3px solid var(--glm-azul);
|
||||
}
|
||||
|
||||
.kpis-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: var(--glm-azul);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.kpis-subtitle {
|
||||
margin: 4px 0 0;
|
||||
color: var(--glm-acero);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.kpis-status {
|
||||
color: var(--glm-verde);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kpis-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.export-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--glm-verde);
|
||||
border-radius: 4px;
|
||||
background: var(--glm-verde-bg);
|
||||
color: var(--glm-azul);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.export-button:hover {
|
||||
background: var(--glm-verde);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 92px;
|
||||
padding: 14px;
|
||||
background: var(--glm-surface);
|
||||
border: 1px solid var(--glm-gris-medio);
|
||||
border-left: 4px solid var(--glm-azul);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.kpi-primary { border-left-color: var(--glm-azul); }
|
||||
.kpi-success { border-left-color: var(--glm-verde); }
|
||||
.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-icon {
|
||||
display: grid;
|
||||
flex: 0 0 34px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--glm-verde-bg);
|
||||
color: var(--glm-azul);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.kpi-card > div:last-child {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
color: var(--glm-acero);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.kpi-card strong {
|
||||
color: var(--glm-azul);
|
||||
font-size: 1.45rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.kpi-card em {
|
||||
font-size: 0.8rem;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.kpi-card small {
|
||||
overflow: hidden;
|
||||
color: var(--glm-acero);
|
||||
font-size: 0.73rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-load-card {
|
||||
margin-top: 14px;
|
||||
padding: 12px 14px;
|
||||
background: var(--glm-surface);
|
||||
border: 1px solid var(--glm-gris-medio);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.user-load-card h3 {
|
||||
margin: 0 0 8px;
|
||||
color: var(--glm-azul);
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.user-load-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.user-load-table {
|
||||
width: 100%;
|
||||
min-width: 560px;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.user-load-table th {
|
||||
padding: 7px 8px;
|
||||
background: var(--glm-azul);
|
||||
color: #fff;
|
||||
font-size: 0.72rem;
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.user-load-table td {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--glm-gris-claro);
|
||||
color: var(--glm-gris-oscuro);
|
||||
}
|
||||
|
||||
.user-load-table tbody tr:nth-child(even) {
|
||||
background: var(--glm-gris-claro);
|
||||
}
|
||||
|
||||
.utilization-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.utilization-cell > span:first-child {
|
||||
flex: 0 0 56px;
|
||||
}
|
||||
|
||||
.capacity-warning {
|
||||
cursor: help;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.utilization-bar {
|
||||
display: block;
|
||||
flex: 1 1 140px;
|
||||
min-width: 140px;
|
||||
height: 7px;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
background: var(--glm-gris-medio);
|
||||
}
|
||||
|
||||
.utilization-bar span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--glm-verde);
|
||||
}
|
||||
|
||||
.utilization-cell.warning .utilization-bar span {
|
||||
background: var(--glm-naranja);
|
||||
}
|
||||
|
||||
.utilization-cell.over-capacity .utilization-bar span {
|
||||
background: #c62828;
|
||||
}
|
||||
|
||||
.load-over-capacity td {
|
||||
color: #8f1616;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.reasons-card {
|
||||
margin-top: 14px;
|
||||
padding: 12px 14px;
|
||||
background: var(--glm-verde-bg);
|
||||
border: 1px solid var(--glm-verde-claro);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.reasons-card h3 {
|
||||
margin: 0 0 8px;
|
||||
color: var(--glm-azul);
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.reason-list {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.reason-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid rgba(108, 194, 74, 0.25);
|
||||
color: var(--glm-gris-oscuro);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.reason-row:last-child { border-bottom: 0; }
|
||||
.reason-row strong { color: var(--glm-azul); }
|
||||
|
||||
.kpis-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
color: var(--glm-acero);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.kpis-empty i { color: var(--glm-gris-medio); }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.kpis-heading { flex-direction: column; gap: 6px; }
|
||||
.kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 430px) {
|
||||
.kpi-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<div class="kpis-panel">
|
||||
<div class="kpis-heading">
|
||||
<div>
|
||||
<h2 class="section-title">Indicadores de planificación</h2>
|
||||
<p class="kpis-subtitle">Resumen total de la planificación actual</p>
|
||||
</div>
|
||||
<div class="kpis-actions">
|
||||
<span class="kpis-status" *ngIf="hasData"><i class="pi pi-check-circle"></i> Datos disponibles</span>
|
||||
<button type="button" class="export-button" *ngIf="hasData" (click)="exportPlanning()"><i class="pi pi-download"></i> Descargar XLSX</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kpi-grid">
|
||||
<article class="kpi-card kpi-primary">
|
||||
<div class="kpi-icon"><i class="pi pi-map-marker"></i></div>
|
||||
<div><span class="kpi-label">PDVs totales</span><strong>{{ summary.totalPdvs }}</strong><small>en la planificación</small></div>
|
||||
</article>
|
||||
<article class="kpi-card kpi-success">
|
||||
<div class="kpi-icon"><i class="pi pi-check"></i></div>
|
||||
<div><span class="kpi-label">PDVs asignados</span><strong>{{ summary.assignedPdvs }}</strong><small>{{ summary.coverage | number:'1.1-1' }}% de cobertura</small></div>
|
||||
</article>
|
||||
<article class="kpi-card kpi-warning">
|
||||
<div class="kpi-icon"><i class="pi pi-exclamation-triangle"></i></div>
|
||||
<div><span class="kpi-label">Sin asignar</span><strong>{{ summary.unassignedPdvs }}</strong><small>requieren revisión</small></div>
|
||||
</article>
|
||||
<article class="kpi-card kpi-blue">
|
||||
<div class="kpi-icon"><i class="pi pi-users"></i></div>
|
||||
<div><span class="kpi-label">Usuarios activos</span><strong>{{ summary.users }}</strong><small>{{ summary.days }} días planificados</small></div>
|
||||
</article>
|
||||
<article class="kpi-card kpi-orange">
|
||||
<div class="kpi-icon"><i class="pi pi-compass"></i></div>
|
||||
<div><span class="kpi-label">Distancia total</span><strong>{{ summary.totalDistance | number:'1.0-1' }} <em>km</em></strong><small>{{ summary.averageDistance | number:'1.0-1' }} km promedio por ruta</small></div>
|
||||
</article>
|
||||
<ng-container *ngIf="hasData">
|
||||
<article class="kpi-card kpi-success">
|
||||
<div class="kpi-icon"><i class="pi pi-briefcase"></i></div>
|
||||
<div><span class="kpi-label">Carga de trabajo</span><strong>{{ summary.totalWorkHours | number:'1.0-1' }} <em>h</em></strong><small>horas de trabajo planificadas</small></div>
|
||||
</article>
|
||||
<article class="kpi-card kpi-blue">
|
||||
<div class="kpi-icon"><i class="pi pi-chart-line"></i></div>
|
||||
<div><span class="kpi-label">Utilización promedio</span><strong>{{ summary.averageUtilization | number:'1.0-1' }}<em>%</em></strong><small>carga frente a disponibilidad</small></div>
|
||||
</article>
|
||||
<article class="kpi-card kpi-primary">
|
||||
<div class="kpi-icon"><i class="pi pi-sitemap"></i></div>
|
||||
<div><span class="kpi-label">Rutas por usuario</span><strong>{{ summary.averageRoutesPerUser | number:'1.0-1' }}</strong><small>promedio de asignaciones</small></div>
|
||||
</article>
|
||||
<article class="kpi-card kpi-warning">
|
||||
<div class="kpi-icon"><i class="pi pi-arrows-h"></i></div>
|
||||
<div><span class="kpi-label">Brecha de balance</span><strong>{{ summary.assignmentBalance }}</strong><small>diferencia entre mayor y menor carga</small></div>
|
||||
</article>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div class="user-load-card" *ngIf="userLoads.length">
|
||||
<h3><i class="pi pi-users"></i> Balance de carga por usuario</h3>
|
||||
<div class="user-load-table-wrap">
|
||||
<table class="user-load-table">
|
||||
<thead>
|
||||
<tr><th>Usuario</th><th>Rutas</th><th>Distancia</th><th>Trabajo</th><th>Utilización</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let load of userLoads" [class.load-over-capacity]="load.status === 'over-capacity'">
|
||||
<td>{{ load.name }}</td>
|
||||
<td>{{ load.routes }}</td>
|
||||
<td>{{ load.distance | number:'1.0-1' }} km</td>
|
||||
<td>{{ load.workHours | number:'1.0-1' }} h</td>
|
||||
<td>
|
||||
<div class="utilization-cell" [ngClass]="utilizationClass(load)">
|
||||
<span>{{ load.utilization | number:'1.0-1' }}%</span>
|
||||
<span class="capacity-warning" *ngIf="load.status === 'over-capacity'" [title]="load.alertMessage" aria-label="Alerta de capacidad">⚠️</span>
|
||||
<span class="utilization-bar" [title]="load.alertMessage"><span [style.width.%]="barWidth(load.utilization)"></span></span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="reasons-card" *ngIf="reasons.length">
|
||||
<h3><i class="pi pi-list"></i> Motivos de PDVs sin asignar</h3>
|
||||
<div class="reason-list">
|
||||
<div class="reason-row" *ngFor="let item of reasons">
|
||||
<span>{{ item.reason }}</span>
|
||||
<strong>{{ item.count }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kpis-empty" *ngIf="!hasData">
|
||||
<i class="pi pi-chart-bar"></i>
|
||||
<span>Carga una planificación para visualizar sus indicadores.</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,191 @@
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { RouteState } from '../../../core/route-state/route';
|
||||
import { PlanningExportService } from '../../../shared/services/planning-export.service';
|
||||
import { summarizeDailyRoutes } from '../../../shared/utils/route-sequencing.utils';
|
||||
import { RouteAssignedResponse } from '../../../core/models/route.model';
|
||||
|
||||
interface KpiSummary {
|
||||
totalPdvs: number;
|
||||
assignedPdvs: number;
|
||||
unassignedPdvs: number;
|
||||
coverage: number;
|
||||
users: number;
|
||||
days: number;
|
||||
totalDistance: number;
|
||||
averageDistance: number;
|
||||
totalWorkHours: number;
|
||||
averageRoutesPerUser: number;
|
||||
averageUtilization: number;
|
||||
assignmentBalance: number;
|
||||
}
|
||||
|
||||
interface ReasonSummary {
|
||||
reason: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface UserLoad {
|
||||
id: string;
|
||||
name: string;
|
||||
routes: number;
|
||||
distance: number;
|
||||
workHours: number;
|
||||
capacityHours: number;
|
||||
utilization: number;
|
||||
status: 'ok' | 'warning' | 'over-capacity';
|
||||
alertMessage: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-planning-kpis',
|
||||
imports: [CommonModule],
|
||||
templateUrl: './planning-kpis.component.html',
|
||||
styleUrl: './planning-kpis.component.css'
|
||||
})
|
||||
export class PlanningKpisComponent implements OnInit, OnDestroy {
|
||||
summary: KpiSummary = this.emptySummary();
|
||||
reasons: ReasonSummary[] = [];
|
||||
userLoads: UserLoad[] = [];
|
||||
hasData = false;
|
||||
|
||||
private readonly subs: Subscription[] = [];
|
||||
|
||||
constructor(
|
||||
private routeState: RouteState,
|
||||
private exportService: PlanningExportService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.subs.push(
|
||||
this.routeState.assignedRoutes$.subscribe(() => this.refresh()),
|
||||
this.routeState.noAssignedRoutes$.subscribe(() => this.refresh())
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
exportPlanning(): void {
|
||||
this.exportService.export();
|
||||
}
|
||||
|
||||
private refresh(): void {
|
||||
const assigned = this.routeState.getAssignedSnapshot();
|
||||
const unassigned = this.routeState.getNoAssignedSnapshot();
|
||||
const assignedPdvIds = new Set(assigned.map(route => String(route.json.pdv_id)));
|
||||
// A PDV with at least one scheduled visit is assigned; repeated weekly visits and
|
||||
// partial-assignment alerts must not inflate the planning coverage counters.
|
||||
const unassignedPdvIds = new Set(unassigned
|
||||
.map(route => String(route.pdv_id))
|
||||
.filter(pdvId => !assignedPdvIds.has(pdvId)));
|
||||
const totalPdvs = new Set([...assignedPdvIds, ...unassignedPdvIds]).size;
|
||||
const dailyRoutes = summarizeDailyRoutes(assigned);
|
||||
const totalDistance = dailyRoutes.reduce((sum, day) => sum + day.distanceKm, 0);
|
||||
const totalWorkHours = dailyRoutes.reduce((sum, day) => sum + day.workHours, 0);
|
||||
const users = new Set(assigned.map(route => String(route.json.usuario_id))).size;
|
||||
const days = new Set(assigned.map(route => route.json.dia)).size;
|
||||
const loads = new Map<string, UserLoad>();
|
||||
|
||||
for (const route of assigned) {
|
||||
const key = String(route.json.usuario_id);
|
||||
const current = loads.get(key) ?? {
|
||||
id: key,
|
||||
name: route.json.nombre_usuario,
|
||||
routes: 0,
|
||||
distance: 0,
|
||||
workHours: 0,
|
||||
capacityHours: this.numberValue(route.json.hora_laboral_semanal_usuario),
|
||||
utilization: 0,
|
||||
status: 'ok',
|
||||
alertMessage: ''
|
||||
};
|
||||
current.routes++;
|
||||
// Sequential distance and work are aggregated per user below.
|
||||
loads.set(key, current);
|
||||
}
|
||||
|
||||
this.userLoads = Array.from(loads.values()).map(load => {
|
||||
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);
|
||||
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.status === 'over-capacity'
|
||||
? `Alerta: ${load.name} supera el 100% de su capacidad por carga (${load.workHours.toFixed(1)} h de ${load.capacityHours.toFixed(1)} h). Reasigna PDVs o reduce horas.`
|
||||
: load.status === 'warning'
|
||||
? `${load.name} está sobre el 90% de su capacidad por carga (${load.workHours.toFixed(1)} h de ${load.capacityHours.toFixed(1)} h).`
|
||||
: `${load.name} está dentro de capacidad por carga (${load.workHours.toFixed(1)} h de ${load.capacityHours.toFixed(1)} h).`;
|
||||
return load;
|
||||
}).sort((a, b) => b.routes - a.routes || b.workHours - a.workHours);
|
||||
const routeCounts = this.userLoads.map(load => load.routes);
|
||||
const assignmentBalance = routeCounts.length ? Math.max(...routeCounts) - Math.min(...routeCounts) : 0;
|
||||
const averageUtilization = this.userLoads.length
|
||||
? this.userLoads.reduce((sum, load) => sum + load.utilization, 0) / this.userLoads.length
|
||||
: 0;
|
||||
|
||||
this.summary = {
|
||||
totalPdvs,
|
||||
assignedPdvs: assignedPdvIds.size,
|
||||
unassignedPdvs: unassignedPdvIds.size,
|
||||
coverage: totalPdvs ? (assignedPdvIds.size / totalPdvs) * 100 : 0,
|
||||
users,
|
||||
days,
|
||||
totalDistance,
|
||||
averageDistance: assigned.length ? totalDistance / assigned.length : 0,
|
||||
totalWorkHours,
|
||||
averageRoutesPerUser: users ? assigned.length / users : 0,
|
||||
averageUtilization,
|
||||
assignmentBalance
|
||||
};
|
||||
|
||||
const reasonByPdv = new Map<string, string>();
|
||||
for (const route of unassigned) {
|
||||
const pdvId = String(route.pdv_id);
|
||||
if (assignedPdvIds.has(pdvId) || reasonByPdv.has(pdvId)) continue;
|
||||
const reason = route.motivo?.trim() || 'Sin motivo especificado';
|
||||
reasonByPdv.set(pdvId, reason);
|
||||
}
|
||||
const reasonCounts = new Map<string, number>();
|
||||
for (const reason of reasonByPdv.values()) {
|
||||
reasonCounts.set(reason, (reasonCounts.get(reason) ?? 0) + 1);
|
||||
}
|
||||
this.reasons = Array.from(reasonCounts.entries())
|
||||
.map(([reason, count]) => ({ reason, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
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,
|
||||
averageRoutesPerUser: 0,
|
||||
averageUtilization: 0,
|
||||
assignmentBalance: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
+470
@@ -0,0 +1,470 @@
|
||||
:host {
|
||||
display: block;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#map-overview {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 380px;
|
||||
width: auto;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.overview-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 380px;
|
||||
z-index: 900;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
background: var(--glm-surface);
|
||||
border-right: 3px solid var(--glm-verde);
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid var(--glm-gris-medio);
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
height: 42px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid var(--glm-gris-medio);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 12px;
|
||||
background: var(--glm-surface);
|
||||
border: 1px solid var(--glm-gris-medio);
|
||||
border-radius: 4px;
|
||||
color: var(--glm-azul);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: var(--glm-verde-bg);
|
||||
border-color: var(--glm-verde);
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: var(--glm-azul);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.filters-card {
|
||||
background: var(--glm-verde-bg);
|
||||
border: 1px solid var(--glm-verde-claro);
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--glm-azul);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--glm-gris-medio);
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
font-family: Arial, sans-serif;
|
||||
background: #fff;
|
||||
color: var(--glm-gris-oscuro);
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--glm-verde);
|
||||
box-shadow: 0 0 0 2px rgba(108, 194, 74, 0.15);
|
||||
}
|
||||
|
||||
.filter-stats {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--glm-acero);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filter-empty {
|
||||
font-size: 0.85rem;
|
||||
color: var(--glm-acero);
|
||||
}
|
||||
|
||||
.heatmap-help { display: inline-grid; width: 16px; height: 16px; margin-left: auto; place-items: center; border: 1px solid var(--glm-acero); border-radius: 50%; color: var(--glm-acero); cursor: help; font-size: 0.68rem; font-weight: 700; }
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 24px 12px;
|
||||
color: var(--glm-acero);
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
color: var(--glm-gris-medio);
|
||||
}
|
||||
|
||||
.error-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--glm-rojo-ok);
|
||||
border: 1px solid #F5A6A0;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--glm-gris-oscuro);
|
||||
}
|
||||
|
||||
.unassigned-bottom-drawer {
|
||||
position: fixed;
|
||||
left: 398px;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
z-index: 950;
|
||||
overflow: hidden;
|
||||
border: 1px solid #f2bf86;
|
||||
border-left: 4px solid var(--glm-naranja);
|
||||
border-radius: 10px 10px 4px 4px;
|
||||
background: #fff8ed;
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.unassigned-drawer-toggle {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border: 0;
|
||||
background: #fff8ed;
|
||||
color: var(--glm-azul);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.unassigned-drawer-toggle span { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.unassigned-drawer-toggle .pi-exclamation-triangle { color: var(--glm-naranja); }
|
||||
|
||||
.unassigned-drawer-content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||
gap: 8px;
|
||||
max-height: 240px;
|
||||
padding: 0 12px 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.unassigned-map-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 7px;
|
||||
border: 1px solid #f2bf86;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
color: var(--glm-gris-oscuro);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.unassigned-map-item:hover,
|
||||
.unassigned-map-item.selected-pending { background: #ffedd9; }
|
||||
|
||||
.pending-icon {
|
||||
display: grid;
|
||||
flex: 0 0 23px;
|
||||
width: 23px;
|
||||
height: 23px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--glm-naranja);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pending-text { display: flex; min-width: 0; flex-direction: column; gap: 2px; }
|
||||
.pending-text strong { font-size: 0.78rem; }
|
||||
.pending-text small { overflow: hidden; color: var(--glm-acero); font-size: 0.7rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.unassigned-detail-card { border-top-color: var(--glm-naranja); }
|
||||
|
||||
.pending-edit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.pending-edit-grid label { display: flex; flex-direction: column; gap: 3px; color: var(--glm-azul); font-size: 0.72rem; font-weight: 700; text-transform: uppercase; }
|
||||
.pending-edit-grid input,
|
||||
.pending-edit-grid select { width: 100%; padding: 6px; border: 1px solid var(--glm-gris-medio); border-radius: 3px; background: #fff; color: var(--glm-gris-oscuro); font: inherit; font-size: 0.8rem; }
|
||||
.day-checkboxes { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.day-checkboxes label { display: inline-flex; align-items: center; gap: 3px; color: var(--glm-acero); font-size: 0.72rem; font-weight: 600; }
|
||||
.wide-field { grid-column: 1 / -1; }
|
||||
.pending-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; }
|
||||
.pending-actions .btn-primary, .pending-actions .btn-secondary { padding: 7px 10px; font-size: 0.78rem; }
|
||||
|
||||
.employee-load-list { display: grid; gap: 5px; max-height: 318px; margin-top: 10px; overflow-y: auto; border-top: 1px solid var(--glm-gris-claro); padding-top: 8px; }
|
||||
.employee-load-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 3px 8px; width: 100%; padding: 7px 8px; border: 1px solid var(--glm-gris-medio); border-radius: 4px; background: #fff; color: var(--glm-gris-oscuro); cursor: pointer; font: inherit; text-align: left; }
|
||||
.employee-load-row:hover, .employee-load-row.selected { border-color: var(--glm-verde); background: var(--glm-verde-bg); }
|
||||
.employee-load-row.over-capacity { border-color: #c9252d; background: var(--glm-rojo-ok); }
|
||||
.employee-load-name { overflow: hidden; color: var(--glm-azul); font-size: 0.78rem; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.employee-load-values { color: var(--glm-acero); font-size: 0.7rem; white-space: nowrap; }
|
||||
.employee-load-bar { grid-column: 1 / -1; display: block; height: 6px; overflow: hidden; border-radius: 6px; background: var(--glm-gris-medio); }
|
||||
.employee-load-bar span { display: block; height: 100%; border-radius: inherit; background: var(--glm-verde); }
|
||||
.employee-load-bar.warning span { background: var(--glm-naranja); }
|
||||
.employee-load-bar.over-capacity span { background: #c9252d; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
#map-overview { left: 0; }
|
||||
.overview-sidebar { width: 340px; max-width: calc(100vw - 24px); }
|
||||
.unassigned-bottom-drawer { right: 12px; bottom: 12px; left: 12px; }
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: var(--glm-surface);
|
||||
border: 1px solid var(--glm-gris-medio);
|
||||
border-radius: 4px;
|
||||
padding: 14px;
|
||||
border-top: 3px solid var(--glm-azul);
|
||||
}
|
||||
|
||||
.map-location-button {
|
||||
margin-top: 0;
|
||||
padding: 6px 9px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.info-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 2px solid var(--glm-verde);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.info-card-header h3 {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--glm-azul);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.detail-section + .detail-section {
|
||||
margin-top: 14px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--glm-gris-medio);
|
||||
}
|
||||
|
||||
.detail-section h4 {
|
||||
margin: 0 0 5px;
|
||||
color: var(--glm-verde);
|
||||
font-size: 0.76rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.reassign-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--glm-gris-medio);
|
||||
}
|
||||
|
||||
.reassign-form label { display: flex; flex-direction: column; gap: 3px; color: var(--glm-azul); font-size: 0.72rem; font-weight: 700; text-transform: uppercase; }
|
||||
.reassign-form select { width: 100%; padding: 6px; border: 1px solid var(--glm-gris-medio); border-radius: 3px; background: #fff; color: var(--glm-gris-oscuro); font: inherit; font-size: 0.8rem; }
|
||||
.reassign-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; }
|
||||
.reassign-actions .btn-primary, .reassign-actions .btn-secondary { padding: 7px 10px; font-size: 0.78rem; }
|
||||
.reassign-hint { grid-column: 1 / -1; margin: 0; color: var(--glm-acero); font-size: 0.74rem; }
|
||||
.reassign-form .reassign-actions, .reassign-form .employee-load-list, .reassign-form .error-card { grid-column: 1 / -1; }
|
||||
|
||||
.daily-routes-card { padding: 12px; border: 1px solid var(--glm-gris-medio); border-top: 3px solid var(--glm-azul); border-radius: 4px; background: var(--glm-surface); }
|
||||
.daily-routes-card h3 { margin: 0 0 8px; color: var(--glm-azul); font-size: 0.82rem; text-transform: uppercase; }
|
||||
.daily-route-group { border-bottom: 1px solid var(--glm-gris-claro); }
|
||||
.daily-route-group:last-child { border-bottom: 0; }
|
||||
.daily-route-toggle { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 0; border: 0; background: transparent; color: var(--glm-gris-oscuro); cursor: pointer; font: inherit; text-align: left; }
|
||||
.daily-route-toggle:hover { background: var(--glm-gris-claro); }
|
||||
.daily-route-toggle-title { color: var(--glm-azul); font-weight: 700; }
|
||||
.daily-route-toggle > span:last-child { color: var(--glm-acero); font-size: 0.78rem; }
|
||||
.daily-route-group-content { padding-left: 14px; }
|
||||
.daily-route { display: grid; gap: 3px; padding: 8px 0; border-bottom: 1px solid var(--glm-gris-claro); color: var(--glm-gris-oscuro); font-size: 0.78rem; }
|
||||
.daily-route:last-child { border-bottom: 0; }
|
||||
.daily-route span, .daily-route small { color: var(--glm-acero); }
|
||||
.daily-route.over-capacity small { color: #c9252d; font-weight: 700; }
|
||||
.btn-icon:focus-visible, button:focus-visible, select:focus-visible { outline: 3px solid rgba(108, 194, 74, 0.45); outline-offset: 2px; }
|
||||
|
||||
.btn-icon {
|
||||
background: transparent;
|
||||
border: 1px solid var(--glm-gris-medio);
|
||||
border-radius: 4px;
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
color: var(--glm-azul);
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: var(--glm-verde-bg);
|
||||
border-color: var(--glm-verde);
|
||||
}
|
||||
|
||||
.info-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.info-list div {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid var(--glm-gris-claro);
|
||||
}
|
||||
|
||||
.info-list div:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-list dt {
|
||||
font-weight: 600;
|
||||
color: var(--glm-azul);
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.info-list dd {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--glm-gris-oscuro);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.overview-loading {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid var(--glm-verde-claro);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);
|
||||
color: var(--glm-acero);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid var(--glm-gris-medio);
|
||||
border-top-color: var(--glm-verde);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--glm-surface);
|
||||
color: var(--glm-azul);
|
||||
border: 1px solid var(--glm-gris-medio);
|
||||
border-radius: 4px;
|
||||
padding: 8px 14px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--glm-verde-bg);
|
||||
border-color: var(--glm-verde);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.layer-toggles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--glm-gris-medio);
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
color: var(--glm-gris-oscuro);
|
||||
}
|
||||
|
||||
.custom-marker {
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.marker-inner {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
<div id="map-overview"></div>
|
||||
|
||||
<div class="overview-loading" *ngIf="isLoading">
|
||||
<div class="spinner"></div>
|
||||
<p>Cargando rutas ORS... {{ orsProgress }}/{{ orsTotal }}</p>
|
||||
</div>
|
||||
|
||||
<div class="overview-sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<img src="GLM_completo.png" alt="GomezLee Marketing" class="sidebar-logo" />
|
||||
</div>
|
||||
|
||||
<div class="sidebar-header">
|
||||
<button type="button" class="back-btn" (click)="goBack()" aria-label="Volver a la planificación">
|
||||
<i class="pi pi-arrow-left"></i> Volver
|
||||
</button>
|
||||
<h2 class="sidebar-title">Mapa de rutas</h2>
|
||||
</div>
|
||||
|
||||
<div class="filters-card" *ngIf="usuarios.length">
|
||||
<label class="filter-label">Usuario</label>
|
||||
<select class="filter-select" [(ngModel)]="selectedUsuarioId" (ngModelChange)="onUsuarioChange()">
|
||||
<option value="Todos">Todos los usuarios</option>
|
||||
<option *ngFor="let u of usuarios" [value]="u.id">{{ u.nombre }} ({{ u.id }})</option>
|
||||
</select>
|
||||
|
||||
<label class="filter-label">Día</label>
|
||||
<select class="filter-select" [(ngModel)]="selectedDia" (ngModelChange)="onDiaChange()">
|
||||
<option value="Todos">Todos</option>
|
||||
<option *ngFor="let d of dias" [value]="d">{{ d }}</option>
|
||||
</select>
|
||||
|
||||
<div class="filter-stats" *ngIf="filteredRoutes.length">
|
||||
<span>{{ uniqueFilteredRoutes.length }} PDVs</span>
|
||||
<span>{{ totalDistancia }} km</span>
|
||||
<span>{{ totalDuracionMin }} min</span>
|
||||
</div>
|
||||
|
||||
<div class="layer-toggles">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" [checked]="showHeat" (change)="toggleHeat()" />
|
||||
<i class="pi pi-fire"></i> Heatmap (carga)
|
||||
<span class="heatmap-help" title="El mapa muestra con mayor intensidad las zonas donde se concentra más carga de trabajo en horas.">?</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="filter-empty" *ngIf="!filteredRoutes.length && !error">
|
||||
Sin rutas para este filtro.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" *ngIf="!usuarios.length">
|
||||
<i class="pi pi-inbox"></i>
|
||||
<p>No hay rutas cargadas.</p>
|
||||
<button type="button" class="btn-secondary" (click)="goBack()">Ir a Home</button>
|
||||
</div>
|
||||
|
||||
<div class="error-card" *ngIf="error && usuarios.length">
|
||||
<i class="pi pi-exclamation-triangle"></i>
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<section class="info-card unassigned-detail-card" *ngIf="selectedUnassigned">
|
||||
<div class="info-card-header">
|
||||
<h3>Gestionar PDV sin ruta</h3>
|
||||
<button type="button" class="btn-icon" (click)="selectedUnassigned = null"><i class="pi pi-times"></i></button>
|
||||
</div>
|
||||
<ng-container *ngIf="editingUnassigned; else pendingSummary">
|
||||
<div class="pending-edit-grid">
|
||||
<label>Nombre<input [(ngModel)]="editUnassignedName" type="text"></label>
|
||||
<label>Horas mínimas<input [(ngModel)]="editUnassignedHours" type="number" min="0.01" step="0.25"></label>
|
||||
<label>Latitud<input [(ngModel)]="editUnassignedLat" type="number" step="any"></label>
|
||||
<label>Longitud<input [(ngModel)]="editUnassignedLng" type="number" step="any"></label>
|
||||
<label class="wide-field">Motivo<input [(ngModel)]="editUnassignedReason" type="text"></label>
|
||||
</div>
|
||||
<div class="pending-actions"><button type="button" class="btn-secondary" (click)="saveUnassignedEdit()"><i class="pi pi-check"></i> Guardar</button><button type="button" class="btn-secondary" (click)="cancelUnassignedEdit()">Cancelar</button></div>
|
||||
</ng-container>
|
||||
<ng-template #pendingSummary>
|
||||
<dl class="info-list">
|
||||
<div><dt>PDV</dt><dd>{{ selectedUnassigned.pdv_nombre }}</dd></div>
|
||||
<div><dt>Motivo</dt><dd>{{ selectedUnassigned.motivo }}</dd></div>
|
||||
<div><dt>Coordenadas</dt><dd>{{ selectedUnassigned.latitud }}, {{ selectedUnassigned.longitud }}</dd></div>
|
||||
</dl>
|
||||
<div class="pending-edit-grid">
|
||||
<label>Usuario<select [(ngModel)]="assignmentUser[selectedUnassigned.pdv_id]"><option value="">Seleccionar</option><option *ngFor="let user of unassignedUserOptions" [value]="user.id">{{ user.name }}</option></select></label>
|
||||
<label>Días<div class="day-checkboxes"><label *ngFor="let day of unassignedDayOptions"><input type="checkbox" [checked]="(assignmentDays[selectedUnassigned.pdv_id] || []).includes(day)" (change)="toggleUnassignedDay(selectedUnassigned.pdv_id, day, $any($event.target).checked)">{{ day.substring(0, 3) }}</label></div></label>
|
||||
</div>
|
||||
<div class="pending-actions"><button type="button" class="btn-secondary" (click)="startUnassignedEdit(selectedUnassigned)"><i class="pi pi-pencil"></i> Editar</button><button type="button" class="btn-primary" (click)="assignUnassigned(selectedUnassigned)" [disabled]="assigningUnassignedId === selectedUnassigned.pdv_id"><span *ngIf="assigningUnassignedId === selectedUnassigned.pdv_id" class="spinner-inline"></span><i *ngIf="assigningUnassignedId !== selectedUnassigned.pdv_id" class="pi pi-user-plus"></i> Reasignar</button></div>
|
||||
<div class="employee-load-list" aria-label="Carga de empleados">
|
||||
<button type="button" class="employee-load-row" *ngFor="let employee of unassignedEmployeeLoads" [class.selected]="assignmentUser[selectedUnassigned.pdv_id] === employee.id" [class.over-capacity]="employeeLoadStatus(employee) === 'over-capacity'" (click)="selectUnassignedEmployee(employee.id)">
|
||||
<span class="employee-load-name">{{ employee.name }}</span>
|
||||
<span class="employee-load-values">{{ employee.workHours | number:'1.0-1' }} / {{ employee.capacityHours | number:'1.0-1' }} h · {{ employee.utilization | number:'1.0-1' }}%</span>
|
||||
<span class="employee-load-bar" [ngClass]="employeeLoadStatus(employee)"><span [style.width.%]="employeeLoadWidth(employee)"></span></span>
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
<div class="error-card" *ngIf="unassignedActionError">{{ unassignedActionError }}</div>
|
||||
</section>
|
||||
|
||||
<section class="info-card" *ngIf="selectedRouteDetail">
|
||||
<div class="info-card-header">
|
||||
<h3>Detalle de la ruta</h3>
|
||||
<button type="button" class="btn-icon" (click)="clearSelection()" title="Cerrar detalle" aria-label="Cerrar detalle de ruta">
|
||||
<i class="pi pi-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<h4><i class="pi pi-user"></i> Usuario</h4>
|
||||
<dl class="info-list">
|
||||
<div><dt>Nombre</dt><dd>{{ selectedRouteDetail.json.nombre_usuario }}</dd></div>
|
||||
<div><dt>ID</dt><dd>{{ selectedRouteDetail.json.usuario_id }}</dd></div>
|
||||
<div><dt>Origen</dt><dd>{{ selectedRouteDetail.json.latitud_usuario }}, {{ selectedRouteDetail.json.longitud_usuario }}</dd></div>
|
||||
<div><dt>Horas disponibles</dt><dd>{{ selectedRouteDetail.json.hora_laboral_semanal_usuario }} h/semana</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<h4><i class="pi pi-map-marker"></i> PDV</h4>
|
||||
<dl class="info-list">
|
||||
<div><dt>Nombre</dt><dd>{{ selectedRouteDetail.json.nombre_pdv }}</dd></div>
|
||||
<div><dt>ID</dt><dd>{{ selectedRouteDetail.json.pdv_id }}</dd></div>
|
||||
<div><dt>Día</dt><dd>{{ selectedRouteDetail.json.dia }}</dd></div>
|
||||
<div><dt>Orden de visita</dt><dd>{{ selectedVisitOrder }}</dd></div>
|
||||
<div><dt>Origen del tramo</dt><dd>{{ selectedSegmentOrigin }}</dd></div>
|
||||
<div><dt>Destino</dt><dd><button type="button" class="btn-secondary map-location-button" (click)="openDestinationInGoogleMaps()" title="Abrir ubicación en Google Maps"><i class="pi pi-map"></i> Ver en Google Maps</button></dd></div>
|
||||
<div><dt>Horas trabajo</dt><dd>{{ selectedRouteDetail.json.horas_trabajo }}</dd></div>
|
||||
<div><dt>Distancia tramo</dt><dd>{{ selectedSegmentDistance | number:'1.2-2' }} km</dd></div>
|
||||
<div><dt>Duración tramo</dt><dd>{{ selectedSegmentDurationMinutes | number:'1.0-0' }} min</dd></div>
|
||||
</dl>
|
||||
<div class="reassign-actions" *ngIf="!showReassignForm">
|
||||
<button type="button" class="btn-secondary" (click)="moveSelectedVisit(-1)" [disabled]="!canMoveSelectedVisitUp" aria-label="Mover visita anterior"><i class="pi pi-arrow-up"></i> Subir</button>
|
||||
<button type="button" class="btn-secondary" (click)="moveSelectedVisit(1)" [disabled]="!canMoveSelectedVisitDown" aria-label="Mover visita posterior"><i class="pi pi-arrow-down"></i> Bajar</button>
|
||||
<button type="button" class="btn-secondary" (click)="resetSelectedDayOrder()"><i class="pi pi-refresh"></i> Orden automático</button>
|
||||
<button type="button" class="btn-secondary" (click)="startReassignRoute()"><i class="pi pi-user-edit"></i> Reasignar ruta</button>
|
||||
</div>
|
||||
<div class="reassign-form" *ngIf="showReassignForm">
|
||||
<label>Nuevo usuario<select [(ngModel)]="reassignUserId"><option *ngFor="let user of unassignedUserOptions" [value]="user.id">{{ user.name }}</option></select></label>
|
||||
<label>Día<select [(ngModel)]="reassignDay"><option *ngFor="let day of unassignedDayOptions" [value]="day">{{ day }}</option></select></label>
|
||||
<p class="reassign-hint">Se recalcularán la distancia y duración con ruta vial ORS.</p>
|
||||
<div class="reassign-actions"><button type="button" class="btn-primary" (click)="reassignRoute()" [disabled]="reassigningRoute"><span *ngIf="reassigningRoute" class="spinner-inline"></span><i *ngIf="!reassigningRoute" class="pi pi-check"></i> {{ reassigningRoute ? 'Calculando...' : 'Confirmar' }}</button><button type="button" class="btn-secondary" (click)="cancelReassignRoute()" [disabled]="reassigningRoute">Cancelar</button></div>
|
||||
<div class="employee-load-list" aria-label="Carga de empleados">
|
||||
<button type="button" class="employee-load-row" *ngFor="let employee of reassignmentEmployeeLoads" [class.selected]="reassignUserId === employee.id" [class.over-capacity]="employeeLoadStatus(employee) === 'over-capacity'" (click)="selectReassignmentEmployee(employee.id)">
|
||||
<span class="employee-load-name">{{ employee.name }}</span>
|
||||
<span class="employee-load-values">{{ employee.workHours | number:'1.0-1' }} / {{ employee.capacityHours | number:'1.0-1' }} h · {{ employee.utilization | number:'1.0-1' }}%</span>
|
||||
<span class="employee-load-bar" [ngClass]="employeeLoadStatus(employee)"><span [style.width.%]="employeeLoadWidth(employee)"></span></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="error-card" *ngIf="reassignError">{{ reassignError }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="daily-routes-card" *ngIf="dailySummaries.length">
|
||||
<h3>Rutas diarias</h3>
|
||||
<article class="daily-route-group" *ngFor="let group of dailyGroups">
|
||||
<button type="button" class="daily-route-toggle" (click)="toggleDailyRouteDay(group.day)" [attr.aria-expanded]="isDailyRouteDayExpanded(group.day)">
|
||||
<span class="daily-route-toggle-title"><i class="pi" [class.pi-chevron-right]="!isDailyRouteDayExpanded(group.day)" [class.pi-chevron-down]="isDailyRouteDayExpanded(group.day)"></i> {{ group.day }}</span>
|
||||
<span>{{ group.totalStops }} paradas · {{ group.totalDistance | number:'1.1-1' }} km</span>
|
||||
</button>
|
||||
<div class="daily-route-group-content" *ngIf="isDailyRouteDayExpanded(group.day)">
|
||||
<article class="daily-route" *ngFor="let route of group.routes" [class.over-capacity]="route.exceedsCapacity">
|
||||
<strong>{{ route.userName }}</strong>
|
||||
<span>{{ route.routes.length }} paradas · {{ route.distanceKm | number:'1.1-1' }} km</span>
|
||||
<small [attr.aria-label]="route.exceedsCapacity ? 'Capacidad excedida' : 'Dentro de capacidad'">{{ route.totalHours | number:'1.1-1' }} / {{ route.capacityHours | number:'1.1-1' }} h {{ route.exceedsCapacity ? '· Excede capacidad' : '· Capacidad disponible' }}</small>
|
||||
</article>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<section class="unassigned-bottom-drawer" *ngIf="selectedUsuarioId === 'Todos' && selectedDia === 'Todos' && unassignedRoutes.length" [class.open]="isUnassignedDrawerOpen">
|
||||
<button type="button" class="unassigned-drawer-toggle" (click)="toggleUnassignedDrawer()" [attr.aria-expanded]="isUnassignedDrawerOpen">
|
||||
<span><i class="pi pi-exclamation-triangle"></i> PDVs sin ruta ({{ unassignedRoutes.length }})</span>
|
||||
<i class="pi" [class.pi-chevron-up]="!isUnassignedDrawerOpen" [class.pi-chevron-down]="isUnassignedDrawerOpen"></i>
|
||||
</button>
|
||||
<div class="unassigned-drawer-content" *ngIf="isUnassignedDrawerOpen">
|
||||
<button type="button" class="unassigned-map-item" *ngFor="let pending of unassignedRoutes" [class.selected-pending]="selectedUnassigned?.pdv_id === pending.pdv_id" (click)="selectUnassigned(pending)">
|
||||
<span class="pending-icon">!</span>
|
||||
<span class="pending-text"><strong>{{ pending.pdv_nombre }}</strong><small>{{ pending.motivo }}</small></span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
+955
@@ -0,0 +1,955 @@
|
||||
import { Component, OnInit, OnDestroy, AfterViewInit, ChangeDetectorRef } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
import { Subscription, Subject } from 'rxjs';
|
||||
import * as L from 'leaflet';
|
||||
import 'leaflet.heat';
|
||||
import { RouteState, UnassignedAssignment } from '../../../core/route-state/route';
|
||||
import { OrsService } from '../../../core/services/ors.service';
|
||||
import { RouteAssignedResponse, RouteNoAssignedResponse } from '../../../core/models/route.model';
|
||||
import { colorForUsuario } from '../../../shared/utils/palette.utils';
|
||||
import { DailyRouteSummary, haversineKm, sequenceRoutes, summarizeDailyRoutes } from '../../../shared/utils/route-sequencing.utils';
|
||||
|
||||
interface DailyRouteGroup {
|
||||
day: string;
|
||||
routes: DailyRouteSummary[];
|
||||
totalStops: number;
|
||||
totalDistance: number;
|
||||
}
|
||||
|
||||
interface EmployeeLoadOption {
|
||||
id: string;
|
||||
name: string;
|
||||
workHours: number;
|
||||
capacityHours: number;
|
||||
utilization: number;
|
||||
}
|
||||
|
||||
interface MapRoute {
|
||||
data: RouteAssignedResponse;
|
||||
color: string;
|
||||
origin: [number, number];
|
||||
destination: [number, number];
|
||||
visitOrder: number;
|
||||
totalStops: number;
|
||||
previousPdvName: string | null;
|
||||
requestId: number;
|
||||
segmentDistanceKm?: number;
|
||||
segmentDurationHours?: number;
|
||||
fallbackLine?: L.Polyline;
|
||||
orsLine?: L.Polyline;
|
||||
orsGlow?: L.Polyline;
|
||||
marker?: L.Marker;
|
||||
startMarker?: L.Marker;
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-route-overview-map',
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './route-overview-map.component.html',
|
||||
styleUrls: ['./route-overview-map.component.css']
|
||||
})
|
||||
export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||
private map?: L.Map;
|
||||
private abort$ = new Subject<void>();
|
||||
private subs: Subscription[] = [];
|
||||
private mapRoutes: MapRoute[] = [];
|
||||
private startMarkers: L.Marker[] = [];
|
||||
private selectedMapRoute: MapRoute | null = null;
|
||||
private heatLayer?: any;
|
||||
showHeat = true;
|
||||
private heatPointsCache: [number, number, number][] = [];
|
||||
private unassignedMarkers = new Map<number, L.Marker>();
|
||||
|
||||
usuarios: { id: string; nombre: string }[] = [];
|
||||
dias: string[] = [];
|
||||
selectedUsuarioId: string = 'Todos';
|
||||
selectedDia: string = 'Todos';
|
||||
expandedDailyRouteDay: string | null = null;
|
||||
selectedRouteDetail: RouteAssignedResponse | null = null;
|
||||
|
||||
filteredRoutes: RouteAssignedResponse[] = [];
|
||||
allRoutes: RouteAssignedResponse[] = [];
|
||||
|
||||
isLoading = false;
|
||||
orsProgress = 0;
|
||||
orsTotal = 0;
|
||||
error: string | null = null;
|
||||
unassignedRoutes: RouteNoAssignedResponse[] = [];
|
||||
isUnassignedDrawerOpen = false;
|
||||
selectedUnassigned: RouteNoAssignedResponse | null = null;
|
||||
editingUnassigned: RouteNoAssignedResponse | null = null;
|
||||
editUnassignedName = '';
|
||||
editUnassignedHours = 0;
|
||||
editUnassignedLat = 0;
|
||||
editUnassignedLng = 0;
|
||||
editUnassignedReason = '';
|
||||
assignmentUser: Record<string, string> = {};
|
||||
assignmentDays: Record<string, string[]> = {};
|
||||
assigningUnassignedId: number | null = null;
|
||||
unassignedActionError: string | null = null;
|
||||
reassigningRoute = false;
|
||||
showReassignForm = false;
|
||||
reassignUserId = '';
|
||||
reassignDay = '';
|
||||
reassignError: string | null = null;
|
||||
|
||||
// Summary stats
|
||||
get uniqueFilteredRoutes(): RouteAssignedResponse[] {
|
||||
const unique = new Map<string, RouteAssignedResponse>();
|
||||
for (const route of this.filteredRoutes) {
|
||||
const pdvId = String(route.json.pdv_id);
|
||||
if (!unique.has(pdvId)) unique.set(pdvId, route);
|
||||
}
|
||||
return Array.from(unique.values());
|
||||
}
|
||||
|
||||
get totalDistancia(): string {
|
||||
const uniqueIds = new Set(this.uniqueFilteredRoutes.map(route => String(route.json.pdv_id)));
|
||||
const sum = this.mapRoutes.length
|
||||
? this.mapRoutes
|
||||
.filter(route => uniqueIds.has(String(route.data.json.pdv_id)))
|
||||
.reduce((acc, route) => {
|
||||
const firstSegment = this.mapRoutes.find(candidate => String(candidate.data.json.pdv_id) === String(route.data.json.pdv_id));
|
||||
return firstSegment === route
|
||||
? acc + (route.segmentDistanceKm ?? haversineKm(route.origin, route.destination))
|
||||
: acc;
|
||||
}, 0)
|
||||
: this.uniqueFilteredRoutes.reduce((acc, route) => acc + Number(route.json.distancia), 0);
|
||||
return sum.toFixed(2);
|
||||
}
|
||||
get totalDuracionMin(): string {
|
||||
const uniqueIds = new Set(this.uniqueFilteredRoutes.map(route => String(route.json.pdv_id)));
|
||||
const sum = this.mapRoutes.length
|
||||
? this.mapRoutes
|
||||
.filter(route => uniqueIds.has(String(route.data.json.pdv_id)))
|
||||
.reduce((acc, route) => {
|
||||
const firstSegment = this.mapRoutes.find(candidate => String(candidate.data.json.pdv_id) === String(route.data.json.pdv_id));
|
||||
return firstSegment === route
|
||||
? acc + (route.segmentDurationHours ?? Number(route.data.json.horas_desplazamiento)) * 60
|
||||
: acc;
|
||||
}, 0)
|
||||
: this.uniqueFilteredRoutes.reduce((acc, route) => acc + Number(route.json.horas_desplazamiento) * 60, 0);
|
||||
return sum.toFixed(0);
|
||||
}
|
||||
|
||||
get dailySummaries(): DailyRouteSummary[] {
|
||||
return summarizeDailyRoutes(this.filteredRoutes);
|
||||
}
|
||||
|
||||
get dailyGroups(): DailyRouteGroup[] {
|
||||
const order = ['Lunes', 'Martes', 'Miércoles', 'Miercoles', 'Jueves', 'Viernes'];
|
||||
const groups = new Map<string, DailyRouteSummary[]>();
|
||||
for (const summary of this.dailySummaries) {
|
||||
const routes = groups.get(summary.day) ?? [];
|
||||
routes.push(summary);
|
||||
groups.set(summary.day, routes);
|
||||
}
|
||||
return Array.from(groups, ([day, routes]) => ({
|
||||
day,
|
||||
routes,
|
||||
totalStops: routes.reduce((sum, route) => sum + route.routes.length, 0),
|
||||
totalDistance: routes.reduce((sum, route) => sum + route.distanceKm, 0)
|
||||
})).sort((a, b) => order.indexOf(a.day) - order.indexOf(b.day));
|
||||
}
|
||||
|
||||
toggleDailyRouteDay(day: string): void {
|
||||
this.expandedDailyRouteDay = this.expandedDailyRouteDay === day ? null : day;
|
||||
}
|
||||
|
||||
isDailyRouteDayExpanded(day: string): boolean {
|
||||
return this.expandedDailyRouteDay === day;
|
||||
}
|
||||
|
||||
openDestinationInGoogleMaps(): void {
|
||||
if (!this.selectedRouteDetail) return;
|
||||
const { latitud_pdv, longitud_pdv } = this.selectedRouteDetail.json;
|
||||
const latitude = Number(latitud_pdv);
|
||||
const longitude = Number(longitud_pdv);
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return;
|
||||
const url = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`${latitude},${longitude}`)}`;
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
toggleUnassignedDrawer(): void {
|
||||
this.isUnassignedDrawerOpen = !this.isUnassignedDrawerOpen;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private routeState: RouteState,
|
||||
private orsService: OrsService,
|
||||
private router: Router,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private cdr: ChangeDetectorRef
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.subs.push(
|
||||
this.routeState.assignedRoutes$.subscribe(routes => {
|
||||
this.allRoutes = [...routes];
|
||||
this.usuarios = this.routeState.getUsuariosUnicos();
|
||||
this.dias = this.routeState.getDiasUnicos();
|
||||
// Start each map load with the complete planning selected by default.
|
||||
const qp = this.activatedRoute.snapshot.queryParams;
|
||||
if (qp['pdv_id']) {
|
||||
this.selectedUsuarioId = 'Todos';
|
||||
this.selectedDia = 'Todos';
|
||||
}
|
||||
// If focus pdv provided, auto-select detail after map loads
|
||||
this.applyFilter();
|
||||
this.cdr.detectChanges();
|
||||
if (this.map) this.renderFiltered();
|
||||
})
|
||||
);
|
||||
|
||||
this.subs.push(
|
||||
this.routeState.noAssignedRoutes$.subscribe(routes => {
|
||||
this.unassignedRoutes = [...routes];
|
||||
if (this.map) this.renderFiltered();
|
||||
this.cdr.detectChanges();
|
||||
})
|
||||
);
|
||||
|
||||
// React to queryParams changes
|
||||
this.subs.push(
|
||||
this.activatedRoute.queryParams.subscribe(qp => {
|
||||
// Focus a specific route if pdv_id passed
|
||||
if (qp['pdv_id'] && this.filteredRoutes.length) {
|
||||
const found = this.filteredRoutes.find(r => String(r.json.pdv_id) === String(qp['pdv_id']));
|
||||
if (found) {
|
||||
this.selectedRouteDetail = found;
|
||||
this.highlightRoute(found);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.initMap();
|
||||
this.applyFilter();
|
||||
// Defer render to ensure container has size (fixes white tiles)
|
||||
setTimeout(() => {
|
||||
this.map?.invalidateSize();
|
||||
this.renderFiltered();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.abort$.next();
|
||||
this.abort$.complete();
|
||||
this.subs.forEach(s => s.unsubscribe());
|
||||
const onResize = (this as any)._onResize;
|
||||
if (onResize) window.removeEventListener('resize', onResize);
|
||||
this.clearMap();
|
||||
if (this.map) this.map.remove();
|
||||
}
|
||||
|
||||
onUsuarioChange() {
|
||||
this.updateQueryParams();
|
||||
this.applyFilter();
|
||||
this.renderFiltered();
|
||||
}
|
||||
|
||||
onDiaChange() {
|
||||
this.updateQueryParams();
|
||||
this.applyFilter();
|
||||
this.renderFiltered();
|
||||
}
|
||||
|
||||
private updateQueryParams() {
|
||||
this.router.navigate([], {
|
||||
relativeTo: this.activatedRoute,
|
||||
queryParams: { usuario_id: this.selectedUsuarioId, dia: this.selectedDia },
|
||||
queryParamsHandling: 'merge',
|
||||
replaceUrl: true
|
||||
});
|
||||
}
|
||||
|
||||
private applyFilter() {
|
||||
let list = this.selectedUsuarioId === 'Todos'
|
||||
? [...this.allRoutes]
|
||||
: this.allRoutes.filter(r => String(r.json.usuario_id) === String(this.selectedUsuarioId));
|
||||
if (this.selectedDia !== 'Todos') {
|
||||
list = list.filter(r => r.json.dia === this.selectedDia);
|
||||
}
|
||||
this.filteredRoutes = list;
|
||||
if (this.filteredRoutes.length === 0) {
|
||||
this.error = this.allRoutes.length === 0 ? 'No hay rutas cargadas. Sube archivos en Home.' : 'Sin rutas para este usuario/día.';
|
||||
} else {
|
||||
this.error = null;
|
||||
}
|
||||
// Clear selected detail if not in filtered
|
||||
if (this.selectedRouteDetail && !this.filteredRoutes.some(route => this.sameRoute(route, this.selectedRouteDetail!))) {
|
||||
this.selectedRouteDetail = null;
|
||||
this.selectedMapRoute = null;
|
||||
}
|
||||
}
|
||||
|
||||
private initMap() {
|
||||
this.map = L.map('map-overview').setView([18.4861, -69.9312], 10);
|
||||
const pdvPane = this.map.createPane('pdvPane');
|
||||
const heatPane = this.map.createPane('heatPane');
|
||||
pdvPane.style.zIndex = '750';
|
||||
heatPane.style.zIndex = '700';
|
||||
const popupPane = this.map.getPane('popupPane');
|
||||
if (popupPane) popupPane.style.zIndex = '1000';
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(this.map);
|
||||
// Fix tiles after sidebar overlay
|
||||
setTimeout(() => this.map?.invalidateSize(), 200);
|
||||
// Resize handler
|
||||
const onResize = () => this.map?.invalidateSize();
|
||||
window.addEventListener('resize', onResize);
|
||||
// store for cleanup
|
||||
(this as any)._onResize = onResize;
|
||||
}
|
||||
|
||||
private clearMap() {
|
||||
for (const mr of this.mapRoutes) {
|
||||
if (mr.fallbackLine) this.map?.removeLayer(mr.fallbackLine);
|
||||
if (mr.orsLine) this.map?.removeLayer(mr.orsLine);
|
||||
if (mr.orsGlow) this.map?.removeLayer(mr.orsGlow);
|
||||
if (mr.marker) this.map?.removeLayer(mr.marker);
|
||||
}
|
||||
this.unassignedMarkers.forEach(marker => this.map?.removeLayer(marker));
|
||||
this.unassignedMarkers.clear();
|
||||
if (this.heatLayer) {
|
||||
this.map?.removeLayer(this.heatLayer);
|
||||
this.heatLayer = undefined;
|
||||
}
|
||||
for (const m of this.startMarkers) this.map?.removeLayer(m);
|
||||
this.heatPointsCache = [];
|
||||
this.mapRoutes = [];
|
||||
this.startMarkers = [];
|
||||
this.selectedMapRoute = null;
|
||||
}
|
||||
|
||||
private async renderFiltered() {
|
||||
if (!this.map) return;
|
||||
this.abort$.next();
|
||||
this.clearMap();
|
||||
this.isLoading = false;
|
||||
this.orsProgress = 0;
|
||||
this.orsTotal = 0;
|
||||
if (!this.filteredRoutes.length) {
|
||||
this.cdr.detectChanges();
|
||||
return;
|
||||
}
|
||||
|
||||
// Add one colored start marker per user in the global view.
|
||||
const starts = new Map<string, RouteAssignedResponse['json']>();
|
||||
this.filteredRoutes.forEach(route => starts.set(String(route.json.usuario_id), route.json));
|
||||
starts.forEach((start, userId) => {
|
||||
const color = colorForUsuario(userId).color;
|
||||
const count = this.filteredRoutes.filter(route => String(route.json.usuario_id) === userId).length;
|
||||
const startIcon = L.divIcon({
|
||||
className: 'custom-marker marker-start',
|
||||
html: `<div class="marker-inner" style="background:${color};border:3px solid #fff;">A</div>`,
|
||||
iconSize: [34, 34],
|
||||
iconAnchor: [17, 17],
|
||||
popupAnchor: [0, -18]
|
||||
});
|
||||
this.startMarkers.push(
|
||||
L.marker([Number(start.latitud_usuario), Number(start.longitud_usuario)], { icon: startIcon, zIndexOffset: 100 })
|
||||
.addTo(this.map!)
|
||||
.bindPopup(`<strong>Usuario:</strong> ${start.nombre_usuario}<br><strong>PDVs:</strong> ${count}`)
|
||||
);
|
||||
});
|
||||
|
||||
// Build one daily chain per user: home -> nearest PDV -> next nearest PDV.
|
||||
const bounds: L.LatLngExpression[] = [];
|
||||
const sequencedRoutes = sequenceRoutes(this.filteredRoutes);
|
||||
this.filteredRoutes = sequencedRoutes.map(segment => segment.route);
|
||||
sequencedRoutes.forEach((segment, idx) => {
|
||||
const route = segment.route;
|
||||
const j = route.json;
|
||||
const color = colorForUsuario(j.usuario_id).color;
|
||||
const origin = segment.origin;
|
||||
const dest = segment.destination;
|
||||
bounds.push([origin[1], origin[0]]);
|
||||
bounds.push([Number(j.latitud_pdv), Number(j.longitud_pdv)]);
|
||||
|
||||
// Fallback dashed line (visible immediately)
|
||||
const fallbackLine = L.polyline(
|
||||
[[origin[1], origin[0]], [dest[1], dest[0]]],
|
||||
{ color, weight: 4, dashArray: '10, 10', opacity: 0.6 }
|
||||
).addTo(this.map!);
|
||||
|
||||
// Marker for PDV
|
||||
const pdvIcon = L.divIcon({
|
||||
className: 'custom-marker marker-end',
|
||||
html: `<div class="location-pin"><span>${segment.order}</span></div>`,
|
||||
iconSize: [30, 38],
|
||||
iconAnchor: [15, 38],
|
||||
popupAnchor: [0, -18]
|
||||
});
|
||||
const marker = L.marker([Number(j.latitud_pdv), Number(j.longitud_pdv)], { icon: pdvIcon, pane: 'pdvPane', zIndexOffset: 1000 })
|
||||
.addTo(this.map!)
|
||||
.bindPopup(`<strong>${j.nombre_pdv}</strong><br>Parada ${segment.order} de ${segment.totalStops} • ${j.dia}`);
|
||||
// Heat point
|
||||
const w = this.computeWeight(j);
|
||||
this.heatPointsCache.push([Number(j.latitud_pdv), Number(j.longitud_pdv), w]);
|
||||
|
||||
const mr: MapRoute = {
|
||||
data: route,
|
||||
color,
|
||||
origin,
|
||||
destination: dest,
|
||||
visitOrder: segment.order,
|
||||
totalStops: segment.totalStops,
|
||||
previousPdvName: segment.previousPdvName,
|
||||
requestId: idx + 1,
|
||||
fallbackLine,
|
||||
marker
|
||||
};
|
||||
this.mapRoutes.push(mr);
|
||||
|
||||
// Click handlers
|
||||
const onClick = () => this.selectRoute(mr);
|
||||
fallbackLine.on('click', onClick);
|
||||
marker.on('click', onClick);
|
||||
});
|
||||
|
||||
this.updateHeatLayer();
|
||||
this.toggleLayerVisibility();
|
||||
|
||||
// Fit bounds
|
||||
try {
|
||||
this.map!.fitBounds(L.latLngBounds(bounds as L.LatLngTuple[]).pad(0.15));
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Progressive ORS hydration — always in background (heat only hides visually)
|
||||
this.hydrateWithOrs();
|
||||
this.renderUnassignedMarkers();
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
|
||||
private renderUnassignedMarkers(): void {
|
||||
if (!this.map || this.selectedUsuarioId !== 'Todos' || this.selectedDia !== 'Todos') return;
|
||||
for (const route of this.unassignedRoutes) {
|
||||
const icon = L.divIcon({
|
||||
className: 'custom-marker unassigned-marker',
|
||||
html: '<div class="location-pin unassigned-pin"><span>!</span></div>',
|
||||
iconSize: [30, 38],
|
||||
iconAnchor: [15, 38]
|
||||
});
|
||||
const marker = L.marker([route.latitud, route.longitud], { icon, pane: 'pdvPane', zIndexOffset: 1000 })
|
||||
.addTo(this.map)
|
||||
.bindPopup(`<strong>PDV sin ruta</strong><br>${route.pdv_nombre}<br>Motivo: ${route.motivo}`);
|
||||
marker.on('click', () => this.selectUnassigned(route));
|
||||
this.unassignedMarkers.set(route.pdv_id, marker);
|
||||
}
|
||||
}
|
||||
|
||||
private async hydrateWithOrs() {
|
||||
if (!this.filteredRoutes.length) return;
|
||||
this.isLoading = true;
|
||||
this.orsTotal = this.filteredRoutes.length;
|
||||
this.orsProgress = 0;
|
||||
this.cdr.detectChanges();
|
||||
|
||||
const pares = this.mapRoutes.map(route => ({
|
||||
pdv_id: route.requestId,
|
||||
origin: route.origin,
|
||||
destination: route.destination
|
||||
}));
|
||||
|
||||
const { promise } = this.orsService.processPairsWithProgress(pares, 2, true, this.abort$);
|
||||
try {
|
||||
const results = await promise;
|
||||
if (!results.length) {
|
||||
this.isLoading = false;
|
||||
this.cdr.detectChanges();
|
||||
return;
|
||||
}
|
||||
const geoByRequest = new Map<number, unknown>();
|
||||
for (const item of results) {
|
||||
const g = item as { pdv_id?: number; geojson?: unknown; error?: string };
|
||||
if (g && !g.error && g.geojson) {
|
||||
geoByRequest.set(g.pdv_id!, g.geojson);
|
||||
} else if (g && g.error) {
|
||||
// error entry, skip but count progress
|
||||
} else {
|
||||
// fallback: if item is raw geojson without wrapper (old cache), treat as geojson
|
||||
const maybeGeo = item as unknown;
|
||||
if (maybeGeo && (maybeGeo as any).type) {
|
||||
// cannot map, will fallback to index correlation below
|
||||
}
|
||||
}
|
||||
}
|
||||
const useMap = geoByRequest.size > 0;
|
||||
for (let i = 0; i < this.mapRoutes.length; i++) {
|
||||
const mr = this.mapRoutes[i];
|
||||
let geo: unknown = null;
|
||||
if (useMap) {
|
||||
geo = geoByRequest.get(mr.requestId);
|
||||
} else {
|
||||
const res = results[i] as { error?: string; geojson?: unknown; type?: string; features?: unknown[] };
|
||||
if ((res as any)?.error) {
|
||||
this.orsProgress = i + 1;
|
||||
this.cdr.detectChanges();
|
||||
continue;
|
||||
}
|
||||
geo = (res as any)?.geojson ?? res;
|
||||
}
|
||||
if (!geo) {
|
||||
this.orsProgress = i + 1;
|
||||
this.cdr.detectChanges();
|
||||
continue;
|
||||
}
|
||||
const coords = this.extractCoords(geo);
|
||||
if (!coords.length) {
|
||||
this.orsProgress = i + 1;
|
||||
this.cdr.detectChanges();
|
||||
continue;
|
||||
}
|
||||
if (mr.fallbackLine) {
|
||||
this.map?.removeLayer(mr.fallbackLine);
|
||||
mr.fallbackLine = undefined;
|
||||
}
|
||||
// Heatmap and route layers are independent; both remain visible together.
|
||||
const glow = L.polyline(coords, { color: mr.color, weight: 12, opacity: 0.25, lineCap: 'round', lineJoin: 'round' }).addTo(this.map!);
|
||||
const line = L.polyline(coords, { color: mr.color, weight: 5, opacity: 0.95, lineCap: 'round', lineJoin: 'round' }).addTo(this.map!);
|
||||
const summary = this.extractSummary(geo);
|
||||
if (summary) {
|
||||
mr.segmentDistanceKm = summary.distance / 1000;
|
||||
mr.segmentDurationHours = summary.duration / 3600;
|
||||
mr.marker?.setPopupContent(
|
||||
`<strong>${mr.data.json.nombre_pdv}</strong><br>Parada ${mr.visitOrder} de ${mr.totalStops} • ${mr.data.json.dia}<br>${mr.segmentDistanceKm.toFixed(2)} km • ${(mr.segmentDurationHours * 60).toFixed(0)} min`
|
||||
);
|
||||
}
|
||||
line.on('click', () => this.selectRoute(mr));
|
||||
mr.orsGlow = glow;
|
||||
mr.orsLine = line;
|
||||
if (this.selectedRouteDetail && this.sameRoute(this.selectedRouteDetail, mr.data)) {
|
||||
this.fitSelectedRoute(mr);
|
||||
}
|
||||
this.orsProgress = i + 1;
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
this.toggleLayerVisibility();
|
||||
} catch {
|
||||
// Ignore abort / network errors, fallback lines remain
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
}
|
||||
|
||||
private extractCoords(geojson: unknown): L.LatLngExpression[] {
|
||||
const g = geojson as { type: string; features?: { geometry: { coordinates: number[][] } }[]; coordinates?: number[][] };
|
||||
if (g?.type === 'FeatureCollection' && g.features?.length) {
|
||||
return g.features[0].geometry.coordinates.map(c => [c[1], c[0]] as L.LatLngExpression);
|
||||
}
|
||||
if (g?.type === 'LineString' && g.coordinates) {
|
||||
return g.coordinates.map(c => [c[1], c[0]] as L.LatLngExpression);
|
||||
}
|
||||
const maybeFeature = (geojson as { features?: { geometry: { coordinates: number[][] } }[] })?.features;
|
||||
if (maybeFeature?.length) {
|
||||
return maybeFeature[0].geometry.coordinates.map(c => [c[1], c[0]] as L.LatLngExpression);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private extractSummary(geojson: unknown): { distance: number; duration: number } | null {
|
||||
const summary = (geojson as { features?: { properties?: { summary?: { distance: number; duration: number } } }[] })
|
||||
?.features?.[0]?.properties?.summary;
|
||||
return summary ?? null;
|
||||
}
|
||||
|
||||
private selectRoute(mr: MapRoute) {
|
||||
this.selectedUnassigned = null;
|
||||
this.editingUnassigned = null;
|
||||
this.unassignedActionError = null;
|
||||
this.selectedRouteDetail = mr.data;
|
||||
this.highlightRoute(mr.data);
|
||||
this.fitSelectedRoute(mr);
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
|
||||
selectRouteFromList(route: RouteAssignedResponse): void {
|
||||
this.selectedUnassigned = null;
|
||||
this.editingUnassigned = null;
|
||||
this.unassignedActionError = null;
|
||||
const mapRoute = this.mapRoutes.find(mr => this.sameRoute(mr.data, route));
|
||||
if (mapRoute) {
|
||||
this.selectRoute(mapRoute);
|
||||
return;
|
||||
}
|
||||
this.selectedRouteDetail = route;
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
|
||||
startReassignRoute(): void {
|
||||
if (!this.selectedRouteDetail) return;
|
||||
this.showReassignForm = true;
|
||||
this.reassignUserId = String(this.selectedRouteDetail.json.usuario_id);
|
||||
this.reassignDay = this.selectedRouteDetail.json.dia;
|
||||
this.reassignError = null;
|
||||
}
|
||||
|
||||
cancelReassignRoute(): void {
|
||||
this.showReassignForm = false;
|
||||
this.reassignError = null;
|
||||
}
|
||||
|
||||
moveSelectedVisit(direction: -1 | 1): void {
|
||||
if (!this.selectedRouteDetail) return;
|
||||
this.routeState.moveRouteVisit(this.selectedRouteDetail, direction);
|
||||
}
|
||||
|
||||
resetSelectedDayOrder(): void {
|
||||
if (!this.selectedRouteDetail) return;
|
||||
this.routeState.resetRouteVisitOrder(this.selectedRouteDetail.json.usuario_id, this.selectedRouteDetail.json.dia);
|
||||
}
|
||||
|
||||
async reassignRoute(): Promise<void> {
|
||||
const route = this.selectedRouteDetail;
|
||||
if (!route || !this.reassignUserId || !this.reassignDay || this.reassigningRoute) return;
|
||||
const sameUser = String(route.json.usuario_id) === this.reassignUserId;
|
||||
const sameDay = route.json.dia === this.reassignDay;
|
||||
if (sameUser && sameDay) {
|
||||
this.cancelReassignRoute();
|
||||
return;
|
||||
}
|
||||
const target = this.routeState.getAssignedSnapshot().find(item => String(item.json.usuario_id) === this.reassignUserId);
|
||||
if (!target) {
|
||||
this.reassignError = 'No se encontró el usuario seleccionado.';
|
||||
return;
|
||||
}
|
||||
this.reassigningRoute = true;
|
||||
this.reassignError = null;
|
||||
try {
|
||||
const origin = [Number(target.json.longitud_usuario), Number(target.json.latitud_usuario)];
|
||||
const destination = [Number(route.json.longitud_pdv), Number(route.json.latitud_pdv)];
|
||||
const results = await this.orsService.processPairs([{ pdv_id: route.json.pdv_id, origin, destination }], 1, true);
|
||||
const response = results[0] as { geojson?: { features?: { properties?: { summary?: { distance: number; duration: number } } }[] }; error?: string } | undefined;
|
||||
const summary = response?.geojson?.features?.[0]?.properties?.summary;
|
||||
if (response?.error || !summary) throw new Error('ORS no devolvió una ruta vial');
|
||||
const capacity = this.routeState.canAssignToDay(
|
||||
this.reassignUserId,
|
||||
this.reassignDay,
|
||||
Number(route.json.horas_trabajo || route.json.hora_minima_semanal_pdv || 0),
|
||||
route,
|
||||
summary.duration / 3600
|
||||
);
|
||||
if (!capacity.allowed) {
|
||||
this.reassignError = `La reasignación excede la capacidad diaria (${capacity.totalHours.toFixed(1)} h de ${capacity.capacityHours.toFixed(1)} h).`;
|
||||
return;
|
||||
}
|
||||
this.routeState.updateAssignedRoute(route, this.reassignUserId, this.reassignDay, {
|
||||
distance: summary.distance / 1000,
|
||||
duration: summary.duration / 3600
|
||||
});
|
||||
this.selectedRouteDetail = this.routeState.findRoute(route.json.pdv_id, this.reassignUserId);
|
||||
this.showReassignForm = false;
|
||||
} catch {
|
||||
this.reassignError = 'No se pudo recalcular la ruta vial. La asignación anterior se mantuvo.';
|
||||
} finally {
|
||||
this.reassigningRoute = false;
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
}
|
||||
|
||||
private fitSelectedRoute(mr: MapRoute): void {
|
||||
if (!this.map) return;
|
||||
const line = mr.orsLine || mr.fallbackLine;
|
||||
if (!line) return;
|
||||
const bounds = line.getBounds();
|
||||
const json = mr.data.json;
|
||||
bounds.extend([Number(json.latitud_usuario), Number(json.longitud_usuario)]);
|
||||
bounds.extend([Number(json.latitud_pdv), Number(json.longitud_pdv)]);
|
||||
if (bounds.isValid()) {
|
||||
this.map.fitBounds(bounds, { padding: [36, 36], maxZoom: 16, animate: true, duration: 0.5 });
|
||||
}
|
||||
}
|
||||
|
||||
routeVisitOrder(route: RouteAssignedResponse): number {
|
||||
return this.mapRoutes.find(item => this.sameRoute(item.data, route))?.visitOrder ?? 0;
|
||||
}
|
||||
|
||||
routeSegmentDistance(route: RouteAssignedResponse): number {
|
||||
const segment = this.mapRoutes.find(item => this.sameRoute(item.data, route));
|
||||
return segment?.segmentDistanceKm ?? Number(route.json.distancia);
|
||||
}
|
||||
|
||||
routeSegmentDurationMinutes(route: RouteAssignedResponse): number {
|
||||
const segment = this.mapRoutes.find(item => this.sameRoute(item.data, route));
|
||||
return (segment?.segmentDurationHours ?? Number(route.json.horas_desplazamiento)) * 60;
|
||||
}
|
||||
|
||||
get selectedVisitOrder(): string {
|
||||
const segment = this.selectedMapSegment;
|
||||
return segment ? `${segment.visitOrder} de ${segment.totalStops}` : '-';
|
||||
}
|
||||
|
||||
get canMoveSelectedVisitUp(): boolean {
|
||||
return (this.selectedMapSegment?.visitOrder ?? 0) > 1;
|
||||
}
|
||||
|
||||
get canMoveSelectedVisitDown(): boolean {
|
||||
const segment = this.selectedMapSegment;
|
||||
return !!segment && segment.visitOrder < segment.totalStops;
|
||||
}
|
||||
|
||||
get selectedSegmentOrigin(): string {
|
||||
const segment = this.selectedMapSegment;
|
||||
if (!segment) return '-';
|
||||
return segment.previousPdvName ?? `Casa de ${segment.data.json.nombre_usuario}`;
|
||||
}
|
||||
|
||||
get selectedSegmentDistance(): number {
|
||||
const segment = this.selectedMapSegment;
|
||||
return segment?.segmentDistanceKm ?? Number(this.selectedRouteDetail?.json.distancia ?? 0);
|
||||
}
|
||||
|
||||
get selectedSegmentDurationMinutes(): number {
|
||||
const segment = this.selectedMapSegment;
|
||||
return (segment?.segmentDurationHours ?? Number(this.selectedRouteDetail?.json.horas_desplazamiento ?? 0)) * 60;
|
||||
}
|
||||
|
||||
private get selectedMapSegment(): MapRoute | null {
|
||||
if (!this.selectedRouteDetail) return null;
|
||||
return this.mapRoutes.find(item => this.sameRoute(item.data, this.selectedRouteDetail!)) ?? null;
|
||||
}
|
||||
|
||||
private sameRoute(left: RouteAssignedResponse, right: RouteAssignedResponse): boolean {
|
||||
return left.json.pdv_id === right.json.pdv_id &&
|
||||
String(left.json.usuario_id) === String(right.json.usuario_id) &&
|
||||
left.json.dia === right.json.dia;
|
||||
}
|
||||
|
||||
private getEmployeeLoads(excluding?: RouteAssignedResponse): EmployeeLoadOption[] {
|
||||
const profiles = new Map<string, RouteAssignedResponse>();
|
||||
const workHours = new Map<string, number>();
|
||||
for (const route of this.routeState.getAssignedSnapshot()) {
|
||||
const id = String(route.json.usuario_id);
|
||||
if (!profiles.has(id)) profiles.set(id, route);
|
||||
if (route !== excluding) {
|
||||
workHours.set(id, (workHours.get(id) ?? 0) + Number(route.json.horas_trabajo || route.json.hora_minima_semanal_pdv || 0));
|
||||
}
|
||||
}
|
||||
return Array.from(profiles, ([id, profile]) => {
|
||||
const capacityHours = Number(profile.json.hora_laboral_semanal_usuario || 0);
|
||||
const assignedHours = workHours.get(id) ?? 0;
|
||||
return {
|
||||
id,
|
||||
name: profile.json.nombre_usuario,
|
||||
workHours: assignedHours,
|
||||
capacityHours,
|
||||
utilization: capacityHours ? (assignedHours / capacityHours) * 100 : 0
|
||||
};
|
||||
}).sort((a, b) => a.utilization - b.utilization || a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
get unassignedUserOptions(): { id: string; name: string }[] {
|
||||
return this.routeState.getUsuariosUnicos().map(user => ({ id: user.id, name: user.nombre }));
|
||||
}
|
||||
|
||||
get unassignedEmployeeLoads(): EmployeeLoadOption[] {
|
||||
return this.getEmployeeLoads();
|
||||
}
|
||||
|
||||
get reassignmentEmployeeLoads(): EmployeeLoadOption[] {
|
||||
return this.getEmployeeLoads(this.selectedRouteDetail ?? undefined);
|
||||
}
|
||||
|
||||
get unassignedDayOptions(): string[] {
|
||||
return this.routeState.getDiasUnicos();
|
||||
}
|
||||
|
||||
employeeLoadWidth(employee: EmployeeLoadOption): number {
|
||||
return Math.min(Math.max(employee.utilization, 0), 100);
|
||||
}
|
||||
|
||||
employeeLoadStatus(employee: EmployeeLoadOption): 'ok' | 'warning' | 'over-capacity' {
|
||||
return employee.utilization > 100 ? 'over-capacity' : employee.utilization > 90 ? 'warning' : 'ok';
|
||||
}
|
||||
|
||||
selectUnassignedEmployee(employeeId: string): void {
|
||||
if (!this.selectedUnassigned) return;
|
||||
this.assignmentUser[String(this.selectedUnassigned.pdv_id)] = employeeId;
|
||||
}
|
||||
|
||||
selectReassignmentEmployee(employeeId: string): void {
|
||||
this.reassignUserId = employeeId;
|
||||
}
|
||||
|
||||
toggleUnassignedDay(pdvId: number, day: string, checked: boolean): void {
|
||||
const key = String(pdvId);
|
||||
const days = new Set(this.assignmentDays[key] ?? []);
|
||||
checked ? days.add(day) : days.delete(day);
|
||||
this.assignmentDays[key] = Array.from(days);
|
||||
}
|
||||
|
||||
selectUnassigned(route: RouteNoAssignedResponse): void {
|
||||
this.selectedUnassigned = route;
|
||||
this.selectedRouteDetail = null;
|
||||
this.showReassignForm = false;
|
||||
this.reassignError = null;
|
||||
this.map?.setView([route.latitud, route.longitud], Math.max(this.map.getZoom(), 14));
|
||||
this.unassignedMarkers.get(route.pdv_id)?.openPopup();
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
|
||||
startUnassignedEdit(route: RouteNoAssignedResponse): void {
|
||||
this.editingUnassigned = route;
|
||||
this.editUnassignedName = route.pdv_nombre;
|
||||
this.editUnassignedHours = route.minimo_horas_semana;
|
||||
this.editUnassignedLat = route.latitud;
|
||||
this.editUnassignedLng = route.longitud;
|
||||
this.editUnassignedReason = route.motivo;
|
||||
this.unassignedActionError = null;
|
||||
}
|
||||
|
||||
cancelUnassignedEdit(): void {
|
||||
this.editingUnassigned = null;
|
||||
}
|
||||
|
||||
saveUnassignedEdit(): void {
|
||||
if (!this.editingUnassigned || !this.editUnassignedName.trim()) {
|
||||
this.unassignedActionError = 'El nombre del PDV es obligatorio.';
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(this.editUnassignedLat) || this.editUnassignedLat < -90 || this.editUnassignedLat > 90 ||
|
||||
!Number.isFinite(this.editUnassignedLng) || this.editUnassignedLng < -180 || this.editUnassignedLng > 180) {
|
||||
this.unassignedActionError = 'Latitud o longitud inválida.';
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(this.editUnassignedHours) || this.editUnassignedHours <= 0) {
|
||||
this.unassignedActionError = 'Las horas mínimas deben ser mayores que cero.';
|
||||
return;
|
||||
}
|
||||
this.routeState.updateNoAssignedRoute(this.editingUnassigned, {
|
||||
pdv_nombre: this.editUnassignedName.trim(),
|
||||
minimo_horas_semana: this.editUnassignedHours,
|
||||
latitud: this.editUnassignedLat,
|
||||
longitud: this.editUnassignedLng,
|
||||
motivo: this.editUnassignedReason.trim()
|
||||
});
|
||||
this.selectedUnassigned = { ...this.editingUnassigned, pdv_nombre: this.editUnassignedName.trim(), latitud: this.editUnassignedLat, longitud: this.editUnassignedLng };
|
||||
this.editingUnassigned = null;
|
||||
}
|
||||
|
||||
async assignUnassigned(route: RouteNoAssignedResponse): Promise<void> {
|
||||
const userId = this.assignmentUser[String(route.pdv_id)];
|
||||
const days = this.assignmentDays[String(route.pdv_id)] ?? [];
|
||||
if (!userId || !days.length) {
|
||||
this.unassignedActionError = 'Selecciona usuario y al menos un día antes de asignar.';
|
||||
return;
|
||||
}
|
||||
const user = this.routeState.getAssignedSnapshot().find(item => String(item.json.usuario_id) === userId);
|
||||
if (!user) return;
|
||||
this.assigningUnassignedId = route.pdv_id;
|
||||
this.unassignedActionError = null;
|
||||
try {
|
||||
const origin = [Number(user.json.longitud_usuario), Number(user.json.latitud_usuario)];
|
||||
const destination = [Number(route.longitud), Number(route.latitud)];
|
||||
const assignments: UnassignedAssignment[] = [];
|
||||
for (const day of days) {
|
||||
const results = await this.orsService.processPairs([{ pdv_id: route.pdv_id, origin, destination }], 1, true);
|
||||
const geo = (results[0] as { geojson?: { features?: { properties?: { summary?: { distance: number; duration: number } } }[] }; error?: string });
|
||||
const summary = geo?.geojson?.features?.[0]?.properties?.summary;
|
||||
if (geo?.error || !summary) throw new Error('No se obtuvo una ruta vial');
|
||||
const capacity = this.routeState.canAssignToDay(userId, day, route.minimo_horas_semana, undefined, summary.duration / 3600);
|
||||
if (!capacity.allowed) {
|
||||
this.unassignedActionError = `${day}: excede la capacidad diaria (${capacity.totalHours.toFixed(1)} h de ${capacity.capacityHours.toFixed(1)} h).`;
|
||||
return;
|
||||
}
|
||||
assignments.push({ userId, day, distance: summary.distance / 1000, duration: summary.duration / 3600 });
|
||||
}
|
||||
this.routeState.addAssignedFromUnassignedMany(route, assignments);
|
||||
this.selectedUnassigned = null;
|
||||
delete this.assignmentUser[String(route.pdv_id)];
|
||||
delete this.assignmentDays[String(route.pdv_id)];
|
||||
} catch {
|
||||
this.unassignedActionError = `No se pudo calcular la ruta vial para ${route.pdv_nombre}.`;
|
||||
} finally {
|
||||
this.assigningUnassignedId = null;
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
}
|
||||
|
||||
highlightRoute(route: RouteAssignedResponse) {
|
||||
// Reset previous selection style
|
||||
if (this.selectedMapRoute) {
|
||||
const prev = this.selectedMapRoute.orsLine || this.selectedMapRoute.fallbackLine;
|
||||
if (prev) (prev as L.Polyline).setStyle?.({ weight: this.selectedMapRoute.orsLine ? 5 : 4, opacity: this.selectedMapRoute.orsLine ? 0.95 : 0.6 });
|
||||
}
|
||||
const mr = this.mapRoutes.find(item => this.sameRoute(item.data, route));
|
||||
if (mr) {
|
||||
this.selectedMapRoute = mr;
|
||||
const line = mr.orsLine || mr.fallbackLine;
|
||||
if (line) (line as L.Polyline).setStyle({ weight: 8, opacity: 1 });
|
||||
// Bring to front
|
||||
if (mr.orsLine) mr.orsLine.bringToFront?.();
|
||||
else if (mr.fallbackLine) mr.fallbackLine.bringToFront?.();
|
||||
try {
|
||||
mr.marker?.openPopup();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
private computeWeight(j: RouteAssignedResponse['json']): number {
|
||||
const v = parseFloat(String(j.horas_trabajo));
|
||||
if (isNaN(v)) return 1;
|
||||
const vals = this.filteredRoutes.map(r => parseFloat(String(r.json.horas_trabajo))).filter(n => !isNaN(n));
|
||||
if (!vals.length) return 1;
|
||||
const min = Math.min(...vals), max = Math.max(...vals);
|
||||
if (max === min) return 1;
|
||||
return 0.3 + 0.7 * (v - min) / (max - min);
|
||||
}
|
||||
|
||||
private updateHeatLayer() {
|
||||
if (this.heatLayer) {
|
||||
this.map?.removeLayer(this.heatLayer);
|
||||
this.heatLayer = undefined;
|
||||
}
|
||||
if (!this.showHeat || !this.heatPointsCache.length || !this.map) return;
|
||||
this.heatLayer = (L as any).heatLayer(this.heatPointsCache, {
|
||||
pane: 'heatPane',
|
||||
radius: 32,
|
||||
blur: 24,
|
||||
maxZoom: 18,
|
||||
minOpacity: 0.55,
|
||||
max: 1.8,
|
||||
gradient: { 0.2: '#b7f05f', 0.45: '#6CC24A', 0.7: '#FF6A13', 1.0: '#D32F2F' }
|
||||
}).addTo(this.map);
|
||||
}
|
||||
|
||||
toggleHeat() {
|
||||
this.showHeat = !this.showHeat;
|
||||
this.updateHeatLayer();
|
||||
this.toggleLayerVisibility();
|
||||
const needsHydrate = !this.mapRoutes.some(m => m.orsLine) || this.orsTotal === 0 || this.orsProgress < this.orsTotal;
|
||||
if (!this.showHeat && needsHydrate) {
|
||||
this.hydrateWithOrs();
|
||||
}
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
|
||||
private toggleLayerVisibility() {
|
||||
this.mapRoutes.forEach(mr => {
|
||||
if (mr.fallbackLine) (mr.fallbackLine as any).setStyle({ opacity: 0.6 });
|
||||
if (mr.orsLine) (mr.orsLine as any).setStyle({ opacity: 0.95 });
|
||||
if (mr.orsGlow) (mr.orsGlow as any).setStyle({ opacity: 0.25 });
|
||||
});
|
||||
}
|
||||
|
||||
clearSelection() {
|
||||
if (this.selectedMapRoute) {
|
||||
const line = this.selectedMapRoute.orsLine || this.selectedMapRoute.fallbackLine;
|
||||
if (line) (line as L.Polyline).setStyle({ weight: this.selectedMapRoute.orsLine ? 5 : 4, opacity: this.selectedMapRoute.orsLine ? 0.95 : 0.6 });
|
||||
}
|
||||
this.selectedMapRoute = null;
|
||||
this.selectedRouteDetail = null;
|
||||
}
|
||||
|
||||
goBack() {
|
||||
this.router.navigate(['/home']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './pdv.schema';
|
||||
export * from './usuario.schema';
|
||||
@@ -0,0 +1,49 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const PDV_REQUIRED_HEADERS = [
|
||||
'ID',
|
||||
'Nombre del PDV',
|
||||
'Segmentación',
|
||||
'Latitud',
|
||||
'Longitud',
|
||||
'Visitas semanales',
|
||||
'Duración visita(horas)',
|
||||
'Prioridad',
|
||||
] as const;
|
||||
|
||||
export const PDV_NORMALIZED_REQUIRED = [
|
||||
'id',
|
||||
'nombre_del_pdv',
|
||||
'segmentacion',
|
||||
'latitud',
|
||||
'longitud',
|
||||
'prioridad',
|
||||
] as const;
|
||||
|
||||
export const SEGMENTACIONES = ['AA', 'A', 'B', 'C', 'D'] as const;
|
||||
|
||||
export const pdvRowSchema = z.object({
|
||||
id: z.union([z.string(), z.number()]).refine(v => String(v).trim() !== '', { message: 'ID requerido' }),
|
||||
nombre_del_pdv: z.string().min(1, 'Nombre del PDV requerido'),
|
||||
segmentacion: z.string().refine(v => (SEGMENTACIONES as readonly string[]).includes(v.toUpperCase()), {
|
||||
message: `Segmentación debe ser ${SEGMENTACIONES.join(', ')}`,
|
||||
}),
|
||||
latitud: z.number().min(-90, 'Latitud mínima -90').max(90, 'Latitud máxima 90'),
|
||||
longitud: z.number().min(-180, 'Longitud mínima -180').max(180, 'Longitud máxima 180'),
|
||||
visitas_semanales: z.number().int('Visitas semanales debe ser entero').min(1, 'Visitas semanales mínimo 1').max(7, 'Visitas semanales máximo 7').optional(),
|
||||
duracion_visitahoras: z.number().min(0.01, 'Duración visita debe ser >0').max(168, 'Duración visita máximo 168').optional(),
|
||||
minimo_horas_semana: z.number().min(0.01, 'Mínimo horas semana debe ser >0').max(168, 'Mínimo horas semana máximo 168').optional(),
|
||||
prioridad: z.number().int('Prioridad debe ser entero').min(1, 'Prioridad mínimo 1').max(5, 'Prioridad máximo 5'),
|
||||
}).superRefine((data, ctx) => {
|
||||
const hasVisitsFormat = data.visitas_semanales !== undefined && data.duracion_visitahoras !== undefined;
|
||||
if (!hasVisitsFormat && data.minimo_horas_semana === undefined) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['minimo_horas_semana'],
|
||||
message: 'Incluye Mínimo horas semana o Visitas semanales + Duración visita(horas)'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type PdvRow = z.infer<typeof pdvRowSchema>;
|
||||
export const PDV_MAX_ROWS = 2000;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const USUARIO_REQUIRED_HEADERS = [
|
||||
'ID',
|
||||
'Nombre del empleado',
|
||||
'Latitud',
|
||||
'Longitud',
|
||||
'Horas por día usuario',
|
||||
'Horas por semana usuario',
|
||||
] as const;
|
||||
|
||||
export const USUARIO_NORMALIZED_REQUIRED = [
|
||||
'id',
|
||||
'nombre_del_empleado',
|
||||
'latitud',
|
||||
'longitud',
|
||||
'horas_por_dia_usuario',
|
||||
'horas_por_semana_usuario',
|
||||
] as const;
|
||||
|
||||
export const usuarioRowSchema = z.object({
|
||||
id: z.union([z.string(), z.number()]).refine(v => String(v).trim() !== '', { message: 'ID requerido' }),
|
||||
nombre_del_empleado: z.string().min(1, 'Nombre del empleado requerido'),
|
||||
latitud: z.number().min(-90, 'Latitud mínima -90').max(90, 'Latitud máxima 90'),
|
||||
longitud: z.number().min(-180, 'Longitud mínima -180').max(180, 'Longitud máxima 180'),
|
||||
horas_por_dia_usuario: z.number().min(0.5, 'Horas por día mínimo 0.5').max(24, 'Horas por día máximo 24'),
|
||||
horas_por_semana_usuario: z.number().min(1, 'Horas por semana mínimo 1').max(168, 'Horas por semana máximo 168'),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.horas_por_semana_usuario > data.horas_por_dia_usuario * 7) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Horas por semana no puede exceder 7 × horas por día',
|
||||
path: ['horas_por_semana_usuario'],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type UsuarioRow = z.infer<typeof usuarioRowSchema>;
|
||||
export const USUARIO_MAX_ROWS = 50;
|
||||
@@ -0,0 +1,217 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { normalizeRow, normalizeHeader, cleanNum } from '../utils/header-normalization.utils';
|
||||
import { pdvRowSchema, PDV_NORMALIZED_REQUIRED, PDV_MAX_ROWS, SEGMENTACIONES } from '../schemas/pdv.schema';
|
||||
import { usuarioRowSchema, USUARIO_NORMALIZED_REQUIRED, USUARIO_MAX_ROWS } from '../schemas/usuario.schema';
|
||||
|
||||
export interface FieldError {
|
||||
row: number; // 1-indexed excel row (2 = first data row)
|
||||
field: string;
|
||||
message: string;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
export interface PreviewResult {
|
||||
fileName: string;
|
||||
type: 'PDV' | 'Usuarios';
|
||||
valid: boolean;
|
||||
totalRows: number;
|
||||
validRows: number;
|
||||
headers: string[];
|
||||
normalizedHeaders: string[];
|
||||
missingHeaders: string[];
|
||||
errors: FieldError[];
|
||||
duplicateIds: (string | number)[];
|
||||
sampleValidRows: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ExcelPreviewService {
|
||||
|
||||
async previewFile(file: File, type: 'PDV' | 'Usuarios'): Promise<PreviewResult> {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const workbook = XLSX.read(buffer, { type: 'array', cellDates: true });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const sheet = workbook.Sheets[sheetName];
|
||||
if (!sheetName || !sheet) {
|
||||
throw new Error(`${type}: El archivo no contiene una hoja válida`);
|
||||
}
|
||||
const rawRows: Record<string, unknown>[] = XLSX.utils.sheet_to_json(sheet, { defval: null, raw: true });
|
||||
|
||||
const headers = this.extractHeaders(sheet);
|
||||
const normalizedHeaders = headers.map(h => normalizeHeader(String(h)));
|
||||
|
||||
const required = type === 'PDV' ? [...PDV_NORMALIZED_REQUIRED] : [...USUARIO_NORMALIZED_REQUIRED];
|
||||
const displayRequired = type === 'PDV'
|
||||
? ['ID', 'Nombre del PDV', 'Segmentación', 'Latitud', 'Longitud', 'Visitas semanales', 'Duración visita(horas)', 'Prioridad']
|
||||
: ['ID', 'Nombre del empleado', 'Latitud', 'Longitud', 'Horas por día usuario', 'Horas por semana usuario'];
|
||||
|
||||
const missingHeaders: string[] = [];
|
||||
for (let i = 0; i < required.length; i++) {
|
||||
if (!normalizedHeaders.includes(required[i])) {
|
||||
missingHeaders.push(displayRequired[i]);
|
||||
}
|
||||
}
|
||||
if (type === 'PDV') {
|
||||
const hasVisitsFormat = normalizedHeaders.includes('visitas_semanales') && normalizedHeaders.includes('duracion_visitahoras');
|
||||
const hasWeeklyHours = normalizedHeaders.includes('minimo_horas_semana');
|
||||
if (!hasVisitsFormat && !hasWeeklyHours) {
|
||||
missingHeaders.push('Mínimo horas semana o Visitas semanales + Duración visita(horas)');
|
||||
}
|
||||
}
|
||||
|
||||
const errors: FieldError[] = [];
|
||||
const repeatedHeaders = normalizedHeaders.filter((header, index) => header && normalizedHeaders.indexOf(header) !== index);
|
||||
if (repeatedHeaders.length) {
|
||||
errors.push({ row: 1, field: 'headers', message: `Columnas duplicadas: ${Array.from(new Set(repeatedHeaders)).join(', ')}` });
|
||||
}
|
||||
if (missingHeaders.length) {
|
||||
errors.push({ row: 1, field: 'headers', message: `Columnas faltantes: ${missingHeaders.join(', ')}` });
|
||||
}
|
||||
|
||||
if (rawRows.length === 0) {
|
||||
errors.push({ row: 1, field: 'file', message: `${type}: Archivo vacío o sin filas de datos` });
|
||||
return this.buildResult(file.name, type, headers, normalizedHeaders, missingHeaders, rawRows, errors, []);
|
||||
}
|
||||
|
||||
const maxRows = type === 'PDV' ? PDV_MAX_ROWS : USUARIO_MAX_ROWS;
|
||||
if (rawRows.length > maxRows) {
|
||||
errors.push({ row: 1, field: 'file', message: `${type}: Máximo ${maxRows} filas permitidas (tiene ${rawRows.length})` });
|
||||
}
|
||||
|
||||
const normalizedRows = rawRows.map(r => normalizeRow(r as Record<string, unknown>));
|
||||
const seenIds = new Map<string, number[]>();
|
||||
const rowErrors: FieldError[] = [];
|
||||
|
||||
normalizedRows.forEach((row, idx) => {
|
||||
const excelRow = idx + 2;
|
||||
const idVal = row['id'];
|
||||
if (idVal !== null && idVal !== undefined && String(idVal).trim() !== '') {
|
||||
const key = String(idVal).trim();
|
||||
if (!seenIds.has(key)) seenIds.set(key, []);
|
||||
seenIds.get(key)!.push(excelRow);
|
||||
}
|
||||
|
||||
// Coerce numeric fields with cleanNum before zod
|
||||
const coerced: Record<string, unknown> = { ...row };
|
||||
if (type === 'PDV') {
|
||||
coerced['latitud'] = this.coerceNum(row['latitud']);
|
||||
coerced['longitud'] = this.coerceNum(row['longitud']);
|
||||
coerced['visitas_semanales'] = this.coerceNum(row['visitas_semanales']);
|
||||
coerced['duracion_visitahoras'] = this.coerceNum(row['duracion_visitahoras']);
|
||||
coerced['minimo_horas_semana'] = this.coerceNum(row['minimo_horas_semana']);
|
||||
coerced['prioridad'] = this.coerceNum(row['prioridad']);
|
||||
if (typeof coerced['segmentacion'] === 'string') {
|
||||
coerced['segmentacion'] = String(coerced['segmentacion']).trim().toUpperCase();
|
||||
}
|
||||
// validate with zod
|
||||
const result = pdvRowSchema.safeParse(coerced);
|
||||
if (!result.success) {
|
||||
for (const issue of result.error.issues) {
|
||||
const field = String(issue.path[0] ?? 'unknown');
|
||||
rowErrors.push({ row: excelRow, field, message: issue.message, value: (row as Record<string, unknown>)[field] ?? coerced[field] });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
coerced['latitud'] = this.coerceNum(row['latitud']);
|
||||
coerced['longitud'] = this.coerceNum(row['longitud']);
|
||||
coerced['horas_por_dia_usuario'] = this.coerceNum(row['horas_por_dia_usuario']);
|
||||
coerced['horas_por_semana_usuario'] = this.coerceNum(row['horas_por_semana_usuario']);
|
||||
const result = usuarioRowSchema.safeParse(coerced);
|
||||
if (!result.success) {
|
||||
for (const issue of result.error.issues) {
|
||||
const field = String(issue.path[0] ?? 'unknown');
|
||||
rowErrors.push({ row: excelRow, field, message: issue.message, value: (row as Record<string, unknown>)[field] ?? coerced[field] });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const duplicateIds: (string | number)[] = [];
|
||||
for (const [id, rows] of seenIds.entries()) {
|
||||
if (rows.length > 1) {
|
||||
duplicateIds.push(id);
|
||||
for (const r of rows) {
|
||||
rowErrors.push({ row: r, field: 'id', message: `ID duplicado: ${id}`, value: id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allErrors = [...errors, ...rowErrors];
|
||||
return this.buildResult(file.name, type, headers, normalizedHeaders, missingHeaders, normalizedRows, allErrors, duplicateIds);
|
||||
}
|
||||
|
||||
private coerceNum(value: unknown): unknown {
|
||||
if (value === null || value === undefined || value === '') return value;
|
||||
const n = cleanNum(value);
|
||||
return Number.isNaN(n) ? value : n;
|
||||
}
|
||||
|
||||
async preparePdvForOptimizer(file: File): Promise<File> {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const workbook = XLSX.read(buffer, { type: 'array', cellDates: true });
|
||||
const sheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
const rows = XLSX.utils.sheet_to_json(sheet, { defval: null, raw: true })
|
||||
.map(row => normalizeRow(row as Record<string, unknown>));
|
||||
const normalized = rows.map(row => {
|
||||
const weeklyHours = cleanNum(row['minimo_horas_semana']);
|
||||
const visits = cleanNum(row['visitas_semanales']);
|
||||
const duration = cleanNum(row['duracion_visitahoras']);
|
||||
return {
|
||||
'ID': row['id'],
|
||||
'Nombre del PDV': row['nombre_del_pdv'],
|
||||
'Segmentación': row['segmentacion'],
|
||||
'Latitud': cleanNum(row['latitud']),
|
||||
'Longitud': cleanNum(row['longitud']),
|
||||
'Visitas semanales': Number.isFinite(visits) ? visits : 1,
|
||||
'Duración visita(horas)': Number.isFinite(duration) ? duration : weeklyHours,
|
||||
'Prioridad': cleanNum(row['prioridad'])
|
||||
};
|
||||
});
|
||||
const output = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(output, XLSX.utils.json_to_sheet(normalized), 'PDV');
|
||||
const data = XLSX.write(output, { bookType: 'xlsx', type: 'array' });
|
||||
return new File([data], file.name, { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||
}
|
||||
|
||||
private extractHeaders(sheet: XLSX.WorkSheet): string[] {
|
||||
const range = XLSX.utils.decode_range(sheet['!ref'] || 'A1');
|
||||
const headers: string[] = [];
|
||||
for (let c = range.s.c; c <= range.e.c; c++) {
|
||||
const addr = XLSX.utils.encode_cell({ r: range.s.r, c });
|
||||
const cell = sheet[addr];
|
||||
headers.push(cell ? String(cell.v) : '');
|
||||
}
|
||||
return headers.filter(h => h !== '');
|
||||
}
|
||||
|
||||
private buildResult(
|
||||
fileName: string,
|
||||
type: 'PDV' | 'Usuarios',
|
||||
headers: string[],
|
||||
normalizedHeaders: string[],
|
||||
missingHeaders: string[],
|
||||
rows: Record<string, unknown>[],
|
||||
errors: FieldError[],
|
||||
duplicateIds: (string | number)[]
|
||||
): PreviewResult {
|
||||
const validRows = Math.max(0, rows.length - new Set(errors.filter(e => e.field !== 'headers' && e.field !== 'file').map(e => e.row)).size);
|
||||
const hasHeaderErrors = missingHeaders.length > 0;
|
||||
const maxRows = type === 'PDV' ? PDV_MAX_ROWS : USUARIO_MAX_ROWS;
|
||||
const overLimit = rows.length > maxRows;
|
||||
const valid = !hasHeaderErrors && !overLimit && errors.length === 0;
|
||||
return {
|
||||
fileName,
|
||||
type,
|
||||
valid,
|
||||
totalRows: rows.length,
|
||||
validRows: valid ? rows.length : validRows,
|
||||
headers,
|
||||
normalizedHeaders,
|
||||
missingHeaders,
|
||||
errors,
|
||||
duplicateIds,
|
||||
sampleValidRows: rows.slice(0, 3),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { saveAs } from 'file-saver';
|
||||
import { RouteState } from '../../core/route-state/route';
|
||||
import { sequenceRoutes, summarizeDailyRoutes } from '../utils/route-sequencing.utils';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlanningExportService {
|
||||
constructor(private routeState: RouteState) {}
|
||||
|
||||
export(): void {
|
||||
const assigned = this.routeState.getAssignedSnapshot();
|
||||
const unassigned = this.routeState.getNoAssignedSnapshot();
|
||||
const users = new Map<string, { nombre: string; rutas: number; distancia: number; trabajo: number; capacidad: number }>();
|
||||
|
||||
for (const route of assigned) {
|
||||
const key = String(route.json.usuario_id);
|
||||
const current = users.get(key) ?? {
|
||||
nombre: route.json.nombre_usuario,
|
||||
rutas: 0,
|
||||
distancia: 0,
|
||||
trabajo: 0,
|
||||
capacidad: Number(route.json.hora_laboral_semanal_usuario) || 0
|
||||
};
|
||||
current.rutas++;
|
||||
current.distancia += Number(route.json.distancia) || 0;
|
||||
current.trabajo += Number.parseFloat(String(route.json.horas_trabajo ?? '')) || 0;
|
||||
users.set(key, current);
|
||||
}
|
||||
|
||||
const total = assigned.length + unassigned.length;
|
||||
const dailyRoutes = summarizeDailyRoutes(assigned);
|
||||
const totalDistance = dailyRoutes.reduce((sum, route) => sum + route.distanceKm, 0);
|
||||
const totalTravel = dailyRoutes.reduce((sum, route) => sum + route.travelHours, 0);
|
||||
const workbook = XLSX.utils.book_new();
|
||||
|
||||
const summary = [
|
||||
{ Indicador: 'PDVs totales', Valor: total },
|
||||
{ Indicador: 'PDVs asignados', Valor: assigned.length },
|
||||
{ Indicador: 'PDVs sin asignar', Valor: unassigned.length },
|
||||
{ Indicador: 'Cobertura (%)', Valor: total ? Number(((assigned.length / total) * 100).toFixed(1)) : 0 },
|
||||
{ Indicador: 'Usuarios activos', Valor: users.size },
|
||||
{ Indicador: 'Días planificados', Valor: new Set(assigned.map(route => route.json.dia)).size },
|
||||
{ Indicador: 'Distancia total (km)', Valor: Number(totalDistance.toFixed(2)) },
|
||||
{ Indicador: 'Desplazamiento total (h)', Valor: Number(totalTravel.toFixed(2)) }
|
||||
];
|
||||
|
||||
const assignedRows = sequenceRoutes(assigned).map(segment => ({
|
||||
'Usuario ID': segment.route.json.usuario_id,
|
||||
Usuario: segment.route.json.nombre_usuario,
|
||||
'PDV ID': segment.route.json.pdv_id,
|
||||
PDV: segment.route.json.nombre_pdv,
|
||||
Día: segment.route.json.dia,
|
||||
'Distancia (km)': segment.route.json.distancia,
|
||||
'Desplazamiento (h)': segment.route.json.horas_desplazamiento,
|
||||
'Horas trabajo': segment.route.json.horas_trabajo,
|
||||
'Horas disponibles': segment.route.json.hora_laboral_semanal_usuario,
|
||||
'Orden visita': segment.order,
|
||||
'Origen del tramo': segment.previousPdvName ?? `Casa de ${segment.route.json.nombre_usuario}`
|
||||
}));
|
||||
const unassignedRows = unassigned.map(route => ({
|
||||
'PDV ID': route.pdv_id,
|
||||
PDV: route.pdv_nombre,
|
||||
'Mínimo horas/semana': route.minimo_horas_semana,
|
||||
Latitud: route.latitud,
|
||||
Longitud: route.longitud,
|
||||
Motivo: route.motivo
|
||||
}));
|
||||
const userRows = Array.from(users, ([id, user]) => {
|
||||
const utilization = user.capacidad ? (user.trabajo / user.capacidad) * 100 : 0;
|
||||
return {
|
||||
'Usuario ID': id,
|
||||
Usuario: user.nombre,
|
||||
Rutas: user.rutas,
|
||||
'Distancia (km)': Number(user.distancia.toFixed(2)),
|
||||
'Horas trabajo': Number(user.trabajo.toFixed(2)),
|
||||
'Capacidad (h)': user.capacidad,
|
||||
'Utilización (%)': Number(utilization.toFixed(1)),
|
||||
Estado: utilization > 100 ? 'Excede capacidad' : utilization > 90 ? 'Riesgo' : 'OK'
|
||||
};
|
||||
});
|
||||
|
||||
this.appendSheet(workbook, summary, 'Resumen');
|
||||
this.appendSheet(workbook, assignedRows, 'Rutas asignadas');
|
||||
this.appendSheet(workbook, unassignedRows, 'PDVs sin asignar');
|
||||
this.appendSheet(workbook, userRows, 'Carga por usuario');
|
||||
this.appendSheet(workbook, dailyRoutes.map(route => ({
|
||||
Usuario: route.userName,
|
||||
Día: route.day,
|
||||
Paradas: route.routes.length,
|
||||
'Distancia secuencial (km)': Number(route.distanceKm.toFixed(2)),
|
||||
'Desplazamiento secuencial (h)': Number(route.travelHours.toFixed(2)),
|
||||
'Carga total (h)': Number(route.totalHours.toFixed(2)),
|
||||
'Capacidad diaria (h)': Number(route.capacityHours.toFixed(2)),
|
||||
Estado: route.exceedsCapacity ? 'Excede capacidad' : 'OK'
|
||||
})), 'Rutas diarias');
|
||||
|
||||
const output = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
|
||||
saveAs(new Blob([output], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), 'planificacion_rutas.xlsx');
|
||||
}
|
||||
|
||||
private appendSheet(workbook: XLSX.WorkBook, rows: object[], name: string): void {
|
||||
const sheet = XLSX.utils.json_to_sheet(rows.length ? rows : [{ Información: 'Sin registros' }]);
|
||||
XLSX.utils.book_append_sheet(workbook, sheet, name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export function normalizeHeader(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9 ]/gi, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '_');
|
||||
}
|
||||
|
||||
export function cleanNum(value: unknown): number {
|
||||
if (value === null || value === undefined || value === '') return NaN;
|
||||
if (typeof value === 'number') return value;
|
||||
const str = String(value).trim().replace(/[^0-9.\-]/g, '');
|
||||
if (str === '' || str === '-' || str === '.') return NaN;
|
||||
const num = Number(str);
|
||||
return Number.isFinite(num) ? num : NaN;
|
||||
}
|
||||
|
||||
export function normalizeRow(row: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key in row) {
|
||||
const newKey = normalizeHeader(key);
|
||||
let val = row[key];
|
||||
if (typeof val === 'string') val = val.trim();
|
||||
out[newKey] = val;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1 +1,4 @@
|
||||
export * from './remove-special-character.utils';
|
||||
export * from './header-normalization.utils';
|
||||
export * from './palette.utils';
|
||||
export * from './route-sequencing.utils';
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export function colorForUsuario(usuarioId: string | number): { color: string; glow: string } {
|
||||
const str = String(usuarioId);
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
|
||||
}
|
||||
hash = Math.abs(hash);
|
||||
// Use a mix of GLM palette + generated HSL to avoid collisions but keep brand feel
|
||||
const glmPalette = ['#6CC24A', '#4F758B', '#FF6A13', '#C4D600', '#5B7F95', '#A4D65E', '#6B8FA3'];
|
||||
const idx = hash % glmPalette.length;
|
||||
// If palette length covers typical usuarios (50 max) collisions low; fallback to HSL for overflow
|
||||
if (hash % 2 === 0) {
|
||||
return { color: glmPalette[idx], glow: glmPalette[idx] };
|
||||
}
|
||||
const h = hash % 360;
|
||||
const s = 65 + (hash % 20);
|
||||
const l = 48 + (hash % 10);
|
||||
const hsl = `hsl(${h} ${s}% ${l}%)`;
|
||||
return { color: hsl, glow: hsl };
|
||||
}
|
||||
|
||||
export function markerStyleForUsuario(usuarioId: string | number): string {
|
||||
return colorForUsuario(usuarioId).color;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { RouteAssignedResponse } from '../../core/models/route.model';
|
||||
|
||||
export interface SequencedRoute {
|
||||
route: RouteAssignedResponse;
|
||||
origin: [number, number];
|
||||
destination: [number, number];
|
||||
order: number;
|
||||
totalStops: number;
|
||||
previousPdvName: string | null;
|
||||
}
|
||||
|
||||
export interface DailyRouteSummary {
|
||||
key: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
day: string;
|
||||
routes: SequencedRoute[];
|
||||
distanceKm: number;
|
||||
travelHours: number;
|
||||
workHours: number;
|
||||
capacityHours: number;
|
||||
totalHours: number;
|
||||
exceedsCapacity: boolean;
|
||||
}
|
||||
|
||||
export function sequenceRoutes(routes: RouteAssignedResponse[]): SequencedRoute[] {
|
||||
const groups = new Map<string, RouteAssignedResponse[]>();
|
||||
for (const route of routes) {
|
||||
const key = `${route.json.usuario_id}\u0000${route.json.dia}`;
|
||||
const group = groups.get(key) ?? [];
|
||||
group.push(route);
|
||||
groups.set(key, group);
|
||||
}
|
||||
|
||||
const sequenced: SequencedRoute[] = [];
|
||||
for (const group of groups.values()) {
|
||||
const first = group[0].json;
|
||||
let current: [number, number] = [Number(first.longitud_usuario), Number(first.latitud_usuario)];
|
||||
let previousPdvName: string | null = null;
|
||||
const hasManualOrder = group.every(route => Number.isFinite(route.json.visit_order));
|
||||
const remaining = hasManualOrder
|
||||
? [...group].sort((a, b) => Number(a.json.visit_order) - Number(b.json.visit_order))
|
||||
: [...group];
|
||||
|
||||
while (remaining.length) {
|
||||
let nearestIndex = 0;
|
||||
if (!hasManualOrder) {
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
for (let index = 0; index < remaining.length; index++) {
|
||||
const candidate = remaining[index].json;
|
||||
const distance = haversineKm(current, [Number(candidate.longitud_pdv), Number(candidate.latitud_pdv)]);
|
||||
if (distance < nearestDistance) {
|
||||
nearestDistance = distance;
|
||||
nearestIndex = index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [route] = remaining.splice(nearestIndex, 1);
|
||||
const destination: [number, number] = [Number(route.json.longitud_pdv), Number(route.json.latitud_pdv)];
|
||||
sequenced.push({
|
||||
route,
|
||||
origin: current,
|
||||
destination,
|
||||
order: group.length - remaining.length,
|
||||
totalStops: group.length,
|
||||
previousPdvName
|
||||
});
|
||||
current = destination;
|
||||
previousPdvName = route.json.nombre_pdv;
|
||||
}
|
||||
}
|
||||
|
||||
return sequenced;
|
||||
}
|
||||
|
||||
export function summarizeDailyRoutes(routes: RouteAssignedResponse[]): DailyRouteSummary[] {
|
||||
const sequences = sequenceRoutes(routes);
|
||||
const grouped = new Map<string, SequencedRoute[]>();
|
||||
for (const segment of sequences) {
|
||||
const key = `${segment.route.json.usuario_id}\u0000${segment.route.json.dia}`;
|
||||
const group = grouped.get(key) ?? [];
|
||||
group.push(segment);
|
||||
grouped.set(key, group);
|
||||
}
|
||||
return Array.from(grouped, ([key, segments]) => {
|
||||
const first = segments[0].route.json;
|
||||
const distanceKm = segments.reduce((sum, segment) => sum + haversineKm(segment.origin, segment.destination), 0);
|
||||
const travelHours = segments.reduce((sum, segment) => sum + Number(segment.route.json.horas_desplazamiento || 0), 0);
|
||||
const workHours = segments.reduce((sum, segment) => sum + Number(segment.route.json.horas_trabajo || segment.route.json.hora_minima_semanal_pdv || 0), 0);
|
||||
const capacityHours = Number(first.hora_laboral_semanal_usuario || 0) / 5;
|
||||
const totalHours = workHours + travelHours;
|
||||
return {
|
||||
key,
|
||||
userId: String(first.usuario_id),
|
||||
userName: first.nombre_usuario,
|
||||
day: first.dia,
|
||||
routes: segments,
|
||||
distanceKm,
|
||||
travelHours,
|
||||
workHours,
|
||||
capacityHours,
|
||||
totalHours,
|
||||
exceedsCapacity: totalHours > capacityHours
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function haversineKm(origin: [number, number], destination: [number, number]): number {
|
||||
const toRadians = (value: number) => value * Math.PI / 180;
|
||||
const [lon1, lat1] = origin;
|
||||
const [lon2, lat2] = destination;
|
||||
const dLat = toRadians(lat2 - lat1);
|
||||
const dLon = toRadians(lon2 - lon1);
|
||||
const a = Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export const environment = {
|
||||
production: true,
|
||||
n8nWebhookUrl: 'https://agenteit.digitalcompass.agency/webhook/calcular-camino',
|
||||
orsBaseUrl: 'http://localhost:18080/ors/v2',
|
||||
requestTimeoutMs: 30000,
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
n8nWebhookUrl: 'https://agenteit.digitalcompass.agency/webhook/calcular-camino',
|
||||
orsBaseUrl: 'http://localhost:18080/ors/v2',
|
||||
requestTimeoutMs: 30000,
|
||||
};
|
||||
@@ -6,7 +6,6 @@
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🗺️</text></svg>">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
|
||||
@@ -1,5 +1,51 @@
|
||||
@import "primeicons/primeicons.css";
|
||||
|
||||
/* Leaflet creates marker HTML outside Angular's style encapsulation. */
|
||||
.location-pin {
|
||||
position: relative;
|
||||
width: 30px;
|
||||
height: 38px;
|
||||
background: #c9252d;
|
||||
clip-path: polygon(50% 100%, 8% 43%, 5% 31%, 10% 18%, 22% 7%, 36% 2%, 50% 0, 64% 2%, 78% 7%, 90% 18%, 95% 31%, 92% 43%);
|
||||
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.42));
|
||||
}
|
||||
|
||||
.location-pin::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 3px 3px 7px;
|
||||
background: #ff424a;
|
||||
clip-path: polygon(50% 100%, 8% 43%, 5% 31%, 10% 18%, 22% 7%, 36% 2%, 50% 0, 64% 2%, 78% 7%, 90% 18%, 95% 31%, 92% 43%);
|
||||
}
|
||||
|
||||
.location-pin::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #620000;
|
||||
}
|
||||
|
||||
.location-pin span {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
width: 14px;
|
||||
color: #fff;
|
||||
font: 700 9px/14px Arial, sans-serif;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* leaflet.heat creates its canvas in the map overlay pane, outside Angular styles. */
|
||||
.leaflet-heatmap-layer {
|
||||
z-index: 700 !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:root {
|
||||
--glm-verde: #6CC24A;
|
||||
--glm-verde-claro: #A4D65E;
|
||||
|
||||
Reference in New Issue
Block a user