feat: implement route planning dashboard including KPIs, user workload tracking, and route assignment management features

This commit is contained in:
2026-08-29 11:55:03 -04:00
parent a0ea3fea48
commit cd5c57c5ee
41 changed files with 3811 additions and 151 deletions
@@ -0,0 +1,182 @@
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]);
}
}
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['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;
}
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,88 @@
import { Injectable } from '@angular/core';
import * as XLSX from 'xlsx';
import { saveAs } from 'file-saver';
import { RouteState } from '../../core/route-state/route';
@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 totalDistance = assigned.reduce((sum, route) => sum + (Number(route.json.distancia) || 0), 0);
const totalTravel = assigned.reduce((sum, route) => sum + (Number(route.json.horas_desplazamiento) || 0), 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 = assigned.map(route => ({
'Usuario ID': route.json.usuario_id,
Usuario: route.json.nombre_usuario,
'PDV ID': route.json.pdv_id,
PDV: route.json.nombre_pdv,
Día: route.json.dia,
'Distancia (km)': route.json.distancia,
'Desplazamiento (h)': route.json.horas_desplazamiento,
'Horas trabajo': route.json.horas_trabajo,
'Horas disponibles': route.json.hora_laboral_semanal_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]) => ({
'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 (%)': user.capacidad ? Number(((user.trabajo / user.capacidad) * 100).toFixed(1)) : 0
}));
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');
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);
}
}