feat: implement route planning dashboard including KPIs, user workload tracking, and route assignment management features
This commit is contained in:
@@ -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 './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,67 @@
|
||||
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 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 remaining = [...group];
|
||||
|
||||
while (remaining.length) {
|
||||
let nearestIndex = 0;
|
||||
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 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));
|
||||
}
|
||||
Reference in New Issue
Block a user