From 69f26cb1245606b166c95e08d5cf0bcc9d39b293 Mon Sep 17 00:00:00 2001 From: EidanThen Date: Mon, 5 Oct 2026 14:13:01 -0400 Subject: [PATCH] feat: unir dashboard y mapa general en una misma pantalla --- .../PDV aguascaleintes.xlsx | Bin 0 -> 10199 bytes ...Detalle_visitas_aguascalientes_limpio.xlsx | Bin 0 -> 9973 bytes .../PDV_aguascalientes_import.xlsx | Bin 0 -> 6391 bytes ...ios_aguascalientes_import_actualizado.xlsx | Bin 0 -> 5442 bytes .../detalle visitas aguascaleintes.xlsx | Bin 0 -> 14375 bytes Proyecto/Mexico-aguascalientes/usuarios.xlsx | Bin 0 -> 9254 bytes n8n-workflow/GenerarReporteRutasPDF.json | 108 +++- .../OptimizadorRutasPDV TEST - Fixed v2.json | 4 +- route-planner/angular.json | 2 +- route-planner/src/app/app.routes.ts | 4 +- .../src/app/core/models/project.model.ts | 2 + .../src/app/core/models/route.model.ts | 2 + .../src/app/core/route-state/route.ts | 10 +- .../src/app/core/services/ors.service.ts | 28 +- .../src/app/core/services/project.service.ts | 4 +- .../private/employees/employees.component.css | 4 +- .../assigned-route-detail.component.ts | 11 +- .../src/app/private/home/home.component.css | 259 +++------ .../src/app/private/home/home.component.html | 86 +-- .../src/app/private/home/home.component.ts | 170 ++---- .../no-assigned-route.component.ts | 30 +- .../planning-kpis/planning-kpis.component.css | 1 + .../planning-kpis.component.html | 24 +- .../planning-kpis/planning-kpis.component.ts | 57 +- .../route-overview-map.component.css | 101 ++++ .../route-overview-map.component.html | 40 +- .../route-overview-map.component.ts | 494 ++++++++++++++---- .../src/app/private/pdvs/pdvs.component.css | 16 +- .../src/app/private/pdvs/pdvs.component.html | 2 +- .../src/app/private/pdvs/pdvs.component.ts | 12 +- .../services/planning-export.service.ts | 31 +- .../services/route-pdf-report.service.ts | 21 +- .../app/shared/utils/country-center.utils.ts | 25 + .../app/shared/utils/fuel-estimation.utils.ts | 43 ++ supabase/proyecto_combustible.sql | 3 + 35 files changed, 1059 insertions(+), 535 deletions(-) create mode 100644 Proyecto/Mexico-aguascalientes/PDV aguascaleintes.xlsx create mode 100644 Proyecto/Mexico-aguascalientes/aguascalientes-modificado/Detalle_visitas_aguascalientes_limpio.xlsx create mode 100644 Proyecto/Mexico-aguascalientes/aguascalientes-modificado/PDV_aguascalientes_import.xlsx create mode 100644 Proyecto/Mexico-aguascalientes/aguascalientes-modificado/Usuarios_aguascalientes_import_actualizado.xlsx create mode 100644 Proyecto/Mexico-aguascalientes/detalle visitas aguascaleintes.xlsx create mode 100644 Proyecto/Mexico-aguascalientes/usuarios.xlsx create mode 100644 route-planner/src/app/shared/utils/country-center.utils.ts create mode 100644 route-planner/src/app/shared/utils/fuel-estimation.utils.ts create mode 100644 supabase/proyecto_combustible.sql diff --git a/Proyecto/Mexico-aguascalientes/PDV aguascaleintes.xlsx b/Proyecto/Mexico-aguascalientes/PDV aguascaleintes.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..47febccf27d934b64e6c817cb5552ece0b9adc36 GIT binary patch literal 10199 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 AZ2$lO literal 0 HcmV?d00001 diff --git a/Proyecto/Mexico-aguascalientes/aguascalientes-modificado/Detalle_visitas_aguascalientes_limpio.xlsx b/Proyecto/Mexico-aguascalientes/aguascalientes-modificado/Detalle_visitas_aguascalientes_limpio.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..0f092261eba21a93782455ecba544d3da3bc5137 GIT binary patch literal 9973 zcmZ{K2Q-{r+x6(35JDu%FnSq83!)PQLG<26i{6diql@07ljuF8Mnn%G%4BrWd+)?I zlJ)-2`{e(=v1Y7Uv#xW_wa?!By6*cvDlgE`NdW)=Hb5finSpGtl3XhC+Ys`75BWBC zFjH}MaC8BiI688A+Sw|_DdBzQx+i_Bus&PZur6ULFLMk;#*FD%JhT>R#Tg)*2Gcn;?bGd?@! z4MzP;3|bIB-~cso{NM#XwLaY`a@!*HJv!O-?`Vl1mbRAsTtZ66=$xk2eI}K{XpeZy ztPo%uyY5yg^(F7zk0t@0v=vWyufYqBh2D8VDU#5b!K-LYM*cQi{`CVMzd}FEa+K}S zCGm$D&4I5|xGt{OKF#PPFtjl6#hN{9bWwJk`?R&xb>!o-5~c{{>QlFj;l<$#Ei5MQ zezhTduDLzj(t&HG#ULzS@fOJAN!QP!$H4>uvX}sX`$%9sZNaWq5Ie}9PwqQtc68@oO$*-hTdNvb zx7gjG6podWq~g97I1wgP%B^kxHm6bWlH!kz)WYp>8e7j zC8g`2tPPU-4uR<5x1&qY5!exEs+Vf1H+*1Xm z!kSX#pGeAM$1Z}txFvy$njcJNVGeyi)SA0Z{d5>2r^^SK-L zB@by%Te5XC_|_?6Nn|gE+({A}CZy|VdmVYnx69Lu)&!NS5^nehqRUffp}=7bxUi-v zu2JY?yh@HOeBKo~@DrZ!H|U;#4RtloW7>Bzxx7Wr9BR{551Zm6K|`(qw*vu7-)H9i zTWI2@yP7UWkLL1!MwwxXFl~!GCW>16#1>n!)TAUk9MDTfZ{ojBxxG-mv@ENPRWKvB z-tvo+M+11&WoT8MU9-3Ic@wvBD;zf4Wdf|7c9UKGAx7m+2~%cJ9Mr#`K@uvOAneMZ zwh&_<|NdHe9fV>d0VHPO1 z8Dg9lKQtvqAuupSE;6^6s-J|S1!#(~Ct!u~>6+un3uxxV$iHD&NkD<$yRXd41OGw* zR5}MI>7K0x$*M7P!`Ja-fFrBo%IoUzf^Sy1_$e{%FG;HDpa%~_gZL;PC+Iyw6~Uae z3gOz~Yz+?$tJfhbEXRL}mCcI_GaTq^c2an;z-@vWVPptzTBC@E{KkyXK%;oYEl zKLW^6VEer~(;O`?BM6QGqT=H;&oBGrg@Mf}iZ03`(U~QDrR{mDuFl71rK?do@s>9L6v2j^%GcuZ9Va@?ZN4UCzDO`0+d? zHPkhS^^5;oUJv&ouQ_X*jIg|whU^e0SMr7S`BUylTD<+Nz#e9WI-P)I>*`OY)C+Rc zi_?=KIn>t*>rK-2B%USqQnMdKUSvr(^AEmAWi5_kb2~9)=Y0$B-zHQ-NE&vse<~Icq!=pwj$&V>Y#yMb zylm-i`MyeVVxGeWSNmWPXOP5my{v8Lq&Rn4pZN7{nMa=N;gTbR>^CyuJ*=L}Jk5@t zU5MG4iZC|QoGtx5;zDUuo^IeL$xZ1mB()*3W-K2o6PZJ0@#}E_rmN)8`wyM_jNfT@?jxp|8T-}QOEMlD%>)@2KJ#gN*JeZ_tbll!l}wyg4- zB$a23Syb3_*up2RMSjii%0x|^1@nuggnT}1H|O11R3I|FNmXlE3%|%0+k5c+ z^xc7pC|Wk{5z{B)WMXfhwz?eC95~a@NNLInBOh9-1+hpaN*+l2IX^+Q9=?6KquEa_ z1d3R(`L~N?qqrpJA#a|im&B&O-XA$; zi#U$|YSLLh@QmxF;+gJekGAYt=bv(op++*D6Hm$M3$NiJJJ_~p#XXQKW388p^P`O)BCV#+ z0hOD(@QmkzV0XGualt2A`61>X*E^{zlxlA@ezET;W?yFw`5XCw2O)>OgGXCG>yLb4 zK3CpNEuPng?TQaJwkv#YmR2sdY=80SaDGQz>v&4OI1|tojbHJQIR@zjAmtaxj|&-nqQC85`1~P2Z~xT*U52jAGY*Q zt|}wqcsJNvWIvqgcEYMarQ!eWwjy)Jx;?>u^7P7W8cV|+xon3-882?{Uu(zK7dQ0< zLjA8U+zx-WKK|xsnC#Z$Nw)8F+FJT6^IG3PQJcxj$B5k$+#s|E0*}EbZk2y&mq1^1 zlB&e(N+@DnsJve+*+j0h9VfSK9q*>YeUevL_)xkc;bhPmm*m{h&H$Ojc5w~H=?_qW2x6^%g zcyKmaCM9SEZ1pt~N|NfRt@4gN3zhO*As1Dgw?HD^J)S=M*2w2V^qq+E?cRJIlvj5kFsQxXpi&orIZaELpD_CBPtrfk7x|+{D+zP_k9m}z5M*y5?j`+FYHqAEx{{BoRkur zYK(#$%-_hEXpVTqal5tiNaF~nW3qR@5Wv*u)#X*ax(?B6cyRAIMNlry|%QvLbVBow3eb0cNp_aLp2 zNw7`zXZM=V0_FRf<@@W;aKuWP7XQo{r1Dk^986fKlJo#$NN`OSEV6tx(uuaQK(o=u zRtrb|)E-4xlP4wEQ4u%|6EuBEhn!RT&z#JQA*+h%E+mC2>Nt70$xqC1xk#_7=}>iu zX=Jm%(q1S22o0ZE0O~CQ3p}U~bBCp5QBi%ak|Olw3&<&QqsUf5o?a2ai12vgF)nK6 z?;%b}zW}6mQKjrYsSG>3zP=LFdcG|%-gy%i8G_R%GvEn~q)TLf0u4k_W7g&3~Q!6}gpJ2>b{k)c`GioP9naansvo2Wt4L7*y5j0nX;$eJL zk`vvs4T>#YFetK?-7JW7_1GUfbL%3r2{M4 z#9DWKUf>YU&}L*GKyHVjTnQc?L!`4Z&LHT?WiimUk|te+zVU&rMY9fHW2F%zk8s70 z)&o;Fji4prM-(|*g8&}IUGW}yoJe>F_05#7NX8gVXh&dYW^_map?O#js^Yxo15dOAYNr!z|Pu zW*>MCSxWryT%dUtCCN%Gwq=dmJH1Xjw3@i*&2skTF7NB3o{AxeuljJ#zpx2lYm@W^Z$*1qj| zGq{M!iBB9&(D!@*{Z;vby2WG&xjg9v8Ozj3gGH-%`bXrHs$na8*$2DFPDed`L)pV1 zf$~KcU&KK*3t=x~N<-A)TOMFh>4RsJhm`W9WfikC6#Yob_}NPC%<5KmhJDz~nb?Fh z-)g$zJ^Jq126`PD)6TwHPTqr;e)5^N6T^WPhvmNM%9oX5Z!CsLHCd%AU+m1yDi6F9 zx7VE+_mk~hOoyQrcMw&*ICRfi9xVZLnYV1K zxSRWvK}!B~Q9D75iHX^SV8n+4H{B#g@3rO9w<~zK|bq()*oqK_g%0B8YTZoQ~Nq_olA^-AXozBq1;1`9|Pa` z!d6g{m}3VX7`xo$hFroLj0qI-zNDyH&P6olPv`RbaXNV?TKHxmcZQx=%m)++*~#}= zzlb{G909PB>XCte3|jrr730g>wdeRTgJBG_d}t@r^xk^L;AbP_P!Kb6S4xS)-FR!V z43hAC!<$dH%45XkP*Y^QVa?j1ju+alnNRj=txC#XP->-qBv7c!l`wJ_(O+K7R%DMO zU!h-tF_(IiAcbdd&!6#Qd*#5C?6bx6QZfs;@sgq?6|r%U(6hwwfr_8?Fhr|6ZNG=d z{f^%dRk_*DG=bP$X%Dd5Xj1=xVGpM!P*aq$C5v<#oS2;5!)YCDpjK~&;T4(iVkKi= zb5`N!jy7SFi^&^fZSefv!x#Z>G0NK!^TKa-Z>)tq=aTm^gL2Bs%3 z8<`uoqi>S4UY({im1&Z&j@I8i1r0%ke09F&VfElWgc`>iYl@S&5*zI56A|S~&qo^0 z(s6}T|Iim>%d@cOuxg~q@Z3IjuwI&1`%Xwi(n0gIoD;dB2*AUU6ZWSpkGomWm{pVH zSDn4Wk(B3`d^W9HZ4zv!HJJWNo2{8(U=oO3PsQ9@J{a0L{ZhsFjj^pb(xP~HuW2LP zFS7-(ehVciPWIet-s0g2dm57G{4u^SvXwzk@LWzHyiqlP{G@Q}ysN0{fb9K1JHU8uNrDH5D*A+i^yd^IAth7V<&s)Wz;*9Gdac zG-)b(6x=$9!Om{hFOg0bsAoIkb@|ifyo8)&JKDqtmR#W%jVjdmk)LuMb-Wxcicpi7 z6;&M|SLfP10{OY))O=fOp>F~EYC>qrp%vRE5U|gP`>KRfzq7L&~C}hjJ zG|D+Kp@9kF2`n$HR zZ*2!s&bKR-V#=JnR7-`KGe&8?LpvX#OLSO%vVG_1!RYU3u}BgXROgkyem%fZASHvf z6a)^P(9n@@ZcYYiWFD$R_oVbj?fj5BTG=`8kMlLhx(*5G!ZL%kGPAt@`XN&+)>~F3 zU)xZk?kq)Lsic`xqUxszaO>#XQ_ zif4-K9pt_@>Qx_RpL>W$n8(tQ|4)=+Y>59oGFqc$t7oY!ZSs94|<-?$t5d( zMm#twW2BdAzvjAPmu^fO(N{KL*W06sq%u`rtUZVJ81TtE0ZW;tjUPgLDGdcnrA%|w zni+U2oqYlN>y*1$a&Ycsl_0G# zF>zM0K(bXSGXm*YDxY;a)14pF^M(wa9No7$xWh(HA<3cR4cY8!NsVXvbOoNs!=#YSFnW(<0fJm<8hA~!n>oQtZ&)kRM7h5i>_7F;bj?ieS%K72vGtaZ zAZwuoUk$!d5|J;RO`G=@p+ti)v%1v9C=KjKOXvnG)4f(QO*Pi|MmOfbUJw4JhZ}>j z;W0wCn)e5{Wnd!(YHbozGmhvC=jlXuSxFAf=~lDY?LTls?wmPgT`J7?#a2I&%(CkH zH{7h!``uRMX&ZzJ$?;P_!%LO%EKt8z!GfFIbyJ83eJrk36v z%;kBdiW7$IM~dDT&e>c>WF^o-!91F|wMD0l_UMQH?HH}3-JI^oxts4=k{3IqjoBkFH&0w z6kPVokcCqPeJK=QhD(l%jTYw;owhsKH~uq#b>9z)tP8AptH`<#E$!poQMS>Ij8vy| zNgZ=9eOwnyj!8rjVG3(!rh)o?(IQNPu@NYYTfEA6iU=J#F}uHcITQ{v*5IrD?JJo2+*hnWNu+GjE~>R+IjvE5osU%J*|0W zM=qnq-~^&7oJ@fX`vZq~>WT8bL^9B*C5IebXXwxFDI+2Is@`{+kC29xyd8W*pIU}Q zWULQKuGOZRulh-G>U#->*_&nrGIIH)#$_d?>6lc=WoD>6X?kD_r2sz5L_=oBACo?K z=RZ|5p+G%CnVKx*?Zcf*__yOm_NSoH7?t83Km14$>3KDP3u) z2pj4~+iJATEq~+JrP+FT{BHP@^m-n;AM;ABU$r?n{P|cAR@b3Xk%Vr*rPbnvtIc<3s>Kr z@}Ee+!}7KLI0v2&kS(5=6fpA3(MkKp2V1+~BRT)$cRLiLB?s_?l8& zP1|B_a;=*|r@cWra^I@?xwEP(a-ycy?<`CH^y!Jf2L(qFkkkC*!Yq~KipL^}bRgmj zo29n4Giu}ohiWMT2_C7}OaG?)X;MCms)Ze9$rO4PY$wIAt6NVl80=%)MG+DC%sv{b zP_FhIcp$>> z&O#pz1V2TAjg;&JVNHrhh|UFnqy`Ej?GV}I-ylUbhr;-5O&NKuV@|oHCCH&@L%j#2 z)9c_e9C0Kayp)k2S%bsANl+mhSoMCJ*OA==hLhmC-buG(vrEb5escC?l~uV>-c+8m z;?^G#>vS;FB%f@}P&^ADxhd?qR8&aJXe&pqahzguFZ{WwPVjf-EiK;3D#zxuagqAF z4$qeGNnqaH#TDK&<_Sw)y*}Cq{=m==!%MWZo~dZQnA61vX{*jAF0mZRp52Pv5_A`UIs zJ$mlM`J?0ehN+s1KmyXOwlR=xe%mM7&hh}zy6_9H>oVdu{J+x~wg7gDi-{7NJVx71B_+L_UszYuPJ3MVn(Zx9;(x~YCz>r;ap zC>hO!S#`Q9ghj*laY%mYT%p83eyGCcvbNM$A-bfbleV)o090suv(3AeMaw)oO&NMo zeg(AsK$EBvg(R?+l$yPnsZ;IP&*YeHHk}rE+^Dbe`1Hu?wrq}#Hk(}aa_rDeN9ZX7E z14`QW?(%eZob_b3`>a;qP&p;BF0rY<>F~(>m)&KvT+U$C;FxmCrk}0o&8hEEm-DIn zRKIY)L*0t=sao5Op>oautUbUZp{fqyd=yN*>_5EPp+)0-di(F&rn4?AvF}jx{iM5qi4`G7Vi9bT>eX`=o7utd z8Lwvr@;0EFYG@#q&$AZy!)uo#m)}z^XV)_du1g@IZ7nqxQor%iPoGoaw8A7`rMz`| z+{?yN#Y`~uh{YiqD3MmOXwLrfb;W~B;h$t)9~An7O4Q5?nK7!MyH;*2Fp4(6HC`+Z zSDGIw@|eKDf$L);+Y*jtAw+`y#vInd?D?$?T^5~v;Q7aUEM? z<(01}Gnad7%pO`_I?wFRC5l;6a0p_QC@qrgWU|s{4%r=UblENMWI0-v*>ij8ZsgSd z+U{#*eTh-VvF|-suUy?6T=ad?28Uz?QlPPMHuQk>dCpZ22Z&!r zfl8qijeg!(t~(;eX5q6l-;S;_%d4uFz02=8*gA))Y~{pt3g0G3v(Cg^N@~j|faC_`No1U&Q<|7L z!&P(nqRq~Mn4{Q%!I!8G{(2Y5nA+Bv^T(Lbfik<`;@0o^Rs(FwSlI7@jtS`2fN^ri z5h)2_+#qoui85e0bC8QHy>zb+h5`v`5Ti`9Kx~aJTA0T;+q&2|E8Di@K=DhpN{{F%S zjT=8;v5aC*SmD)rzIp8?V~d(IO82A)#WYE+!t69wt#$FS%<0dy#F5=(lBusn%5~~+ zXV!+Z#B>pqaW2)KsTvjQrv&8>E8NTJvBNN)2klrKVMAy4uV&rGsKi2ky;?R&Lwn}p zIjFv!xu1%&7h6iNv!@s2F}d=k)s=&3kFe$p`sQq|Glt^A=~4E)Y6)@odZc7G03jiYJyQG$|!Qe#Q}U z)pT;vw1$-umvPbcI5^IlF?mP*N=Fq0HwoZfq+a4aPhkis#u;o}Xk-X~;ocIU8fC3x zNFGF;Bv>4KY5si5U;90I2&3B@%Ii0{;-j3aOqNxqkw=ka26IUh<+j*F8y>W&7B3@g z4h!EUQ#r>?qDPXh<7Ez{Ocurx#jziEt9Et>o9wZ9bMx~vE9}Ny=h#)|DF^4PbgX2u zc^jlm)`R53BLaz(t>a#o_S%&Ayfjoz$vvlS-D0uFQmjcMxAefT5|p_QG=}WOK8vy- zdedwpD~20s3Ga{27#R3)A(10U&`4V26&AoE@YwT=l`%Y!x2?7LV@>g_mp|TI+ZU!V zC#mAB_U3cUe`EY)YpJ;ciR~2Y5B>D=#TX1U}sFj|12WM~Cr26n*hI zlC47clF7`?;1;2dV%eerMWD386bY#zOFY{lM!Az2JjT-`N)4{+4>_lyXl1I5YYP>` zFL4_CZ*s?t&<(hv`DQM@CW>NebjpA6JZ4)QIdAS(YwfbJj%-bP6B8r~804hgI-+FK zSDS`L#cng2k_^Feoo0Xv_)xGAEFguDeiz=7Nf6Cr84 z3C8!5&d74exflM0s`>SzZvYYN7We%yr=Hj}T?Rf!H@O z6qOe!sHA}Z`-l>fHNRg0NQD3Y151BL|9v*-Pb>fsfYSZn(f>Iq^mq8*Cusf&pF>Xn zznrJ}dw{dkkMO|Frv5Uqm0qR=tM6` zesb^n*1hrjzH`<-XYD_p_w4sMZ`u3NQpLDK1^@u=0%Sv^OqF`opQhi;hHnP(%>cPt zX?eK1dGcAfx$*irJE_O3<9*>LR`{iYLKfDeWG!VU3sC(p)kJ^^pmb39`F>FdeIs?m z*^6VAhq5+Aij!12vp~{E&MBNeA$Cy;A$nGB83oHs0*mD08$7h5(oD<-gAJx!AMowN z+-1!z9--*858ze3LfNgu&qk|74e(@l1d=R*@EB4=NtylO4F0qIsRqB6oSNi#J@>57 z9>|z}TztKZsr82w+fvSZTQ`dzL<0cs{o4sER}bsoUKEXKt9J4eV;@useqS)l{Nk23 z7`>7lvh?_nhq;kwMu<_&{&cs1DDMjSuDtU=`lRWU?F2&Ndhj&%osJ6{$)tu%A+;Cel!*{!M+(8f!ayu2+RoVO zW9rQ2*N$oY7uOrGd7~tj78c<+E2#!g4Yvi@_G;%*K)|;MHGlp-J=<6z9O3Z7V#;pa zO^I`Zov$tJxORpt63QiSOnf$5>l%GqUKZb>+>UbPte(Z;&AQ)@Xzaff2*P|-<+A^i z(F`!vQ5mHB{!e1wJrfNNxHLr%{3mMr`P^f% zNAc>0Onp*L2rt2$ii>&ok|0s2|3pKpeQ>-|K}TP?F<&QR)ZI#4PF=4aDG{ij7FxMI zjU=^b+eB&6>~Rl5V}hu2bborx@4gUZS4gS^A?7?i<^{C7j+hB636*V(>sid*bSa^x zKW%{+X9}aJ;z*P(hJ8qq>!%cI8G7A>D0iUmiZ(S1tlo#)DqleF9R?mwjeA1+!i&}45Ggw_@`k<0 zOBkM$p%NLGqm*1&PJf<)W(a7Ebs^wv5;g|mD2o{6#45jG`Idw>O-!mGBskqgz^H!C zmtuUj5u&8SE-;P4Q)C=nf2@Jhn=Tl!!^KaFZPO&FV)8$v4i6Egp-nQmk1mM?w+rLn z=537(kEk;uFNETYVMBy)o6H9Kn%z}Ymjo=(V{H^ll@8dN>X=^vlzc&OOp{`uf`k9W zT`_FNfrPwI)XWJs!_DlP1GzF&vBdbJMWe$%ab6N+d3LZLAgNE@k^F#kZ@SX3o%GvP za2F!wMEu`xMyAM=(hG{G>XTRgI4IeQEJ*@RhdWTt+ni~Yg_~<+0sOg=$oQAbf3=Hn-luVVKwdF-`7-gRY(%^wI`*zcg#|9!(`vK76enN zAkUe65x>lymO#YBaV(?9HjM*fhV9KkrX;N|Y91)Q(?ausZmMhOZ;)3uu}^n1F9s4= zAH8lPuC>fD1e;sqn9i1eLJ0nJNnN6zxJp{4YfHgzd@-L)I31Kv7(^rWE>eEr z-hn90rP1_*JSV!64XZkra+}J=d%j+M^he(l*7hdyxBRcZ5+3C{28mxTsBX?YNlOp+ z%Hiw^N)+<-DFQFpJ7h-WeXECrxqDGAwJn|sL^0qUWWVlVSE)4$PPMOsSw2{LidaU# z!*U*6tDqVc>PY-bT;||auJ*oS&a9~qLR4^90t|$kW2VE(c72C72#UT;3;U+(L=d{X2x}vvOt~+^}LvQL5n&<7h6G8lYfxjWt^z z+tNz4sYM6Z>qgAl#n> zwJ|+!+wujHgb1rNm>JVA85b69re2{Ig&Pc&fShYK+-hMpnFnh9JQk$7LoMq-ixg<) zgblL(z=Whh&}kZOFZpwEPceGxEEFh{7WUH9+q zX$%O%5gY7Y>b;i%pKEh0bEer=q7-P#%>x+dmSm#TX#}m?&Vd9~CipH;H>(xHSu~kq z(8xuqd3=h;@Jok^UHQI8YnkTB4#L}4_U~+Zwk{Rh#7sXQ3CY{MSd~Exk&Yg7y**AC zvgoKAkmA==J2U?B;-p<4qsOfM^8Iz zYcEf}zg~Z}kD|;)k1{1Hn7MW2$J3R&+*geSUx~Y)5@{=`i|^8nsehp*$Mf@;^WD;? z&*Ov3zJ2YT&MrnNwiMXzDjT@ROYdc#d~9;cq(B(x1V=z^Th}i3C?|%}_$*1SAt1H} z8Q^vaGg1KzS01;0geYP%rpq0k3rkZRQYUkb4LmzA$~bN5mp1P;xmV19a0O~#PRBlD ztCZ>lE=Xr|kMLbAW7IAp6-O+2zh9YVP_(RbjBqZzai(B1Nr)=RS)OaZ0~BY7Oo)8e zYLn`$l|5{g6u{+ktv^x~!*pQ~+`iu89k9cv>GRre0Ne8O9yPDfY~IwCVD4_weJ6@_k{BC6vF7hN(! zdf7=sK|f4d2h9Dk9>G*36|cOdRn2`(6)v9IwK;O1bEryi|1A7;JZC>`7H zF>22``(ZIXbY)sQ^C+Fgb7Dv>Y1oPrrUI1}4bVCWwF%_S5wXmAGI-t|)QB4}6N9b& zVB;zk{a6~1yjvSVE1XQ^PO+hlC0PBoVaym5Mbbyg=N_tV-0PIJ284L&Xtwy2{tZty2>pt=VMiAgybFjYDL4@ubC_{Zd z@m3|s)WwkHO5jsQR{L2V(qhIE2#O15U^P8@slV@lqC|wy_0g?7>&pjPpUh|rr@;{} zADvqlfE5k1CqQ#wGvAdbt$H}$Kb~g!Ej*bOH14&`;tEnXH%We-1~u)%`k0-pAIC~w z5M~vlZ?;JsDSkKOg}^Dy&wt}}9;F5)EM-z>cI~-UvToyB`TKHVg?O7{8%&ISAJ+Om z&_pmtmN~a#IDh=Ex#hAWB>L_7Zpy@5DIByE0O#=Xz}?Jd%1l(j7Nr^^QsPY2K+!-F zprj#sX*iJnE>Z8?H4+E6(zzb-7~!Tcn&)zcEn&tjlbQIVwx(a$(yLfg2@+$ui`2!# zjLFbR@rx}`t!gTi#ChMmo7v#jA1sndn%)Cdo&Za`;*jqCp zwXwknj=2~5^~0O|rI7;8RZ(Wn!To0%jV5k-68I8{6+G2BMhuFn%@!P zh0kBj4oRTAS3}&^7r~E~G_D!6zP@ap$4J#uBZ=a-lsBP>g5W4}$f;$vDnVu0;gV|l zSw(fyEynC{;}38UOqz>K2B+KmC?~Wk+_^|wJi@(8b9M5?4uS37XLD%o0&v4@iGV^E zj9zy@`C-crRyh_ui02t{wMs)Ar{rhfV#5nJj5()dH56!tmb4fVnP}`T&t;b*zXuYau zRi#up7G&pCyj}@I%D-A;AM!Fng$AL^Tn8Eih**^%~Yr41})hN2w79a(pnuVG2e^n zJ9~+11rq8y%6M*FmNii{!AeiPJ#QANyPtZr@|mQbUQY(JNu|c(|gt)fA_>A;So=11A;(ypJ! z_KJKBn_YQSOA>87tzx{;xoq<1j5#TO^%{{)7U%4Q<}_8pfQ`R=QNBM|ljbl!vqef(Wri=d5 z3X2hT@)b2Dw3nk${8C5m{(Y!DKIyORM7OtLksqTBo{D`-mkpE~C_}wFo8OXPv}VTK zR&}D4$?-Q!`dTyLe-w8u-ATrD-?YKv0sJhcju^H04~oL%rHe8l;=?+>N8fT zRWN%YZRFUn=xadwUj7kYdsh1qBARVghH&9N-MS3c>c!`8<)RT6)eutHe5LJKR*)P0 zR+b80xS6+e4QuXoQa~sv3tzxwXo4Ba-G^Ac^JH+nsq6rnb}bHzVkU5%W%#jrCk^x< z=vyom8N>i7Z?ReizQ#iOi`4=#G;#N4UTK@ZG0&eOE=dk z8A-oFJWMQV$NlEC1Y%AM3vpT)#cCdmu8~zWOJFztF1LXu=YN-0IO!FV6r(Ini( zwO;L=tUWXG4;(2vN_RWK`!ik}pKM3LZsL^UCT4F!7GUmS?c~YF`}+wQS9QP;zz<&j zNVPAaqKnE0)oii0=y;%Y!zHN@BsD5X1a^&m@v-9R%0}|&UMk7lP?1Kh-n0j2{aNxm zNwi6R?Vh=6EqgHnD0R6H^x@qI%qJncHb-~;kq1{u?+H4Yu%EhX78w{)o_>RRJ6Q+m zIQwxQ9vbbNg!sb0b+vl&u&Nuysl`$V!=2zw4u< z8$YBz)QqcmMlL?h2@DAQ_So0T~vna24$=!<8smsFQ3Sb|UjvmS}a`LTA(n1RqvIah-#5I`F~^+M_K z!FnBkN!h4{>5l`Yr{Th2=t75u8UY5}DMTfY4T+RdqpvTMaC{HTB>L6E@ANd~uxQZr zPlg|DuQr$8Fne{wGR@x%{~b;L=J~J4s!yyY4CTjH2x1w)evzvaiJQ?}WpMrYP6Pu9 zNp}U!&o&bL;K@9^WN(pHiHj&on(l%u<@g+hI-6o02b*7jT!RAr6w!Fkx?0c zv{nZ;VTLt(|427bUzw<&Vy)kGB2PzdvHM+6@bc;x1)9VnJ3XodRh+RMV26JV zv++YkWUAn#DJ$l4_!~cgr!OJu@4p{$9T^pLO9iH8fdjud^PrWKf8_hq$*c&uu=_U- zPGkHnyqUg-K;%Le}__ea)$=G0C9x5^#s=}q5r{)XS^%=V&~}f5bOJM z8WYrR;Fq=033kN%e)dF=i}s+)#@a?xrHV@`5|J{}{&;|sL`;ORwc0Cuo>aRq;Z$;v z)wE5xn+!xcxQIEUFilo|*cQ)enDyz&JRa+53XML0RawqyIL1e9){Uj|dzv^6{XcRi z&F`4<#|Y0~3?)lr>31l1`5kjDkDfPo>$G+{*hjTyyon8w1`P5tY#-6EJ=a0_N5}23 zT9OPm<+{%^s^I(ci8lqO5kB0TZpk8u5wv|9w^O5KQ>AyX-zv{7?+d}R=3e2$2Pjc{-Ik=ecxFxlSICoy)}zBpU8Z5JR;6MQH$B7>%-iiS=G z_}?P+P1O8#1>Z3I|0V0&=-cY>-&g=37_Ix?=>KTNx8b)n)PLX$H=qA6it23xw~x~Q zG0^au>h50#{_DZ|ww2pEhW}UzM)SR?Q#Z@_Xa8^;db@c417#!mqm;VKD3Z`lGYswL^pl9`e;!IXqycCqR)4KBRmGse%?N|CDvkxjQ+;&m=c;FVgKa~j zD>jyQ!LrpPv%%acv2yEcbxU+kzE8G`DDTf~*TbafnNGH5_^B=KtMoHmgMax8pmk5* z#rVmO1prX~(_ecy!tuAmqiMa`{UQ`G)@lg&O?R*2;=K43EhSH-eOjKzj@}B=AH~s!EUSvMSBSFB{;MuJ9Q4wuqoJg^ax-ztyQh!s9A-NANG4P z&g0dEGYnFPoC&2^KGFH1zcmT;f+P*qR@u@Y6q_NY$Z$L5<+}yGKd94jlPUXZf{4?P zXkG!IHnn#w$rD%(BhrUts@Li&Ke(62orU;be)gj|?s^z>lAI_U*&bo-SNsa_Z+4zq z7|9~tPSb18lfx1CdOoJ6v9T!?aJVC6w;27K=JKbsM;tTU2mF zf0bbYM|g-ZLD|E`;Lq0}X0e}*`uclZobKFreKRqs86G(ll7ZWUPn2k`KjrGTFqQOVuy)*^w0mFw@$0%LgVp0c-(fsAFY8>(zS24?tFL|! z1kp6DD^9Tkpq{?d?%wYV;(y^dqu?O9eenNRQIDj(^n`FomS4R}2;F19( z`zLe_f{{wiam#&alNKq{=vz$&j0CxFBYtQ^atwSzUjE4L7%CGBEC`VqbIk~YRRf=I zcjTedFT6L`ik_#>=qZnRb_x*gpL3eqKECN8ONEeOc2W*TA0MZdbA!>lr$pZ&a!zdb zv+6C;=(WNG1ZT&>F*M{@U&DiQrmCm$#!QAfms-(V!S*#usU|H_Fx4G`f|wi^17@r; zLeZ?^n4hS@8p>s=kd|(Ow@Zn!C)My=d6QzO*4(3?6zYsC`QBf=C*02Q%he~2jXOV; zWG1*)^pepH5Jx!`B{_?r2{3*q~3}LP_ z?bKvgBj$p`NUo&Z+CEBt zWd%Ei+l+`N+K6Vasz)|vpuq`$$RvvL!n8&rUBTw<)@bU(vFTJY$}}rU%(r9lDU`2R zW2Cfmw@bXBIG2T1ueEwNBS#)wwuZ)h5OWhqI?;Gfr@cnb^Zd?NusD8ct|?*Hqx&vJ zFCL34RI-AJaQE9-71jg&i6pZII}Pe0gMhy#<|O;7e@6C5hY5wC%Xiz3nGfFFy|&5F`9Cei~|)aGkJV9{GpvWvyN=zZIv@$`-&{~Q4m0%%2{uq zMv28#S`a$lnlrkac%-zC8s2$ASVu@?o#fJ3n*E-!NSq&A`mS-7ix_6Bv@$ApW4pH( ze3FqW%t>t{n!1+)e|`FOz@KqN`Xf$TNREm7#_Ytz`9|;E`qEvwES5L12PGUk#W6YP z*D8+#f~<3#G6xOBsomXbxCGQ&Y+7gb1*n%DYy_X-M_tSM+k&vN_2MqYkh;0@oiY`O4!R4SstnTinyZ9x{oC!p@KQaP4e4ravdD1H{hESO`;y$dK;~|coUojf( zVC?3x(Rm%{Q6PBp*Sytxc@&+z`eEtQW!A>W-g$woeQGtopwq0U;3a9#d6`9Qt1nan zDrKR)A#rixl+||K?MVGo?tEEP(0&Aa-o^BM+3~_MN$~q>vvLQ1$~!vv!E9-G_6g=k z;?~L3cC{4^wS#k_Bmv>jLz3bLQcI83av8-w#MekC62X-|D6Zd`e^2UsOe)TtifbkM zwE1@F8iYvh)736%eP4gl=k54cz<&$K$LQ9SQ;f7)69NGAe+0x2j&SvMc69Xd7XIt; zmx?52xx(i}sAGP`Won%_TQF}-1Md+<+@KlmYlmGv=uv#nHO6e%9(G4oi*mH~QrT@o zPFP{LAs8%Wc%08h_<334$y9J9vozX6TyTk1E7Cjj0T&JYIDyaU5Do8#TA{v$afl67~E1TqaFI@ARFYy6iy7oz5( zqEkYA9lRVc)(L8nmu!69UScZXZi1gQsotRyc;Ix>ax?%DQjSHNOb6!i z5{T};-ju10=_A#u+x5ICbUDj7zG|w|0Tx#=Mc<_WJyOgA4yY%Tu*cRi;qAonUtrF(>=Fo{7 zBSUNkG8swSdA>6@rI4e)w}E4<+*YB~>2t9i_N` ztFV`CckI-N1Sy%smQLGMjM7itt36R_ysDvFlbD-ioga@Gyc;Dqxo9tm2!Ua#M13`D#!5~?V*Dn1X`#J``nacSXsC+q+oRph707v8~caW-SCd+m&_DqZSydRB-Ag*{_HVH< zgTua75UJ6)4mXvyO}&9VUKxNbSg~`-aCnL5rIKZLmHMx1>4M`*8Apr_0OXJX0HlA? z#oH&q&C&aJr?p_>4$l*zxZ;mxGXO#F=4h5B)lHVUzjz-yYjTs`ZfN$Ml<}nXogzJd zScqk_Z~@+n`rV69-hSPUQ=L;cA#BGP-e*9{pK4qm$IYXY(%feZLETN z`aNPyPx&3RWK!0D&V1=$QeTR_xygx9F*o%zO>Wh=&LhQnO9(I(kO_;fbv27VnI{<;kFn zT0JdVF)iMgV)-&ZTy0vMLlfu4TU1dmJh=MC;mf{8uA^331ynu0@4HgOx}e@?G@SI1 zl!BgI(JYI{CD25G;7mDDI zbHs`{?$hF&0`HfQ&w_B{)Cyi9=gPfOh#3OpL*!eb*S2Hg&o`==){&&SJbs2%jBXje~7+R%8}D4Op?18GUK`O%SiqBEgIaf&>y$M^a~O z#j^BCUyaG;x<&)BAJB(Ea|!?9=YVJR1lx20U4t-WBq$ zZQan>kS&IWbH8eJgxH8ty>ikd{OIhye4%1C{(B<%5~}4)8h<+%CX&Yxb;B|f(KFUT zZ(gslUsb6uh=`$gCjtq;M{k#9qBy2!Rnu|4b4V67G$l}XXr2wWGp{^3Yv+sKsKmC) zj(RK44x($DQv5)v8LRb1~`~d&op~S_SS5k{% z_9upA#y<@IiKc(@{8wZ(rOJ~Kt4#UZVAk=uMjyD8nA;$&n!%Al9Z z{{?SQ#`h0mCvZU{jQ z;;J86oU(X+3zAjTR#$0AePUH^pSfA9C}Y+WIJYv)pfb;hD^uCEn#E|3Rn>sa?(}`a zM^uleP$U7}@$sVf0nyuGJt9i!2Ar@r1)M_*#txjC4i!j$j+Z~S&XpkMQQfJdtCu7& z$+ZYO=oj?Qph3D{Ok?|$eLJu2G;~fw7{k8Af4*ZChG7zjMUDOc&$}>m{&j?7`2D}r zuj}aR)2BaJ03aM#|4;P)&Z4fvua9T`hL2%>|6hhR*9}~6&;Mf}f}G$l1OK&Izi#Dv z*YY1LKm|-W#<=l!4|5%Qy>$N#^})12|0v|wf!9mn-#|-DWyJjRp9y;%d_Bwm2EW40 rEHU8!C+Dx*xt>UW+bP4$68}qD8R+0*rfC2GA?8%T6!9I<@3;Q}-0x@2 literal 0 HcmV?d00001 diff --git a/Proyecto/Mexico-aguascalientes/detalle visitas aguascaleintes.xlsx b/Proyecto/Mexico-aguascalientes/detalle visitas aguascaleintes.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..afac906a01ec1591191c0413811ead6039e253e4 GIT binary patch literal 14375 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 literal 0 HcmV?d00001 diff --git a/Proyecto/Mexico-aguascalientes/usuarios.xlsx b/Proyecto/Mexico-aguascalientes/usuarios.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..d11d863f64d91a603532f685ae34844c9f1a2428 GIT binary patch literal 9254 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 q2mt&ehW-ryyA%8?e1hmN@ZVjcq6`#hbpZf)(C String(value ?? '').replace(/[&<>\"']/g, char => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[char]));\nconst filters = Object.entries(payload.filters ?? {}).filter(([, value]) => value).map(([key, value]) => `${escape(key)}: ${escape(value)}`).join('');\nconst routeRows = payload.assignedRoutes.map(route => `${escape(route.usuario)}${escape(route.dia)}${escape(route.orden)}${escape(route.pdv)}${escape(route.origen)}${escape(route.distanciaKm)} km${escape(route.horasTrabajo)} h`).join('') || 'Sin rutas asignadas para los filtros seleccionados.';\nconst unassignedRows = payload.unassignedPdvs.map(route => `${escape(route.pdv_id)}${escape(route.pdv_nombre)}${escape(route.minimo_horas_semana)} h${escape(route.motivo)}${escape(route.recomendacion)}`).join('') || 'No hay PDVs sin asignar para los filtros seleccionados.';\nconst summary = payload.summary ?? {};\nconst html = `

Reporte de rutas

${escape(payload.project.name)}
${escape(payload.project.country)}${payload.project.description ? `
${escape(payload.project.description)}` : ''}
Generado: ${escape(payload.generatedAt)}

${filters ? `
Filtros aplicados:
${filters}
` : '

Filtros aplicados: Ninguno

'}
${escape(summary.totalPdvs)}PDVs totales
${escape(summary.assignedPdvs)}PDVs asignados
${escape(summary.unassignedPdvs)}PDVs sin asignar
${escape(summary.coveragePercent)}%Cobertura
${escape(summary.totalDistanceKm)} kmDistancia
${escape(summary.totalWorkHours)} hTrabajo

Rutas asignadas

${routeRows}
UsuarioDíaOrdenPDVOrigenDistanciaTrabajo

PDVs sin asignar

${unassignedRows}
PDV IDPDVHoras/semanaMotivoRecomendación

Route Planner · GomezLee Marketing

`;\nreturn [{ json: { fileName: `rutas_${String(payload.project.name).replace(/[^a-z0-9]+/gi, '_').toLowerCase()}.pdf` }, binary: { data: { data: Buffer.from(html).toString('base64'), mimeType: 'text/html', fileName: 'index.html' } } }];" + "jsCode": "const payload = $json.payload;\nconst escape = value => String(value ?? '').replace(/[&<>\"']/g, char => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[char]));\nconst filters = Object.entries(payload.filters ?? {}).filter(([, value]) => value).map(([key, value]) => `${escape(key)}: ${escape(value)}`).join('');\nconst routeRows = payload.assignedRoutes.map(route => `${escape(route.usuario)}${escape(route.dia)}${escape(route.orden)}${escape(route.pdv)}${escape(route.origen)}${escape(route.distanciaKm)} km${escape(route.horasTrabajo)} h${route.costoCombustible != null ? `${escape(route.monedaCombustible)} ${escape(route.costoCombustible)}` : '\\u2014'}`).join('') || 'Sin rutas asignadas para los filtros seleccionados.';\nconst unassignedRows = payload.unassignedPdvs.map(route => `${escape(route.pdv_id)}${escape(route.pdv_nombre)}${escape(route.minimo_horas_semana)} h${escape(route.motivo)}${escape(route.recomendacion)}`).join('') || 'No hay PDVs sin asignar para los filtros seleccionados.';\nconst summary = payload.summary ?? {};const fuel = payload.fuel ?? {};\nconst html = `

Reporte de rutas

${escape(payload.project.name)}
${escape(payload.project.country)}${payload.project.description ? `
${escape(payload.project.description)}` : ''}
Generado: ${escape(payload.generatedAt)}

${filters ? `
Filtros aplicados:
${filters}
` : '

Filtros aplicados: Ninguno

'}
${escape(summary.totalPdvs)}PDVs totales
${escape(summary.assignedPdvs)}PDVs asignados
${escape(summary.unassignedPdvs)}PDVs sin asignar
${escape(summary.coveragePercent)}%Cobertura
${escape(summary.totalDistanceKm)} kmDistancia
${escape(summary.totalWorkHours)} hTrabajo
${fuel.configured ? `${escape(summary.fuelLiters)} L` : '\\u2014'}Combustible
${fuel.configured ? `${escape(summary.fuelCurrency)} ${escape(summary.fuelCost)}` : '\\u2014'}Costo combustible

Rutas asignadas

${routeRows}
UsuarioDíaOrdenPDVOrigenDistanciaTrabajoCosto comb.

PDVs sin asignar

${unassignedRows}
PDV IDPDVHoras/semanaMotivoRecomendación

Route Planner · GomezLee Marketing

`;\nreturn [{ json: { fileName: `rutas_${String(payload.project.name).replace(/[^a-z0-9]+/gi, '_').toLowerCase()}.pdf` }, binary: { data: { data: Buffer.from(html).toString('base64'), mimeType: 'text/html', fileName: 'index.html' } } }];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [-100, 260], + "position": [ + -100, + 260 + ], "id": "9258c394-683f-4569-8ced-af2337ec8dba", "name": "Build Report HTML", "onError": "continueErrorOutput", @@ -65,7 +74,10 @@ }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, - "position": [180, 260], + "position": [ + 180, + 260 + ], "id": "73e2759c-d9f9-49dc-925f-3ad1ee7b3897", "name": "Convert HTML to PDF", "retryOnFail": true, @@ -91,7 +103,10 @@ }, "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.5, - "position": [460, 200], + "position": [ + 460, + 200 + ], "id": "93928c3e-1e9c-4f77-a644-c156fae907a5", "name": "Respond PDF" }, @@ -105,7 +120,10 @@ }, "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.5, - "position": [-100, 460], + "position": [ + -100, + 460 + ], "id": "c8b69f14-45d0-4f55-9f7c-2e3fb3425f14", "name": "Respond Validation Error" }, @@ -119,16 +137,80 @@ }, "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.5, - "position": [460, 430], + "position": [ + 460, + 430 + ], "id": "c8d6e363-80b6-46ca-a8c6-32ce393207cc", "name": "Respond PDF Error" } ], "connections": { - "Webhook: Generate PDF": { "main": [[{ "node": "Validate Report Payload", "type": "main", "index": 0 }]] }, - "Validate Report Payload": { "main": [[{ "node": "Build Report HTML", "type": "main", "index": 0 }], [{ "node": "Respond Validation Error", "type": "main", "index": 0 }]] }, - "Build Report HTML": { "main": [[{ "node": "Convert HTML to PDF", "type": "main", "index": 0 }], [{ "node": "Respond Validation Error", "type": "main", "index": 0 }]] }, - "Convert HTML to PDF": { "main": [[{ "node": "Respond PDF", "type": "main", "index": 0 }], [{ "node": "Respond PDF Error", "type": "main", "index": 0 }]] } + "Webhook: Generate PDF": { + "main": [ + [ + { + "node": "Validate Report Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validate Report Payload": { + "main": [ + [ + { + "node": "Build Report HTML", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Respond Validation Error", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Report HTML": { + "main": [ + [ + { + "node": "Convert HTML to PDF", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Respond Validation Error", + "type": "main", + "index": 0 + } + ] + ] + }, + "Convert HTML to PDF": { + "main": [ + [ + { + "node": "Respond PDF", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Respond PDF Error", + "type": "main", + "index": 0 + } + ] + ] + } }, "active": false, "settings": { @@ -138,4 +220,4 @@ "templateCredsSetupCompleted": true }, "tags": [] -} +} \ No newline at end of file diff --git a/n8n-workflow/OptimizadorRutasPDV TEST - Fixed v2.json b/n8n-workflow/OptimizadorRutasPDV TEST - Fixed v2.json index ab44024..30ee6dd 100644 --- a/n8n-workflow/OptimizadorRutasPDV TEST - Fixed v2.json +++ b/n8n-workflow/OptimizadorRutasPDV TEST - Fixed v2.json @@ -167,7 +167,7 @@ }, { "parameters": { - "jsCode": "const validationItems = $('Code in JavaScript').all();\nconst pdvResult = validationItems[0]?.json;\nconst userResult = validationItems[1]?.json;\nconst matrixResponses = $input.all();\nconst matrixRequests = $('Prepare ORS Matrix Requests').all();\n\nfunction header(value) {\n return String(value).toLowerCase().normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').replace(/[^a-z0-9 ]/gi, '').trim().replace(/\\s+/g, '_');\n}\nfunction normalizeRows(data) {\n return data.map(item => Object.fromEntries(Object.entries(item.json).map(([key, value]) => [header(key), typeof value === 'string' ? value.trim() : value])));\n}\nfunction number(value) {\n if (typeof value === 'number') return Number.isFinite(value) ? value : 0;\n const text = String(value ?? '').trim().replace(',', '.').replace(/[^0-9.\\-]/g, '');\n const parsed = Number(text);\n return Number.isFinite(parsed) ? parsed : 0;\n}\nfunction active(value) {\n return !['n', 'no', '0', 'false'].includes(String(value ?? 'si').trim().toLowerCase());\n}\nfunction restDays(value) {\n if (Array.isArray(value)) return value.map(String);\n try { const parsed = JSON.parse(String(value ?? '[]')); if (Array.isArray(parsed)) return parsed.map(String); } catch {}\n return String(value ?? '').split(',').map(day => day.trim()).filter(Boolean);\n}\n\nconst scheduleDays = {\n LUN_DOM_44: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes', 'Sabado', 'Domingo'],\n LUN_SAB_40: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes', 'Sabado'],\n LUN_SAB_44: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes', 'Sabado'],\n LUN_VIE_40: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes']\n};\nconst segmentWeight = { AA: 100, A: 80, B: 60, C: 40, D: 20 };\n\nconst pdvs = normalizeRows(pdvResult.data).map((pdv, index) => ({\n id: String(pdv.id ?? index + 1),\n nombre: String(pdv.nombre_del_pdv ?? ''),\n segmentacion: String(pdv.segmentacion ?? 'D').toUpperCase(),\n latitud: number(pdv.latitud), longitud: number(pdv.longitud),\n visitas: Math.max(1, Math.min(7, Math.round(number(pdv.visitas_semanales) || 1))),\n duracion: number(pdv.duracion_visitahoras ?? pdv.duracion_visita_horas ?? pdv.duracion_visita),\n prioridad: Math.max(1, Math.min(5, Math.round(number(pdv.prioridad) || 5))),\n activo: active(pdv.activo),\n pais: String(pdv.pais ?? pdv.country ?? ''), ciudad: String(pdv.ciudad ?? ''),\n estado: String(pdv.estado ?? ''), provincia: String(pdv.provincia ?? ''),\n region: String(pdv.region ?? pdv.regional ?? ''), zona: String(pdv.zona ?? ''),\n metro: String(pdv.metro_de_prueba ?? '')\n})).filter(pdv => pdv.activo && pdv.duracion > 0);\n\nconst users = normalizeRows(userResult.data).map((user, index) => {\n const jornada = String(user.jornada ?? 'LUN_VIE_40');\n const descansos = new Set(restDays(user.dias_descanso).map(day => header(day)));\n const days = (scheduleDays[jornada] ?? scheduleDays.LUN_VIE_40).filter(day => !descansos.has(header(day)));\n return {\n id: String(user.id ?? index + 1), nombre: String(user.nombre_del_empleado ?? ''),\n latitud: number(user.latitud), longitud: number(user.longitud),\n horasDia: number(user.horas_por_dia_usuario), horasSemana: number(user.horas_por_semana_usuario),\n descanso: Math.max(0, Math.round(number(user.horas_descanso_por_dia))),\n maxVisitas: Math.max(1, Math.round(number(user.visitas_maximas_por_dia) || 3)),\n jornada, days, activo: active(user.usuario_activo ?? user.usuarioactivo),\n pais: String(user.pais ?? user.country ?? ''), ciudad: String(user.ciudad ?? ''),\n estado: String(user.estado ?? ''), provincia: String(user.provincia ?? ''),\n region: String(user.region ?? user.regional ?? ''), zona: String(user.zona ?? '')\n };\n}).filter(user => user.activo && user.days.length && user.horasDia > user.descanso && user.horasSemana > 0);\n\nconst country = matrixRequests[0]?.json?.country ?? 'REPUBLICA DOMINICANA';\nfunction territory(pdv) {\n return { pais: pdv.pais || country, ciudad: pdv.ciudad, estado: pdv.estado, provincia: pdv.provincia, region: pdv.region, zona: pdv.zona, metro: pdv.metro };\n}\n\nconst road = new Map();\nfor (let i = 0; i < matrixResponses.length; i++) {\n const request = matrixRequests[i]?.json;\n const response = matrixResponses[i]?.json;\n const userIds = request?.userIds ?? (request?.userId ? [request.userId] : []);\n for (let u = 0; u < userIds.length; u++) {\n const distances = response?.distances?.[u] ?? [];\n const durations = response?.durations?.[u] ?? [];\n for (let p = 0; p < (request?.pdvIds?.length ?? 0); p++) {\n const distanceKm = Number(distances[p]);\n const durationSeconds = Number(durations[p]);\n if (Number.isFinite(distanceKm) && Number.isFinite(durationSeconds)) road.set(userIds[u] + '|' + request.pdvIds[p], { distanceKm, durationSeconds });\n }\n }\n}\n\nconst remaining = new Map(users.map(user => [user.id, user.horasSemana]));\nconst pending = new Set(pdvs.map(pdv => pdv.id));\nconst weeklyAssignments = [];\nconst noAssigned = [];\n\nconst candidatesByUser = new Map(users.map(user => {\n const candidates = pdvs.map(pdv => {\n const metric = road.get(user.id + '|' + pdv.id);\n if (!metric) return null;\n const workHours = pdv.duracion * pdv.visitas;\n const estimatedTravelHours = metric.durationSeconds / 3600 * pdv.visitas;\n return {\n pdv,\n metric,\n totalHours: workHours + estimatedTravelHours,\n score: (segmentWeight[pdv.segmentacion] ?? 0) + (6 - pdv.prioridad) * 6\n };\n }).filter(Boolean).sort((a, b) => a.metric.durationSeconds - b.metric.durationSeconds || a.metric.distanceKm - b.metric.distanceKm || b.score - a.score);\n return [user.id, candidates];\n}));\nconst candidateIndex = new Map(users.map(user => [user.id, 0]));\n\nwhile (pending.size) {\n const options = [];\n for (const user of users) {\n const candidates = candidatesByUser.get(user.id) ?? [];\n let index = candidateIndex.get(user.id) ?? 0;\n while (index < candidates.length) {\n const candidate = candidates[index];\n if (!pending.has(candidate.pdv.id) || candidate.totalHours > (remaining.get(user.id) ?? 0) + 0.001) {\n index++;\n continue;\n }\n options.push({ user, candidate });\n break;\n }\n candidateIndex.set(user.id, index);\n }\n options.sort((a, b) => b.candidate.metric.durationSeconds - a.candidate.metric.durationSeconds);\n if (!options.length) break;\n let assignedThisRound = false;\n for (const option of options) {\n const { user, candidate } = option;\n if (!pending.has(candidate.pdv.id) || candidate.totalHours > (remaining.get(user.id) ?? 0) + 0.001) continue;\n weeklyAssignments.push({ user, pdv: candidate.pdv, metric: candidate.metric });\n remaining.set(user.id, (remaining.get(user.id) ?? 0) - candidate.totalHours);\n pending.delete(candidate.pdv.id);\n candidateIndex.set(user.id, (candidateIndex.get(user.id) ?? 0) + 1);\n assignedThisRound = true;\n }\n if (!assignedThisRound) break;\n}\n\nfor (const pdv of pdvs.filter(item => pending.has(item.id))) noAssigned.push({ pdv_id: pdv.id, pdv_nombre: pdv.nombre, minimo_horas_semana: pdv.duracion * pdv.visitas, latitud: pdv.latitud, longitud: pdv.longitud, ...territory(pdv), motivo: 'No hay empleado con capacidad semanal considerando el recorrido real' });\n\nconst daily = new Map();\nconst assigned = [];\nfunction dayState(userId, day) {\n const key = userId + '|' + day;\n if (!daily.has(key)) daily.set(key, { hours: 0, visits: 0 });\n return daily.get(key);\n}\nfunction preferredDays(days, count) {\n if (count >= days.length) return days.slice(0, count);\n return Array.from({ length: count }, (_, index) => days[Math.floor(index * days.length / count)]);\n}\n\nfor (const assignment of weeklyAssignments) {\n const { user, pdv, metric } = assignment;\n if (pdv.visitas > user.days.length) {\n noAssigned.push({ pdv_id: pdv.id, pdv_nombre: pdv.nombre, minimo_horas_semana: pdv.duracion * pdv.visitas, latitud: pdv.latitud, longitud: pdv.longitud, ...territory(pdv), motivo: 'Las visitas semanales exceden los días laborables disponibles' });\n continue;\n }\n const selected = [];\n const preferred = preferredDays(user.days, pdv.visitas);\n for (let visit = 0; visit < pdv.visitas; visit++) {\n const orderedDays = [preferred[visit], ...user.days.filter(day => day !== preferred[visit])];\n const day = orderedDays.find(candidate => {\n if (selected.includes(candidate)) return false;\n const state = dayState(user.id, candidate);\n return state.visits < user.maxVisitas && state.hours + pdv.duracion + metric.durationSeconds / 3600 <= user.horasDia - user.descanso + 0.001;\n });\n if (!day) { selected.length = 0; break; }\n selected.push(day);\n }\n if (!selected.length) {\n noAssigned.push({ pdv_id: pdv.id, pdv_nombre: pdv.nombre, minimo_horas_semana: pdv.duracion * pdv.visitas, latitud: pdv.latitud, longitud: pdv.longitud, ...territory(pdv), motivo: 'Excede capacidad diaria o visitas máximas considerando el recorrido real' });\n continue;\n }\n for (const day of selected) {\n const state = dayState(user.id, day);\n state.hours += pdv.duracion + metric.durationSeconds / 3600;\n state.visits++;\n assigned.push({ json: {\n usuario_id: user.id, nombre_usuario: user.nombre, latitud_usuario: user.latitud, longitud_usuario: user.longitud,\n pais_usuario: user.pais || country, ciudad_usuario: user.ciudad, estado_usuario: user.estado, provincia_usuario: user.provincia, region_usuario: user.region, zona_usuario: user.zona,\n hora_laboral_semanal_usuario: user.horasSemana, horas_por_dia_usuario: user.horasDia, horas_descanso_por_dia: user.descanso,\n jornada_codigo: user.jornada, pdv_id: pdv.id, nombre_pdv: pdv.nombre, latitud_pdv: pdv.latitud, longitud_pdv: pdv.longitud,\n pais: pdv.pais || country, ciudad: pdv.ciudad, estado: pdv.estado, provincia: pdv.provincia, region: pdv.region, zona: pdv.zona, metro: pdv.metro,\n hora_minima_semanal_pdv: pdv.duracion * pdv.visitas, dia: day, horas_trabajo: Number(pdv.duracion.toFixed(2)),\n horas_desplazamiento: Number((metric.durationSeconds / 3600).toFixed(4)), distancia: Number(metric.distanceKm.toFixed(2))\n } });\n }\n}\n\nconst groups = new Map();\nfor (const route of assigned) {\n const key = route.json.usuario_id + '|' + route.json.dia;\n if (!groups.has(key)) groups.set(key, []);\n groups.get(key).push(route);\n}\nfor (const routes of groups.values()) routes.sort((a, b) => a.json.horas_desplazamiento - b.json.horas_desplazamiento).forEach((route, index) => route.json.visit_order = index + 1);\n\nreturn [{ json: { assigned } }, { json: { noAssigned } }];" + "jsCode": "const validationItems = $('Code in JavaScript').all();\nconst pdvResult = validationItems[0]?.json;\nconst userResult = validationItems[1]?.json;\nconst matrixResponses = $input.all();\nconst matrixRequests = $('Prepare ORS Matrix Requests').all();\n\nfunction header(value) {\n return String(value).toLowerCase().normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').replace(/[^a-z0-9 ]/gi, '').trim().replace(/\\s+/g, '_');\n}\nfunction normalizeRows(data) {\n return data.map(item => Object.fromEntries(Object.entries(item.json).map(([key, value]) => [header(key), typeof value === 'string' ? value.trim() : value])));\n}\nfunction number(value) {\n if (typeof value === 'number') return Number.isFinite(value) ? value : 0;\n const text = String(value ?? '').trim().replace(',', '.').replace(/[^0-9.\\-]/g, '');\n const parsed = Number(text);\n return Number.isFinite(parsed) ? parsed : 0;\n}\nfunction active(value) {\n return !['n', 'no', '0', 'false'].includes(String(value ?? 'si').trim().toLowerCase());\n}\nfunction restDays(value) {\n if (Array.isArray(value)) return value.map(String);\n try { const parsed = JSON.parse(String(value ?? '[]')); if (Array.isArray(parsed)) return parsed.map(String); } catch {}\n return String(value ?? '').split(',').map(day => day.trim()).filter(Boolean);\n}\n\nconst scheduleDays = {\n LUN_DOM_44: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes', 'Sabado', 'Domingo'],\n LUN_SAB_40: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes', 'Sabado'],\n LUN_SAB_44: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes', 'Sabado'],\n LUN_VIE_40: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes']\n};\nconst segmentWeight = { AA: 100, A: 80, B: 60, C: 40, D: 20 };\n\nconst pdvs = normalizeRows(pdvResult.data).map((pdv, index) => ({\n id: String(pdv.id ?? index + 1),\n nombre: String(pdv.nombre_del_pdv ?? ''),\n segmentacion: String(pdv.segmentacion ?? 'D').toUpperCase(),\n latitud: number(pdv.latitud), longitud: number(pdv.longitud),\n visitas: Math.max(1, Math.min(7, Math.round(number(pdv.visitas_semanales) || 1))),\n duracion: number(pdv.duracion_visitahoras ?? pdv.duracion_visita_horas ?? pdv.duracion_visita),\n prioridad: Math.max(1, Math.min(5, Math.round(number(pdv.prioridad) || 5))),\n activo: active(pdv.activo),\n pais: String(pdv.pais ?? pdv.country ?? ''), ciudad: String(pdv.ciudad ?? ''),\n estado: String(pdv.estado ?? ''), provincia: String(pdv.provincia ?? ''),\n region: String(pdv.region ?? pdv.regional ?? ''), zona: String(pdv.zona ?? ''),\n metro: String(pdv.metro_de_prueba ?? '')\n})).filter(pdv => pdv.activo && pdv.duracion > 0);\n\nconst users = normalizeRows(userResult.data).map((user, index) => {\n const jornada = String(user.jornada ?? 'LUN_VIE_40');\n const descansos = new Set(restDays(user.dias_descanso).map(day => header(day)));\n const days = (scheduleDays[jornada] ?? scheduleDays.LUN_VIE_40).filter(day => !descansos.has(header(day)));\n return {\n id: String(user.id ?? index + 1), nombre: String(user.nombre_del_empleado ?? ''),\n latitud: number(user.latitud), longitud: number(user.longitud),\n horasDia: number(user.horas_por_dia_usuario), horasSemana: number(user.horas_por_semana_usuario),\n descanso: Math.max(0, Math.round(number(user.horas_descanso_por_dia))),\n maxVisitas: Math.max(1, Math.round(number(user.visitas_maximas_por_dia) || 3)),\n jornada, days, activo: active(user.usuario_activo ?? user.usuarioactivo),\n pais: String(user.pais ?? user.country ?? ''), ciudad: String(user.ciudad ?? ''),\n estado: String(user.estado ?? ''), provincia: String(user.provincia ?? ''),\n region: String(user.region ?? user.regional ?? ''), zona: String(user.zona ?? '')\n };\n}).filter(user => user.activo && user.days.length && user.horasDia > user.descanso && user.horasSemana > 0);\n\nconst country = matrixRequests[0]?.json?.country ?? 'REPUBLICA DOMINICANA';\nfunction territory(pdv) {\n return { pais: pdv.pais || country, ciudad: pdv.ciudad, estado: pdv.estado, provincia: pdv.provincia, region: pdv.region, zona: pdv.zona, metro: pdv.metro };\n}\n\nconst road = new Map();\nfor (let i = 0; i < matrixResponses.length; i++) {\n const request = matrixRequests[i]?.json;\n const response = matrixResponses[i]?.json;\n const userIds = request?.userIds ?? (request?.userId ? [request.userId] : []);\n for (let u = 0; u < userIds.length; u++) {\n const distances = response?.distances?.[u] ?? [];\n const durations = response?.durations?.[u] ?? [];\n for (let p = 0; p < (request?.pdvIds?.length ?? 0); p++) {\n const distanceKm = Number(distances[p]);\n const durationSeconds = Number(durations[p]);\n if (Number.isFinite(distanceKm) && Number.isFinite(durationSeconds)) road.set(userIds[u] + '|' + request.pdvIds[p], { distanceKm, durationSeconds });\n }\n }\n}\n\nconst remaining = new Map(users.map(user => [user.id, user.horasSemana]));\nconst pending = new Set(pdvs.map(pdv => pdv.id));\nconst weeklyAssignments = [];\nconst noAssigned = [];\n\n// Asignación por prioridad estricta: prioridad 1 primero; un nivel solo se\n// intenta cuando los superiores ya salieron o no caben en nadie. Empate:\n// segmentación (AA > A > B > C > D), luego carga semanal menor, luego cercanía.\nconst orderedPdvs = [...pdvs].sort((a, b) =>\n a.prioridad - b.prioridad ||\n (segmentWeight[b.segmentacion] ?? 0) - (segmentWeight[a.segmentacion] ?? 0) ||\n (a.duracion * a.visitas) - (b.duracion * b.visitas)\n);\n\nfor (const pdv of orderedPdvs) {\n const workHours = pdv.duracion * pdv.visitas;\n const options = [];\n for (const user of users) {\n const metric = road.get(user.id + '|' + pdv.id);\n if (!metric) continue;\n const estimatedTravelHours = metric.durationSeconds / 3600 * pdv.visitas;\n const totalHours = workHours + estimatedTravelHours;\n if (totalHours > (remaining.get(user.id) ?? 0) + 0.001) continue;\n options.push({ user, metric, totalHours });\n }\n if (!options.length) continue;\n options.sort((a, b) =>\n a.metric.durationSeconds - b.metric.durationSeconds ||\n a.metric.distanceKm - b.metric.distanceKm ||\n (remaining.get(b.user.id) ?? 0) - (remaining.get(a.user.id) ?? 0)\n );\n const best = options[0];\n weeklyAssignments.push({ user: best.user, pdv, metric: best.metric });\n remaining.set(best.user.id, (remaining.get(best.user.id) ?? 0) - best.totalHours);\n pending.delete(pdv.id);\n}\n\nfor (const pdv of pdvs.filter(item => pending.has(item.id))) noAssigned.push({ pdv_id: pdv.id, pdv_nombre: pdv.nombre, minimo_horas_semana: pdv.duracion * pdv.visitas, latitud: pdv.latitud, longitud: pdv.longitud, ...territory(pdv), motivo: 'No hay empleado con capacidad semanal considerando el recorrido real' });\n\nconst daily = new Map();\nconst assigned = [];\nfunction dayState(userId, day) {\n const key = userId + '|' + day;\n if (!daily.has(key)) daily.set(key, { hours: 0, visits: 0 });\n return daily.get(key);\n}\nfunction preferredDays(days, count) {\n if (count >= days.length) return days.slice(0, count);\n return Array.from({ length: count }, (_, index) => days[Math.floor(index * days.length / count)]);\n}\n\nfor (const assignment of weeklyAssignments) {\n const { user, pdv, metric } = assignment;\n if (pdv.visitas > user.days.length) {\n noAssigned.push({ pdv_id: pdv.id, pdv_nombre: pdv.nombre, minimo_horas_semana: pdv.duracion * pdv.visitas, latitud: pdv.latitud, longitud: pdv.longitud, ...territory(pdv), motivo: 'Las visitas semanales exceden los días laborables disponibles' });\n continue;\n }\n const selected = [];\n const preferred = preferredDays(user.days, pdv.visitas);\n for (let visit = 0; visit < pdv.visitas; visit++) {\n const orderedDays = [preferred[visit], ...user.days.filter(day => day !== preferred[visit])];\n const day = orderedDays.find(candidate => {\n if (selected.includes(candidate)) return false;\n const state = dayState(user.id, candidate);\n return state.visits < user.maxVisitas && state.hours + pdv.duracion + metric.durationSeconds / 3600 <= user.horasDia - user.descanso + 0.001;\n });\n if (!day) { selected.length = 0; break; }\n selected.push(day);\n }\n if (!selected.length) {\n noAssigned.push({ pdv_id: pdv.id, pdv_nombre: pdv.nombre, minimo_horas_semana: pdv.duracion * pdv.visitas, latitud: pdv.latitud, longitud: pdv.longitud, ...territory(pdv), motivo: 'Excede capacidad diaria o visitas máximas considerando el recorrido real' });\n continue;\n }\n for (const day of selected) {\n const state = dayState(user.id, day);\n state.hours += pdv.duracion + metric.durationSeconds / 3600;\n state.visits++;\n assigned.push({ json: {\n usuario_id: user.id, nombre_usuario: user.nombre, latitud_usuario: user.latitud, longitud_usuario: user.longitud,\n pais_usuario: user.pais || country, ciudad_usuario: user.ciudad, estado_usuario: user.estado, provincia_usuario: user.provincia, region_usuario: user.region, zona_usuario: user.zona,\n hora_laboral_semanal_usuario: user.horasSemana, horas_por_dia_usuario: user.horasDia, horas_descanso_por_dia: user.descanso,\n jornada_codigo: user.jornada, pdv_id: pdv.id, nombre_pdv: pdv.nombre, latitud_pdv: pdv.latitud, longitud_pdv: pdv.longitud,\n pais: pdv.pais || country, ciudad: pdv.ciudad, estado: pdv.estado, provincia: pdv.provincia, region: pdv.region, zona: pdv.zona, metro: pdv.metro,\n hora_minima_semanal_pdv: pdv.duracion * pdv.visitas, dia: day, horas_trabajo: Number(pdv.duracion.toFixed(2)),\n prioridad: pdv.prioridad, segmentacion: pdv.segmentacion,\n horas_desplazamiento: Number((metric.durationSeconds / 3600).toFixed(4)), distancia: Number(metric.distanceKm.toFixed(2))\n } });\n }\n}\n\nconst groups = new Map();\nfor (const route of assigned) {\n const key = route.json.usuario_id + '|' + route.json.dia;\n if (!groups.has(key)) groups.set(key, []);\n groups.get(key).push(route);\n}\nfor (const routes of groups.values()) routes.sort((a, b) => a.json.horas_desplazamiento - b.json.horas_desplazamiento).forEach((route, index) => route.json.visit_order = index + 1);\n\nreturn [{ json: { assigned } }, { json: { noAssigned } }];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -636,4 +636,4 @@ }, "id": "IkKMqgb7uZuRk9d9", "tags": [] -} +} \ No newline at end of file diff --git a/route-planner/angular.json b/route-planner/angular.json index 76c3c59..53aa712 100644 --- a/route-planner/angular.json +++ b/route-planner/angular.json @@ -30,7 +30,7 @@ "production": { "budgets": [ { "type": "initial", "maximumWarning": "2.1MB", "maximumError": "3MB" }, - { "type": "anyComponentStyle", "maximumWarning": "6kB", "maximumError": "12kB" } + { "type": "anyComponentStyle", "maximumWarning": "8kB", "maximumError": "15kB" } ], "outputHashing": "all", "fileReplacements": [ diff --git a/route-planner/src/app/app.routes.ts b/route-planner/src/app/app.routes.ts index b13cf52..453d278 100644 --- a/route-planner/src/app/app.routes.ts +++ b/route-planner/src/app/app.routes.ts @@ -19,7 +19,7 @@ export const routes: Routes = [ { path: 'proyectos/:id/ruta', component: AssignedRouteDetailComponent, canActivate: [authGuard] }, { path: 'proyectos/:id/empleados', component: EmployeesComponent, canActivate: [authGuard] }, { path: 'proyectos/:id/pdvs', component: PdvsComponent, canActivate: [authGuard] }, - { path: 'home/assigned-route', component: AssignedRouteDetailComponent, canActivate: [authGuard] }, - { path: 'home/mapa', component: RouteOverviewMapComponent, canActivate: [authGuard] }, + { path: 'home/assigned-route', redirectTo: 'home', pathMatch: 'full' }, + { path: 'home/mapa', redirectTo: 'home', pathMatch: 'full' }, { path: '**', redirectTo: 'login' } ]; diff --git a/route-planner/src/app/core/models/project.model.ts b/route-planner/src/app/core/models/project.model.ts index 95e79ce..97791f4 100644 --- a/route-planner/src/app/core/models/project.model.ts +++ b/route-planner/src/app/core/models/project.model.ts @@ -3,6 +3,8 @@ export interface RuteroProyecto { nombre: string; descripcion: string | null; pais: string | null; + rendimiento_km_l: number | null; + precio_por_litro: number | null; propietario_id: string; estado: 'optimizado' | 'archivado' | null; version: number; diff --git a/route-planner/src/app/core/models/route.model.ts b/route-planner/src/app/core/models/route.model.ts index 03cff79..ed4b121 100644 --- a/route-planner/src/app/core/models/route.model.ts +++ b/route-planner/src/app/core/models/route.model.ts @@ -27,6 +27,8 @@ export interface RouteAssignedJson { usuario_id: string; pdv_id: number; visit_order?: number; + prioridad?: number; + segmentacion?: string; geojson?: unknown; horas_por_dia_usuario?: number; horas_descanso_por_dia?: number; diff --git a/route-planner/src/app/core/route-state/route.ts b/route-planner/src/app/core/route-state/route.ts index 0f95cf2..1e4c0b0 100644 --- a/route-planner/src/app/core/route-state/route.ts +++ b/route-planner/src/app/core/route-state/route.ts @@ -201,9 +201,17 @@ export class RouteState { const profiles = Array.from(new Map(current.map(route => [String(route.json.usuario_id), route])).values()); if (profiles.length < 2) return; + const segmentWeight: Record = { AA: 100, A: 80, B: 60, C: 40, D: 20 }; + const priorityOf = (route: (typeof current)[number]): number => { + const value = Number(route.json.prioridad); + return Number.isFinite(value) ? value : 5; + }; + const segmentOf = (route: (typeof current)[number]): number => + segmentWeight[String(route.json.segmentacion ?? 'D').toUpperCase()] ?? 20; const load = new Map(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)) + .sort((a, b) => priorityOf(a) - priorityOf(b) || segmentOf(b) - segmentOf(a) || + 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 diff --git a/route-planner/src/app/core/services/ors.service.ts b/route-planner/src/app/core/services/ors.service.ts index 79c8296..8778695 100644 --- a/route-planner/src/app/core/services/ors.service.ts +++ b/route-planner/src/app/core/services/ors.service.ts @@ -24,6 +24,7 @@ const MAX_CONCURRENT_REQUESTS = 2; export class OrsService { private geoCache = new Map(); + private failedCache = new Map(); constructor(private http: HttpClient) { } @@ -37,7 +38,8 @@ export class OrsService { isJson = false ) { const body = { - coordinates: [origin, destination] + coordinates: [origin, destination], + radiuses: [1000, 1000] }; const baseUrl = this.isMexico(origin) || this.isMexico(destination) ? environment.orsMexicoBaseUrl @@ -57,6 +59,14 @@ export class OrsService { return latitude >= 14 && latitude <= 33 && longitude >= -119 && longitude <= -86; } + private orsErrorMessage(error: unknown): string { + const err = error as { message?: string; error?: { error?: { message?: string; code?: number }; message?: string } }; + const serverMessage = err?.error?.error?.message ?? err?.error?.message; + if (serverMessage) return String(serverMessage); + if (err?.message) return String(err.message); + return 'Error ORS'; + } + async processPairs( pares: { pdv_id: number; @@ -99,6 +109,17 @@ export class OrsService { }) ); } + if (isJson && this.failedCache.has(key)) { + const errOut = { pdv_id: pair.pdv_id, error: this.failedCache.get(key) ?? 'Sin conexión vial (ORS).' }; + return of(errOut).pipe( + map(data => { + 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 => { @@ -124,9 +145,11 @@ export class OrsService { } }), catchError(error => { + const message = this.orsErrorMessage(error); + if (isJson) this.failedCache.set(key, message); const errOut = { pdv_id: pair.pdv_id, - error: (error as Error).message || 'Error ORS' + error: message }; completed++; progressSubject.next({ completed, total: pares.length, item: errOut }); @@ -151,5 +174,6 @@ export class OrsService { clearCache() { this.geoCache.clear(); + this.failedCache.clear(); } } diff --git a/route-planner/src/app/core/services/project.service.ts b/route-planner/src/app/core/services/project.service.ts index 05f36b1..3c89866 100644 --- a/route-planner/src/app/core/services/project.service.ts +++ b/route-planner/src/app/core/services/project.service.ts @@ -17,7 +17,7 @@ export class ProjectService { return data as RuteroProyecto; } - async update(id: string, changes: Partial>): Promise { + async update(id: string, changes: Partial>): Promise { const { error } = await supabaseClient.from('rutero_proyectos').update(changes).eq('id', id); if (error) throw error; } @@ -44,6 +44,8 @@ export class ProjectService { nombre: nombre.trim(), descripcion: descripcion.trim() || null, pais: pais.trim(), + rendimiento_km_l: null, + precio_por_litro: null, propietario_id: ownerId, estado: null, version: 1, diff --git a/route-planner/src/app/private/employees/employees.component.css b/route-planner/src/app/private/employees/employees.component.css index 1f26c83..fa07d29 100644 --- a/route-planner/src/app/private/employees/employees.component.css +++ b/route-planner/src/app/private/employees/employees.component.css @@ -18,6 +18,6 @@ .employee-form-grid input::placeholder { color: #71808a; opacity: 1; } .employee-form-grid input:focus, .employee-form-grid select:focus { outline: 2px solid rgba(108, 194, 74, .35); border-color: var(--glm-verde); background: #fff; color: #1f2d34; } .form-actions { display: flex; gap: 8px; }.form-actions button:first-child { background: var(--glm-azul); color: #fff; }.form-error { color: #c9252d; font-size: .82rem; }.table-header { display: flex; justify-content: space-between; align-items: center; gap: 12px; }.table-wrap { overflow-x: auto; margin-top: 12px; }table { width: 100%; min-width: 920px; border-collapse: collapse; font-size: .82rem; }th { padding: 9px; background: var(--glm-azul); color: #fff; text-align: left; }td { padding: 9px; border-bottom: 1px solid var(--glm-gris-claro); color: var(--glm-gris-oscuro); }.status { color: var(--glm-verde); font-weight: 700; }.status.inactive { color: #c9252d; }.row-actions { display: flex; gap: 5px; }.row-actions .delete { color: #c9252d; }.loading { color: var(--glm-acero); }.rest-days-button { max-width: 155px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.rest-days-picker { display: grid; gap: 4px; margin-top: 6px; padding: 6px; border: 1px solid var(--glm-gris-medio); background: #fff; }.rest-days-picker label { font-size: .72rem; white-space: nowrap; } -.editing-row { background: var(--glm-verde-bg); }.editing-row select, .inline-number { width: 100%; min-width: 92px; box-sizing: border-box; padding: 7px; border: 1px solid var(--glm-verde); border-radius: 4px; font: inherit; }.inline-number { max-width: 100px; }.inline-picker { min-width: 130px; margin: 0; }.editing-row .row-actions { flex-wrap: wrap; } -.table-header > div { display:flex; align-items:center; gap:8px; }.selected-count { color:var(--glm-azul); font-size:.78rem; font-weight:700; }.select-column { width:38px; }.bulk-actions { display:flex; flex-wrap:wrap; align-items:end; gap:10px; margin:12px 0; padding:12px; border:1px solid var(--glm-verde-claro); background:var(--glm-verde-bg); }.bulk-actions > label,.bulk-actions fieldset { display:grid; gap:4px; margin:0; color:var(--glm-azul); font-size:.72rem; font-weight:700; text-transform:uppercase; }.bulk-actions input,.bulk-actions select { min-height:32px; padding:5px 7px; border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; font:inherit; }.bulk-actions fieldset { grid-template-columns:repeat(2,max-content); gap:5px 8px; padding:6px 8px; text-transform:none; }.bulk-actions legend { padding:0 3px; text-transform:uppercase; }.bulk-actions fieldset label { font-size:.72rem; font-weight:400; }.bulk-actions fieldset input { min-height:0; padding:0; }.bulk-actions button { border:1px solid var(--glm-gris-medio); border-radius:4px; padding:7px 10px; background:#fff; color:var(--glm-azul); cursor:pointer; font:600 .78rem Arial,sans-serif; }.bulk-actions button:hover:not(:disabled) { background:#fff; border-color:var(--glm-verde); }.bulk-actions button.delete { color:#c9252d; } +.editing-row { background: var(--glm-verde-bg); }.editing-row select, .inline-number { width: 100%; min-width: 92px; box-sizing: border-box; padding: 7px; border: 1px solid var(--glm-verde); border-radius: 4px; background: #fff; color: var(--glm-gris-oscuro); font-size: .85rem; font-weight: 600; }.inline-number { max-width: 100px; }.inline-picker { min-width: 130px; margin: 0; }.editing-row .row-actions { flex-wrap: wrap; } +.table-header > div { display:flex; align-items:center; gap:8px; }.selected-count { color:var(--glm-azul); font-size:.78rem; font-weight:700; }.select-column { width:38px; }.bulk-actions { position:relative; z-index:30; display:flex; flex-wrap:wrap; align-items:end; gap:10px; margin:12px 0; padding:12px; border:1px solid var(--glm-verde-claro); background:var(--glm-verde-bg); }.bulk-actions > label,.bulk-actions fieldset { display:grid; gap:4px; margin:0; color:var(--glm-azul); font-size:.72rem; font-weight:700; text-transform:uppercase; }.bulk-actions input,.bulk-actions select { min-height:32px; padding:5px 7px; border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-gris-oscuro); font-size:.85rem; font-weight:600; text-transform:none; }.bulk-actions select { min-width:200px; }.bulk-actions select option,.employee-form-grid select option,.editing-row select option { color:var(--glm-gris-oscuro); background:#fff; }.bulk-actions fieldset { grid-template-columns:repeat(2,max-content); gap:5px 8px; padding:6px 8px; text-transform:none; }.bulk-actions legend { padding:0 3px; text-transform:uppercase; }.bulk-actions fieldset label { font-size:.72rem; font-weight:400; }.bulk-actions fieldset input { min-height:0; padding:0; }.bulk-actions button { border:1px solid var(--glm-gris-medio); border-radius:4px; padding:7px 10px; background:#fff; color:var(--glm-azul); cursor:pointer; font:600 .78rem Arial,sans-serif; }.bulk-actions button:hover:not(:disabled) { background:#fff; border-color:var(--glm-verde); }.bulk-actions button.delete { color:#c9252d; } @media (max-width: 760px) { .employees-page { padding: 16px; }.employee-form-grid { grid-template-columns: 1fr; }.employees-topbar { padding: 9px 16px; }.topbar-actions button { font-size: 0; }.topbar-actions button i { font-size: .9rem; } } diff --git a/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.ts b/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.ts index 53fb747..bbba6af 100644 --- a/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.ts +++ b/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.ts @@ -5,7 +5,9 @@ import { Subscription } from 'rxjs'; import * as L from 'leaflet'; import { RouteState } from '../../../core/route-state/route'; import { OrsService } from '../../../core/services/ors.service'; +import { ProjectService } from '../../../core/services/project.service'; import { RouteAssignedResponse } from '../../../core/models/route.model'; +import { centerForCountry } from '../../../shared/utils/country-center.utils'; import { sequenceRoutes } from '../../../shared/utils/route-sequencing.utils'; import { colorForUsuario } from '../../../shared/utils/palette.utils'; @@ -45,6 +47,7 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie private activatedRoute: ActivatedRoute, private routeState: RouteState, private orsService: OrsService, + private projects: ProjectService, private cdr: ChangeDetectorRef ) {} @@ -117,7 +120,13 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie if (territoryBounds?.isValid()) { this.fitRouteBounds(territoryBounds); } else { - this.map.setView([18.4861, -69.9312], 13); + const projectId = this.activatedRoute.snapshot.paramMap.get('id') ?? ''; + void this.projects.get(projectId).then(project => { + const center = centerForCountry(project?.pais ?? ''); + this.map.setView([center.lat, center.lng], 10); + }).catch(() => { + this.map.setView([19.4326, -99.1332], 5); + }); } const pdvPane = this.map.createPane('pdvPane'); pdvPane.style.zIndex = '650'; diff --git a/route-planner/src/app/private/home/home.component.css b/route-planner/src/app/private/home/home.component.css index 0a0e79c..dbd8384 100644 --- a/route-planner/src/app/private/home/home.component.css +++ b/route-planner/src/app/private/home/home.component.css @@ -36,9 +36,86 @@ .projects-link button,.home-employees-link,.edit-project,.project-edit-actions button { border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-azul); cursor:pointer; font:inherit; font-weight:700; padding:8px 11px; text-decoration:none; }.home-employees-link { display:inline-block; margin-bottom:12px; }.project-heading { width:100%; }.project-title-line { display:flex; align-items:center; gap:14px; }.project-title-line h1 { margin:0; }.back-projects { color:var(--glm-azul); font-size:.85rem; font-weight:700; text-decoration:none; white-space:nowrap; }.edit-project,.project-edit-actions { margin-left:auto; }.project-title-line input,.project-heading textarea { border:1px solid #9aaab3; border-radius:4px; background:#fff; color:#2f3f48; font:inherit; padding:8px; }.project-heading textarea { display:block; width:100%; margin-top:10px; resize:vertical; }.project-edit-actions { display:flex; gap:6px; }.project-edit-actions button:first-child { background:var(--glm-azul); color:#fff; } .home-container { - max-width: 1000px; + max-width: 1600px; margin: 0 auto; - padding: 24px; + padding: 20px 24px 32px; +} + +.planning-toolbar-card { + padding: 16px 20px; +} + +.planning-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px 16px; +} + +.toolbar-summary { + margin: 0; + flex: 1 1 260px; +} + +.toolbar-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-left: auto; +} + +.planning-toolbar-card .upload-error { + margin-top: 12px; +} + +.fuel-config { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; } +.fuel-config label { display: grid; gap: 5px; color: var(--glm-azul); font-size: .75rem; font-weight: 700; text-transform: uppercase; } +.fuel-config input { border: 1px solid #9aaab3; border-radius: 4px; background: #fff; color: #2f3f48; font: inherit; padding: 8px; width: 180px; } +.fuel-summary { margin: 4px 0 0; color: var(--glm-acero); font-size: .82rem; } + +.dashboard-map-card { + padding: 20px; +} + +.dashboard-map-header { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 16px; + margin-bottom: 12px; +} + +.dashboard-map-header .section-title { + display: inline-flex; + align-items: center; + gap: 8px; + margin: 0; +} + +.dashboard-map-hint { + margin: 0; + color: var(--glm-acero); + font-size: 0.85rem; +} + +.dashboard-map-panel { + position: relative; + min-height: 480px; +} + +.dashboard-map-panel app-route-overview-map { + display: block; + position: relative; + height: 620px; +} + +@media (max-width: 1100px) { + .home-container { + padding: 16px; + } + .dashboard-map-panel app-route-overview-map { + height: auto; + } } .home-header { @@ -104,103 +181,8 @@ letter-spacing: 0.3px; } -/* Upload card */ -.upload-card { - padding: 24px; -} - .employees-summary { display: flex; align-items: center; gap: 7px; margin: -2px 0 16px; color: var(--glm-acero); font-size: .88rem; }.employees-summary i { color: var(--glm-verde); }.employees-summary.empty, .employees-summary.empty i { color: #c9252d; } -.upload-grid { - display: grid; - grid-template-columns: 1fr; - gap: 16px; - margin-bottom: 20px; -} - -@media (min-width: 768px) { - .upload-grid { - grid-template-columns: 1fr 1fr; - } -} - -.file-input-wrapper { - display: flex; - flex-direction: column; - gap: 8px; -} - -.file-label { - display: flex; - flex-direction: column; - gap: 4px; - padding: 16px; - border: 2px dashed var(--glm-gris-medio); - border-radius: 4px; - background: var(--glm-verde-bg); - cursor: pointer; - transition: border-color 0.2s, background 0.2s; -} - -.file-label:hover { - border-color: var(--glm-verde); - background: #e5f5dc; -} - -.file-label:has(.file-input:focus-visible) { - border-color: var(--glm-verde); - box-shadow: 0 0 0 2px rgba(108, 194, 74, 0.2); -} - -.file-label i { - font-size: 2rem; - color: var(--glm-verde); -} - -.file-label span:first-of-type { - font-weight: 600; - color: var(--glm-azul); - font-size: 14px; -} - -.file-hint { - font-size: 0.8rem; - color: var(--glm-acero); -} - -.file-input { - position: absolute; - width: 0.1px; - height: 0.1px; - opacity: 0; - overflow: hidden; - z-index: -1; -} - -.file-name { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 12px; - background: var(--glm-verde-bg); - border: 1px solid var(--glm-verde-claro); - border-radius: 4px; - color: var(--glm-gris-oscuro); - font-size: 0.9rem; -} - -.file-name i { - color: var(--glm-verde); -} - -.upload-actions { - display: flex; - flex-direction: column; - gap: 12px; - padding-top: 16px; - border-top: 1px solid var(--glm-gris-medio); -} - .upload-error { display: flex; align-items: center; @@ -265,89 +247,6 @@ 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; diff --git a/route-planner/src/app/private/home/home.component.html b/route-planner/src/app/private/home/home.component.html index 3cc0606..80e49c3 100644 --- a/route-planner/src/app/private/home/home.component.html +++ b/route-planner/src/app/private/home/home.component.html @@ -8,76 +8,23 @@
Mis proyectos
-

{{ projectName || 'Proyecto' }}

Gestión de rutas asignadas y puntos de venta pendientes

+

{{ projectName || 'Proyecto' }}

Gestión de rutas asignadas y puntos de venta pendientes

Combustible: {{ fuelRendimiento ?? '—' }} km/L · {{ fuelPrecio ?? '—' }}/L

-
- -
-

Cargando empleados activos...{{ activeEmployees }} empleados activos de este proyecto se usarán para planificar.

- -
-
- - -
- - {{ pdvFile.name }} - Validando... -
- -
-
Analizando archivo PDV...
-
- -
-
PDV válido — {{ pdvPreview!.totalRows }} filas
-
{{ pdvPreview!.validRows }} filas válidas • Columnas: {{ pdvPreview!.headers.join(', ') }}
-
- -
-
Errores en PDV ({{ pdvPreview.errors.length }})
-
Faltan: {{ pdvPreview.missingHeaders.join(', ') }}
-
{{ pdvPreview.totalRows }} filas leídas • {{ pdvPreview.validRows }} válidas
-
    -
  • - Fila {{ e.row }} — {{ e.field }}: {{ e.message }} - ({{ e.value }}) -
  • -
  • + {{ pdvPreview.errors.length - 8 }} errores más...
  • -
-
-
- -
- -
-
- - {{ uploadError }} -
-
+
+
+

Cargando empleados activos...{{ activeEmployees }} empleados activos de este proyecto se usarán para planificar.

+
- {{ submitDisabledReason }}
+
+ + {{ uploadError }}
+ {{ submitHint }}
- -
@@ -112,6 +60,16 @@
+
+
+

Mapa general y detalle de ruta

+

Selecciona una ruta en el mapa o en el balance para ver su detalle sin cambiar de pantalla.

+
+
+ +
+
+
diff --git a/route-planner/src/app/private/home/home.component.ts b/route-planner/src/app/private/home/home.component.ts index b5286a8..4b108d3 100644 --- a/route-planner/src/app/private/home/home.component.ts +++ b/route-planner/src/app/private/home/home.component.ts @@ -8,6 +8,7 @@ import { ApiResponse } from '../../core/models/route.model'; import { ExcelPreviewService, PreviewResult } from '../../shared/services/excel-preview.service'; import { Subscription } from 'rxjs'; import { PlanningKpisComponent } from './planning-kpis/planning-kpis.component'; +import { RouteOverviewMapComponent } from './route-overview-map/route-overview-map.component'; import { PlanningInputService } from '../../core/services/planning-input.service'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { EmployeeService } from '../../core/services/employee.service'; @@ -19,17 +20,14 @@ import { PlanningFilterKey, PlanningFilters, PlanningFilterService } from '../.. @Component({ standalone: true, selector: 'app-home', - imports: [CommonModule, FormsModule, RouterLink, NoAssignedRouteComponent, PlanningKpisComponent], + imports: [CommonModule, FormsModule, RouterLink, NoAssignedRouteComponent, PlanningKpisComponent, RouteOverviewMapComponent], templateUrl: './home.component.html', styleUrls: ['./home.component.css'] }) export class HomeComponent implements OnInit { pdvFile: File | null = null; - usuariosFile: File | null = null; pdvPreview: PreviewResult | null = null; - usuariosPreview: PreviewResult | null = null; pdvPreviewLoading = false; - usuariosPreviewLoading = false; activeEmployees = 0; employeesLoading = true; uploadError: string | null = null; @@ -40,12 +38,35 @@ export class HomeComponent implements OnInit { projectDescription = ''; projectCountry = ''; projectSaving = false; - inputsExpanded = true; + fuelRendimiento: number | null = null; + fuelPrecio: number | null = null; filtersExpanded = true; - unassignedExpanded = false; + unassignedExpanded = true; planningFilters: PlanningFilters = { cobertura: '', zona: '', region: '', estado: '', ciudad: '' }; + submitDisabled = true; + submitHint: string | null = 'Sube el archivo PDV'; private calcSub: Subscription | null = null; + private safeDetectChanges(): void { + setTimeout(() => { + try { this.cdr.detectChanges(); } catch { /* ignore destroyed view */ } + }); + } + + private updateSubmitState(): void { + this.submitDisabled = !this.canSubmit; + this.submitHint = this.computeSubmitHint(); + } + + private computeSubmitHint(): string | null { + if (!this.pdvFile) return 'Sube el archivo PDV'; + if (this.employeesLoading) return 'Cargando empleados...'; + if (!this.activeEmployees) return 'No hay empleados activos. Regístralos en Gestionar empleados.'; + if (this.pdvPreviewLoading) return 'Validando archivo PDV...'; + if (this.pdvPreview && !this.pdvPreview.valid) return 'Corrige los errores del archivo PDV'; + return null; + } + constructor( private rutasService: RutasService, private routeState: RouteState, @@ -82,13 +103,15 @@ export class HomeComponent implements OnInit { this.projectName = project.nombre; this.projectDescription = project.descripcion ?? ''; this.projectCountry = input.country; + this.fuelRendimiento = project.rendimiento_km_l ?? null; + this.fuelPrecio = project.precio_por_litro ?? null; this.planningFilter.setPdvs(input.rows); this.pdvFile = input.file; this.pdvPreview = await this.previewService.previewFile(input.file, 'PDV'); } catch (error: any) { this.uploadError = error?.message ?? 'No se pudo cargar el proyecto.'; } finally { - this.cdr.detectChanges(); + this.updateSubmitState(); this.safeDetectChanges(); } } @@ -98,9 +121,22 @@ export class HomeComponent implements OnInit { async saveProject(): Promise { if (!this.projectId || !this.projectName.trim()) return; + if (this.fuelRendimiento != null && !(this.fuelRendimiento > 0)) { + this.uploadError = 'El rendimiento debe ser mayor a 0 km/L.'; + return; + } + if (this.fuelPrecio != null && !(this.fuelPrecio >= 0)) { + this.uploadError = 'El precio por litro no puede ser negativo.'; + return; + } this.projectSaving = true; try { - await this.projectsService.update(this.projectId, { nombre: this.projectName.trim(), descripcion: this.projectDescription.trim() || null }); + await this.projectsService.update(this.projectId, { + nombre: this.projectName.trim(), + descripcion: this.projectDescription.trim() || null, + rendimiento_km_l: this.fuelRendimiento, + precio_por_litro: this.fuelPrecio + }); this.editingProject = false; } catch (error: any) { this.uploadError = error?.message ?? 'No se pudo actualizar el proyecto.'; } finally { this.projectSaving = false; this.cdr.detectChanges(); } @@ -111,7 +147,6 @@ export class HomeComponent implements OnInit { filterOptions(key: PlanningFilterKey): string[] { return this.planningFilter.options(key); } applyPlanningFilters(): void { this.planningFilter.set(this.planningFilters); } clearPlanningFilters(): void { this.planningFilters = { cobertura: '', zona: '', region: '', estado: '', ciudad: '' }; this.applyPlanningFilters(); } - toggleInputs(): void { this.inputsExpanded = !this.inputsExpanded; } toggleFilters(): void { this.filtersExpanded = !this.filtersExpanded; } toggleUnassigned(): void { this.unassignedExpanded = !this.unassignedExpanded; } @@ -123,104 +158,10 @@ export class HomeComponent implements OnInit { this.activeEmployees = 0; } finally { this.employeesLoading = false; - this.cdr.detectChanges(); + this.updateSubmitState(); this.safeDetectChanges(); } } - async onPdvFileChange(event: Event) { - const input = event.target as HTMLInputElement; - if (input.files && input.files.length > 0) { - const file = input.files[0]; - const basicError = this.validateFileBasic(file, 'PDV'); - if (basicError) { - this.pdvFile = null; - this.pdvPreview = null; - this.uploadError = basicError; - return; - } - this.pdvFile = file; - if (this.usuariosFile) this.planningInput.setFiles(this.projectId, file, this.usuariosFile); - this.uploadError = null; - this.pdvPreviewLoading = true; - this.pdvPreview = null; - this.cdr.detectChanges(); - try { - this.pdvPreview = await this.previewService.previewFile(file, 'PDV'); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : 'Error al leer el archivo PDV'; - this.pdvPreview = { - fileName: file.name, - type: 'PDV', - valid: false, - totalRows: 0, - validRows: 0, - headers: [], - normalizedHeaders: [], - missingHeaders: [], - errors: [{ row: 1, field: 'file', message: msg }], - duplicateIds: [], - sampleValidRows: [], - }; - } finally { - this.pdvPreviewLoading = false; - this.cdr.detectChanges(); - } - } - } - - async onUsuariosFileChange(event: Event) { - const input = event.target as HTMLInputElement; - if (input.files && input.files.length > 0) { - const file = input.files[0]; - const basicError = this.validateFileBasic(file, 'Usuarios'); - if (basicError) { - this.usuariosFile = null; - this.usuariosPreview = null; - this.uploadError = basicError; - return; - } - this.usuariosFile = file; - if (this.pdvFile) this.planningInput.setFiles(this.projectId, this.pdvFile, file); - this.uploadError = null; - this.usuariosPreviewLoading = true; - this.usuariosPreview = null; - this.cdr.detectChanges(); - try { - this.usuariosPreview = await this.previewService.previewFile(file, 'Usuarios'); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : 'Error al leer el archivo Usuarios'; - this.usuariosPreview = { - fileName: file.name, - type: 'Usuarios', - valid: false, - totalRows: 0, - validRows: 0, - headers: [], - normalizedHeaders: [], - missingHeaders: [], - errors: [{ row: 1, field: 'file', message: msg }], - duplicateIds: [], - sampleValidRows: [], - }; - } finally { - this.usuariosPreviewLoading = false; - this.cdr.detectChanges(); - } - } - } - - private validateFileBasic(file: File, type: string): string | null { - const validExtensions = ['.xlsx', '.xls']; - const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase(); - if (!validExtensions.includes(ext)) { - return `El archivo ${type} debe ser .xlsx o .xls`; - } - if (file.size > 10 * 1024 * 1024) { - return `El archivo ${type} supera 10MB`; - } - return null; - } - get canSubmit(): boolean { if (!this.pdvFile || !this.activeEmployees) return false; if (this.pdvPreviewLoading || this.employeesLoading) return false; @@ -254,7 +195,7 @@ export class HomeComponent implements OnInit { this.uploadError = 'No se pudo preparar el archivo PDV para el optimizador.'; this.routeState.setLoading(false); this.isLoading = false; - this.cdr.detectChanges(); + this.updateSubmitState(); this.safeDetectChanges(); return; } let employeesFile: File; @@ -282,7 +223,7 @@ export class HomeComponent implements OnInit { this.uploadError = String((anyRes as { error: unknown }).error); this.routeState.setLoading(false); this.isLoading = false; - this.cdr.detectChanges(); + this.updateSubmitState(); this.safeDetectChanges(); return; } const payload = Array.isArray(res) @@ -294,7 +235,7 @@ export class HomeComponent implements OnInit { this.uploadError = 'El optimizador devolvió una respuesta vacía. Verifica que el workflow de n8n esté publicado.'; this.routeState.setLoading(false); this.isLoading = false; - this.cdr.detectChanges(); + this.updateSubmitState(); this.safeDetectChanges(); return; } const assigned = payload.find(r => r && typeof r === 'object' && 'assigned' in r) as { assigned: unknown } | undefined; @@ -303,7 +244,7 @@ export class HomeComponent implements OnInit { this.uploadError = 'El optimizador devolvió un formato de respuesta no válido. Verifica el workflow de n8n.'; this.routeState.setLoading(false); this.isLoading = false; - this.cdr.detectChanges(); + this.updateSubmitState(); this.safeDetectChanges(); return; } if (assigned) { @@ -314,13 +255,13 @@ export class HomeComponent implements OnInit { } this.routeState.setLoading(false); this.isLoading = false; - this.cdr.detectChanges(); + this.updateSubmitState(); this.safeDetectChanges(); }, error: (err) => { this.uploadError = this.mapBackendError(err); this.routeState.setLoading(false); this.isLoading = false; - this.cdr.detectChanges(); + this.updateSubmitState(); this.safeDetectChanges(); } }); } @@ -347,17 +288,12 @@ export class HomeComponent implements OnInit { this.planningInput.clear(this.projectId); this.routeState.clearAll(); this.pdvFile = null; - this.usuariosFile = null; this.pdvPreview = null; - this.usuariosPreview = null; this.pdvPreviewLoading = false; - this.usuariosPreviewLoading = false; this.uploadError = null; this.isLoading = false; this.routeState.setLoading(false); - const pdvInput = document.getElementById('pdvFile') as HTMLInputElement; - const usuariosInput = document.getElementById('usuariosFile') as HTMLInputElement; - if (pdvInput) pdvInput.value = ''; - if (usuariosInput) usuariosInput.value = ''; + this.updateSubmitState(); + this.safeDetectChanges(); } } diff --git a/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.ts b/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.ts index f16a89a..e0c3db8 100644 --- a/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.ts +++ b/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.ts @@ -27,6 +27,19 @@ export class NoAssignedRouteComponent implements OnDestroy { assignmentDays: Record = {}; assigningId: number | null = null; actionError: string | null = null; + userOptions: { id: string; name: string }[] = []; + dayOptions: string[] = []; + + private safeDetectChanges(): void { + setTimeout(() => { + try { this.cdr.detectChanges(); } catch { /* ignore destroyed view */ } + }); + } + + private refreshOptions(): void { + this.userOptions = this.routesState.getUsuariosUnicos().map(user => ({ id: user.id, name: user.nombre })); + this.dayOptions = this.routesState.getDiasUnicos(); + } constructor( private routesState: RouteState, @@ -36,22 +49,24 @@ export class NoAssignedRouteComponent implements OnDestroy { ) {} ngOnInit() { + this.refreshOptions(); this.subs.push( this.routesState.loading$.subscribe(value => { this.isLoading = value; - this.cdr.detectChanges(); + this.safeDetectChanges(); }) ); this.subs.push( this.routesState.noAssignedRoutes$.subscribe(data => { this.allNoAssignedRoutes = data.map(route => ({ ...route, recomendacion: route.recomendacion ?? recommendationForUnassigned(route) })); this.noAssignedRoutes = this.filters.filterUnassigned(this.allNoAssignedRoutes); - this.cdr.detectChanges(); + this.refreshOptions(); + this.safeDetectChanges(); }) ); this.subs.push(this.filters.filters$.subscribe(() => { this.noAssignedRoutes = this.filters.filterUnassigned(this.allNoAssignedRoutes); - this.cdr.detectChanges(); + this.safeDetectChanges(); })); this.cols = [ { field: 'pdv_id', header: 'PDV ID' }, @@ -64,13 +79,7 @@ export class NoAssignedRouteComponent implements OnDestroy { ]; } - get userOptions(): { id: string; name: string }[] { - return this.routesState.getUsuariosUnicos().map(user => ({ id: user.id, name: user.nombre })); - } - get dayOptions(): string[] { - return this.routesState.getDiasUnicos(); - } toggleDay(pdvId: number, day: string, checked: boolean): void { const key = String(pdvId); @@ -113,7 +122,8 @@ export class NoAssignedRouteComponent implements OnDestroy { this.actionError = `No se pudo calcular la ruta vial para ${route.pdv_nombre}.`; } finally { this.assigningId = null; - this.cdr.detectChanges(); + this.refreshOptions(); + this.safeDetectChanges(); } } diff --git a/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.css b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.css index 6492f8a..846cc10 100644 --- a/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.css +++ b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.css @@ -88,6 +88,7 @@ .kpi-warning { border-left-color: var(--glm-naranja); } .kpi-blue { border-left-color: var(--glm-acero); } .kpi-orange { border-left-color: var(--glm-naranja); } +.kpi-fuel { border-left-color: var(--glm-verde); } .kpi-icon { display: grid; diff --git a/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.html b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.html index 12f7ba5..02cd8fd 100644 --- a/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.html +++ b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.html @@ -23,9 +23,9 @@ class="export-button" *ngIf="hasData" (click)="downloadRoutes()" - [disabled]="pdfLoading" + [disabled]="exportLoading" > - {{ pdfLoading ? "Generando PDF..." : "Descargar rutas" }} {{ exportLoading ? "Generando Excel..." : "Descargar rutas" }}
-
- {{ reoptError || editError || pdfError }} +
+ {{ reoptError || editError || exportError }}
@@ -97,6 +97,17 @@ >
+
+
+
+ Combustible{{ summary.fuelLiters | number: "1.0-1" }} L—{{ fuelCurrency }} {{ summary.fuelCost | number: "1.2-2" }}Sin configurar en el proyecto +
+
@@ -119,6 +130,7 @@ Visitas Distancia Trabajo + Costo comb. Carga @@ -142,6 +154,8 @@ {{ load.visits }} {{ load.distance | number: "1.0-1" }} km {{ load.workHours | number: "1.0-1" }} h + {{ fuelCurrency }} {{ load.fuelCost | number: "1.2-2" }} + —
- + diff --git a/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.ts b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.ts index e7f3cd8..95b097c 100644 --- a/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.ts +++ b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.ts @@ -4,17 +4,19 @@ import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { Subscription } from 'rxjs'; import { RouteState } from '../../../core/route-state/route'; -import { RoutePdfReportService } from '../../../shared/services/route-pdf-report.service'; +import { PlanningExportService } from '../../../shared/services/planning-export.service'; import { summarizeDailyRoutes } from '../../../shared/utils/route-sequencing.utils'; import { RouteAssignedResponse } from '../../../core/models/route.model'; import { PlanningFilterService } from '../../../core/services/planning-filter.service'; import { RutasService } from '../../../core/services/router.service'; import { PlanningInputService } from '../../../core/services/planning-input.service'; +import { ProjectService } from '../../../core/services/project.service'; +import { currencyForCountry, fuelCost, isFuelConfigured, type FuelConfig } from '../../../shared/utils/fuel-estimation.utils'; -interface KpiSummary { totalPdvs: number; assignedPdvs: number; unassignedPdvs: number; coverage: number; users: number; days: number; totalDistance: number; averageDistance: number; totalWorkHours: number; } +interface KpiSummary { totalPdvs: number; assignedPdvs: number; unassignedPdvs: number; coverage: number; users: number; days: number; totalDistance: number; averageDistance: number; totalWorkHours: number; fuelLiters: number; fuelCost: number; } interface ReasonSummary { reason: string; count: number; } interface UserLoad { - id: string; name: string; uniquePdvs: number; visits: number; routes: RouteAssignedResponse[]; distance: number; workHours: number; + id: string; name: string; uniquePdvs: number; visits: number; routes: RouteAssignedResponse[]; distance: number; workHours: number; fuelLiters: number; fuelCost: number; capacityHours: number; utilization: number; status: 'ok' | 'warning' | 'over-capacity'; alertMessage: string; } @@ -35,44 +37,64 @@ export class PlanningKpisComponent implements OnInit, OnDestroy { reoptLoading = false; reoptError = ''; hasLocalChanges = false; - pdfLoading = false; - pdfError = ''; + exportLoading = false; + exportError = ''; readonly userLoadPageSize = 14; userLoadPage = 1; + fuelConfig: FuelConfig = { rendimientoKmL: null, precioPorLitro: null }; + fuelCurrency = '$'; + fuelConfigured = false; private readonly subs: Subscription[] = []; constructor( private routeState: RouteState, private filters: PlanningFilterService, - private pdfReport: RoutePdfReportService, + private excelExport: PlanningExportService, private rutasService: RutasService, private planningInput: PlanningInputService, + private projects: ProjectService, private router: Router, private activatedRoute: ActivatedRoute, private cdr: ChangeDetectorRef ) {} ngOnInit(): void { + void this.loadFuelConfig(); this.subs.push( this.routeState.assignedRoutes$.subscribe(() => this.refresh()), this.routeState.noAssignedRoutes$.subscribe(() => this.refresh()), this.filters.filters$.subscribe(() => this.refresh()) ); } + + private async loadFuelConfig(): Promise { + try { + const project = await this.projects.get(this.projectId); + this.fuelConfig = { rendimientoKmL: project.rendimiento_km_l ?? null, precioPorLitro: project.precio_por_litro ?? null }; + this.fuelCurrency = currencyForCountry(project.pais); + } catch { + this.fuelConfig = { rendimientoKmL: null, precioPorLitro: null }; + this.fuelCurrency = '$'; + } finally { + this.fuelConfigured = isFuelConfigured(this.fuelConfig); + this.refresh(); + this.cdr.detectChanges(); + } + } ngOnDestroy(): void { this.subs.forEach(subscription => subscription.unsubscribe()); } barWidth(value: number): number { return Math.min(Math.max(value, 0), 100); } utilizationClass(load: UserLoad): string { return load.status; } async downloadRoutes(): Promise { - if (this.pdfLoading || !this.hasData) return; - this.pdfLoading = true; - this.pdfError = ''; + if (this.exportLoading || !this.hasData) return; + this.exportLoading = true; + this.exportError = ''; try { - await this.pdfReport.download(this.projectId); + await this.excelExport.export(this.projectId); } catch (error: any) { - this.pdfError = error?.message ?? 'No se pudo generar el PDF de rutas.'; + this.exportError = error?.message ?? 'No se pudo generar el Excel de rutas.'; } finally { - this.pdfLoading = false; + this.exportLoading = false; this.cdr.detectChanges(); } } @@ -138,7 +160,7 @@ export class PlanningKpisComponent implements OnInit, OnDestroy { const loads = new Map(); for (const route of assigned) { const id = String(route.json.usuario_id); - const load = loads.get(id) ?? { id, name: route.json.nombre_usuario, uniquePdvs: 0, visits: 0, routes: [], distance: 0, workHours: 0, capacityHours: this.numberValue(route.json.hora_laboral_semanal_usuario), utilization: 0, status: 'ok', alertMessage: '' }; + const load = loads.get(id) ?? { id, name: route.json.nombre_usuario, uniquePdvs: 0, visits: 0, routes: [], distance: 0, workHours: 0, fuelLiters: 0, fuelCost: 0, capacityHours: this.numberValue(route.json.hora_laboral_semanal_usuario), utilization: 0, status: 'ok', alertMessage: '' }; load.routes.push(route); load.visits++; loads.set(id, load); } @@ -148,13 +170,18 @@ export class PlanningKpisComponent implements OnInit, OnDestroy { const userDays = dailyRoutes.filter(day => day.userId === load.id); load.distance = userDays.reduce((sum, day) => sum + day.distanceKm, 0); load.workHours = userDays.reduce((sum, day) => sum + day.workHours, 0); + const fuel = fuelCost(load.distance, this.fuelConfig); + load.fuelLiters = fuel.liters; + load.fuelCost = fuel.cost; load.utilization = load.capacityHours ? load.workHours / load.capacityHours * 100 : 0; load.status = load.utilization > 100 ? 'over-capacity' : load.utilization > 90 ? 'warning' : 'ok'; load.alertMessage = `${load.name}: ${load.workHours.toFixed(1)} h de ${load.capacityHours.toFixed(1)} h.`; return load; }).sort((a, b) => b.visits - a.visits || b.workHours - a.workHours); this.userLoadPage = Math.min(this.userLoadPage, this.userLoadPages); - this.summary = { totalPdvs, assignedPdvs: assignedPdvIds.size, unassignedPdvs: unassignedPdvIds.size, coverage: totalPdvs ? assignedPdvIds.size / totalPdvs * 100 : 0, users: loads.size, days: new Set(assigned.map(route => route.json.dia)).size, totalDistance, averageDistance: assignedPdvIds.size ? totalDistance / assignedPdvIds.size : 0, totalWorkHours }; + const fuelTotal = fuelCost(totalDistance, this.fuelConfig); + this.fuelConfigured = isFuelConfigured(this.fuelConfig); + this.summary = { totalPdvs, assignedPdvs: assignedPdvIds.size, unassignedPdvs: unassignedPdvIds.size, coverage: totalPdvs ? assignedPdvIds.size / totalPdvs * 100 : 0, users: loads.size, days: new Set(assigned.map(route => route.json.dia)).size, totalDistance, averageDistance: assignedPdvIds.size ? totalDistance / assignedPdvIds.size : 0, totalWorkHours, fuelLiters: fuelTotal.liters, fuelCost: fuelTotal.cost }; const reasonByPdv = new Map(); for (const route of unassigned) if (!assignedPdvIds.has(String(route.pdv_id)) && !reasonByPdv.has(String(route.pdv_id))) reasonByPdv.set(String(route.pdv_id), route.motivo?.trim() || 'Sin motivo especificado'); const counts = new Map(); for (const reason of reasonByPdv.values()) counts.set(reason, (counts.get(reason) ?? 0) + 1); @@ -162,5 +189,5 @@ export class PlanningKpisComponent implements OnInit, OnDestroy { this.hasData = totalPdvs > 0; } private numberValue(value: number | string | null | undefined): number { const result = Number(value); return Number.isFinite(result) ? result : 0; } - private emptySummary(): KpiSummary { return { totalPdvs: 0, assignedPdvs: 0, unassignedPdvs: 0, coverage: 0, users: 0, days: 0, totalDistance: 0, averageDistance: 0, totalWorkHours: 0 }; } + private emptySummary(): KpiSummary { return { totalPdvs: 0, assignedPdvs: 0, unassignedPdvs: 0, coverage: 0, users: 0, days: 0, totalDistance: 0, averageDistance: 0, totalWorkHours: 0, fuelLiters: 0, fuelCost: 0 }; } } diff --git a/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.css b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.css index 97d0ae3..e4b1880 100644 --- a/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.css +++ b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.css @@ -115,6 +115,41 @@ box-shadow: 0 0 0 2px rgba(108, 194, 74, 0.15); } +.segmented-control { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 0; + overflow: hidden; + border: 1px solid var(--glm-gris-medio); + border-radius: 4px; + background: #fff; +} + +.segmented-control button { + padding: 8px 4px; + border: 0; + border-right: 1px solid var(--glm-gris-medio); + background: #fff; + color: var(--glm-acero); + cursor: pointer; + font: inherit; + font-size: 0.78rem; + font-weight: 700; +} + +.segmented-control button:last-child { + border-right: 0; +} + +.segmented-control button:hover { + background: var(--glm-verde-bg); +} + +.segmented-control button.active { + background: var(--glm-verde); + color: #fff; +} + .filter-stats { display: flex; gap: 10px; @@ -310,6 +345,13 @@ text-transform: uppercase; } +.visit-group-title { + margin: 8px 0 4px; + color: var(--glm-azul); + font-size: 0.76rem; + font-weight: 700; +} + .reassign-form { display: grid; grid-template-columns: 1fr 1fr; @@ -506,3 +548,62 @@ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4); font-family: Arial, sans-serif; } + +:host.embedded { + height: 620px; + min-height: 480px; + overflow: hidden; + border: 1px solid var(--glm-gris-medio); + border-radius: 4px; + background: #fff; +} + +:host.embedded #map-overview { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 340px; + height: 100%; +} + +:host.embedded .overview-sidebar { + position: absolute; + top: 0; + left: 0; + bottom: 0; + width: 340px; + z-index: 500; +} + +:host.embedded .unassigned-bottom-drawer { + position: absolute; + left: 352px; + right: 12px; + bottom: 12px; + z-index: 600; +} + +@media (max-width: 1100px) { + :host.embedded { + height: auto; + min-height: 0; + } + :host.embedded #map-overview { + position: relative; + left: 0; + height: 420px; + } + :host.embedded .overview-sidebar { + position: relative; + width: 100%; + max-height: none; + } + :host.embedded .unassigned-bottom-drawer { + position: relative; + left: 0; + right: 0; + bottom: 0; + margin: 0 12px 12px; + } +} diff --git a/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.html b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.html index 129d7d1..546989a 100644 --- a/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.html +++ b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.html @@ -6,12 +6,12 @@
-
IDNombreSegmentaciónCiudadEstadoZonaAcciones
{{ pdv.pdv_id }}{{ value(pdv, 'nombre_del_pdv') }}{{ value(pdv, 'segmentacion') }}{{ value(pdv, 'ciudad') }}{{ value(pdv, 'estado') }}{{ value(pdv, 'zona') }}
No hay PDVs registrados.
diff --git a/route-planner/src/app/private/pdvs/pdvs.component.ts b/route-planner/src/app/private/pdvs/pdvs.component.ts index 306557b..a1b6d43 100644 --- a/route-planner/src/app/private/pdvs/pdvs.component.ts +++ b/route-planner/src/app/private/pdvs/pdvs.component.ts @@ -5,7 +5,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import * as XLSX from 'xlsx'; import { ProjectService } from '../../core/services/project.service'; import { RuteroProyectoPdv } from '../../core/models/project.model'; -import { ExcelPreviewService } from '../../shared/services/excel-preview.service'; +import { ExcelPreviewService, FieldError } from '../../shared/services/excel-preview.service'; import { normalizeRow } from '../../shared/utils/header-normalization.utils'; import { AuthService } from '../../core/services/auth.service'; import { UserProfileService } from '../../core/services/user-profile.service'; @@ -44,6 +44,8 @@ export class PdvsComponent implements OnInit { importing = false; error = ''; message = ''; + importErrors: FieldError[] = []; + importMissingHeaders: string[] = []; projectCountry = 'Republica Dominicana'; selectedIds = new Set(); bulkSaving = false; @@ -115,10 +117,14 @@ export class PdvsComponent implements OnInit { const input = event.target as HTMLInputElement; const file = input.files?.[0]; if (!file) return; - this.importing = true; this.error = ''; this.message = ''; + this.importing = true; this.error = ''; this.message = ''; this.importErrors = []; this.importMissingHeaders = []; try { const result = await this.preview.previewFile(file, 'PDV'); - if (!result.valid) throw new Error(result.errors[0]?.message ?? 'El archivo PDV contiene errores.'); + if (!result.valid) { + this.importErrors = result.errors; + this.importMissingHeaders = result.missingHeaders; + throw new Error(result.errors[0]?.message ?? 'El archivo PDV contiene errores.'); + } const workbook = XLSX.read(await file.arrayBuffer(), { type: 'array' }); const rows = XLSX.utils.sheet_to_json>(workbook.Sheets[workbook.SheetNames[0]], { defval: null, raw: true }); await this.projects.upsertPdvs(this.projectId, rows); diff --git a/route-planner/src/app/shared/services/planning-export.service.ts b/route-planner/src/app/shared/services/planning-export.service.ts index bc3e79f..37b9ee6 100644 --- a/route-planner/src/app/shared/services/planning-export.service.ts +++ b/route-planner/src/app/shared/services/planning-export.service.ts @@ -4,14 +4,22 @@ import { saveAs } from 'file-saver'; import { RouteState } from '../../core/route-state/route'; import { sequenceRoutes, summarizeDailyRoutes } from '../utils/route-sequencing.utils'; import { PlanningFilterService } from '../../core/services/planning-filter.service'; +import { ProjectService } from '../../core/services/project.service'; +import { currencyForCountry, fuelCost, isFuelConfigured } from '../utils/fuel-estimation.utils'; +import { recommendationForUnassigned } from '../utils/unassigned-recommendation.utils'; @Injectable({ providedIn: 'root' }) export class PlanningExportService { - constructor(private routeState: RouteState, private filters: PlanningFilterService) {} + constructor(private routeState: RouteState, private filters: PlanningFilterService, private projects: ProjectService) {} - export(): void { + async export(projectId: string): Promise { + const project = await this.projects.get(projectId); + const fuelConfig = { rendimientoKmL: project.rendimiento_km_l ?? null, precioPorLitro: project.precio_por_litro ?? null }; + const fuelConfigured = isFuelConfigured(fuelConfig); + const fuelCurrency = currencyForCountry(project.pais); const assigned = this.filters.filterAssigned(this.routeState.getAssignedSnapshot()); - const unassigned = this.filters.filterUnassigned(this.routeState.getNoAssignedSnapshot()); + const unassigned = this.filters.filterUnassigned(this.routeState.getNoAssignedSnapshot()) + .map(route => ({ ...route, recomendacion: route.recomendacion ?? recommendationForUnassigned(route) })); const users = new Map; distancia: number; trabajo: number; capacidad: number }>(); for (const route of assigned) { @@ -32,11 +40,13 @@ export class PlanningExportService { } const assignedIds = new Set(assigned.map(route => String(route.json.pdv_id))); - const unassignedIds = new Set(unassigned.map(route => String(route.pdv_id)).filter(id => !assignedIds.has(id))); + const pending = unassigned.filter(route => !assignedIds.has(String(route.pdv_id))); + const unassignedIds = new Set(pending.map(route => String(route.pdv_id))); const total = assignedIds.size + unassignedIds.size; 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 fuelTotal = fuelCost(totalDistance, fuelConfig); const workbook = XLSX.utils.book_new(); const summary = [ @@ -47,7 +57,9 @@ export class PlanningExportService { { 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)) } + { Indicador: 'Desplazamiento total (h)', Valor: Number(totalTravel.toFixed(2)) }, + { Indicador: 'Combustible total (L)', Valor: fuelConfigured ? Number(fuelTotal.liters.toFixed(2)) : 'Sin configurar' }, + { Indicador: `Costo combustible total (${fuelCurrency})`, Valor: fuelConfigured ? Number(fuelTotal.cost.toFixed(2)) : 'Sin configurar' } ]; const assignedRows = sequenceRoutes(assigned).map(segment => ({ @@ -61,15 +73,17 @@ export class PlanningExportService { '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}` + 'Origen del tramo': segment.previousPdvName ?? `Casa de ${segment.route.json.nombre_usuario}`, + 'Costo combustible': fuelConfigured ? Number(fuelCost(Number(segment.route.json.distancia) || 0, fuelConfig).cost.toFixed(2)) : 'Sin configurar' })); - const unassignedRows = unassigned.map(route => ({ + const unassignedRows = pending.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 + Motivo: route.motivo, + Recomendación: route.recomendacion ?? '' })); const userRows = Array.from(users, ([id, user]) => { const utilization = user.capacidad ? (user.trabajo / user.capacidad) * 100 : 0; @@ -82,6 +96,7 @@ export class PlanningExportService { 'Horas trabajo': Number(user.trabajo.toFixed(2)), 'Capacidad (h)': user.capacidad, 'Utilización (%)': Number(utilization.toFixed(1)), + [`Costo combustible (${fuelCurrency})`]: fuelConfigured ? Number(fuelCost(user.distancia, fuelConfig).cost.toFixed(2)) : 'Sin configurar', Estado: utilization > 100 ? 'Excede capacidad' : utilization > 90 ? 'Riesgo' : 'OK' }; }); diff --git a/route-planner/src/app/shared/services/route-pdf-report.service.ts b/route-planner/src/app/shared/services/route-pdf-report.service.ts index b7d6063..eb20abb 100644 --- a/route-planner/src/app/shared/services/route-pdf-report.service.ts +++ b/route-planner/src/app/shared/services/route-pdf-report.service.ts @@ -8,6 +8,7 @@ import { ProjectService } from '../../core/services/project.service'; import { PlanningFilterService } from '../../core/services/planning-filter.service'; import { sequenceRoutes, summarizeDailyRoutes } from '../utils/route-sequencing.utils'; import { recommendationForUnassigned } from '../utils/unassigned-recommendation.utils'; +import { currencyForCountry, fuelCost, isFuelConfigured } from '../utils/fuel-estimation.utils'; @Injectable({ providedIn: 'root' }) export class RoutePdfReportService { @@ -30,17 +31,31 @@ export class RoutePdfReportService { .map(route => ({ ...route, recomendacion: route.recomendacion ?? recommendationForUnassigned(route) })); const dailyRoutes = summarizeDailyRoutes(assigned); const totalPdvs = assignedIds.size + new Set(pending.map(route => String(route.pdv_id))).size; + const totalDistanceKm = dailyRoutes.reduce((sum, route) => sum + route.distanceKm, 0); + const fuelConfig = { rendimientoKmL: project.rendimiento_km_l ?? null, precioPorLitro: project.precio_por_litro ?? null }; + const fuelConfigured = isFuelConfigured(fuelConfig); + const fuelTotal = fuelCost(totalDistanceKm, fuelConfig); + const fuelCurrency = currencyForCountry(project.pais); const payload = { project: { id: project.id, name: project.nombre, country: project.pais ?? '', description: project.descripcion ?? '' }, filters: this.filters.snapshot(), generatedAt: new Date().toISOString(), + fuel: { + configured: fuelConfigured, + rendimientoKmL: fuelConfig.rendimientoKmL, + precioPorLitro: fuelConfig.precioPorLitro, + currency: fuelCurrency + }, summary: { totalPdvs, assignedPdvs: assignedIds.size, unassignedPdvs: pending.length, coveragePercent: totalPdvs ? Number(((assignedIds.size / totalPdvs) * 100).toFixed(1)) : 0, - totalDistanceKm: Number(dailyRoutes.reduce((sum, route) => sum + route.distanceKm, 0).toFixed(2)), - totalWorkHours: Number(dailyRoutes.reduce((sum, route) => sum + route.workHours, 0).toFixed(2)) + totalDistanceKm: Number(totalDistanceKm.toFixed(2)), + totalWorkHours: Number(dailyRoutes.reduce((sum, route) => sum + route.workHours, 0).toFixed(2)), + fuelLiters: Number(fuelTotal.liters.toFixed(2)), + fuelCost: Number(fuelTotal.cost.toFixed(2)), + fuelCurrency }, assignedRoutes: sequenceRoutes(assigned).map(segment => ({ usuarioId: segment.route.json.usuario_id, @@ -53,6 +68,8 @@ export class RoutePdfReportService { distanciaKm: segment.route.json.distancia, desplazamientoHoras: segment.route.json.horas_desplazamiento, horasTrabajo: segment.route.json.horas_trabajo, + costoCombustible: fuelConfigured ? Number(fuelCost(Number(segment.route.json.distancia) || 0, fuelConfig).cost.toFixed(2)) : null, + monedaCombustible: fuelCurrency, cobertura: segment.route.json.cobertura ?? '', zona: segment.route.json.zona ?? '', region: segment.route.json.region ?? '', diff --git a/route-planner/src/app/shared/utils/country-center.utils.ts b/route-planner/src/app/shared/utils/country-center.utils.ts new file mode 100644 index 0000000..645ca07 --- /dev/null +++ b/route-planner/src/app/shared/utils/country-center.utils.ts @@ -0,0 +1,25 @@ +export interface CountryCenter { + lat: number; + lng: number; + zoom: number; +} + +function normalize(value: string | null | undefined): string { + return (value ?? '') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]/gi, '') + .toLowerCase(); +} + +export function centerForCountry(country: string | null | undefined): CountryCenter { + const normalized = normalize(country); + if (!normalized) return { lat: 19.4326, lng: -99.1332, zoom: 5 }; + if (['mexico', 'mx', 'cdmx', 'ciudaddemexico', 'mexicocity', 'estadosunidosmexicanos'].includes(normalized)) { + return { lat: 19.4326, lng: -99.1332, zoom: 10 }; + } + if (['republicadominicana', 'dominicana', 'rd', 'do', 'santodomingo'].includes(normalized)) { + return { lat: 18.4861, lng: -69.9312, zoom: 8 }; + } + return { lat: 19.4326, lng: -99.1332, zoom: 5 }; +} diff --git a/route-planner/src/app/shared/utils/fuel-estimation.utils.ts b/route-planner/src/app/shared/utils/fuel-estimation.utils.ts new file mode 100644 index 0000000..6463474 --- /dev/null +++ b/route-planner/src/app/shared/utils/fuel-estimation.utils.ts @@ -0,0 +1,43 @@ +export interface FuelConfig { + rendimientoKmL: number | null; + precioPorLitro: number | null; +} + +export function currencyForCountry(country: string | null | undefined): string { + const normalized = (country ?? '') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]/gi, '') + .toLowerCase(); + if (!normalized) return '$'; + if (['mexico', 'mx', 'cdmx', 'ciudaddemexico', 'mexicocity', 'estadosunidosmexicanos'].includes(normalized)) return 'MX$'; + if (['republicadominicana', 'dominicana', 'rd', 'do', 'santodomingo'].includes(normalized)) return 'RD$'; + return '$'; +} + +export function isFuelConfigured(config: FuelConfig): boolean { + return ( + config.rendimientoKmL != null && + config.precioPorLitro != null && + Number.isFinite(config.rendimientoKmL) && + Number.isFinite(config.precioPorLitro) && + config.rendimientoKmL > 0 && + config.precioPorLitro >= 0 + ); +} + +export function fuelLiters(distanceKm: number, rendimientoKmL: number): number { + if (!Number.isFinite(distanceKm) || distanceKm <= 0) return 0; + if (!Number.isFinite(rendimientoKmL) || rendimientoKmL <= 0) return 0; + return distanceKm / rendimientoKmL; +} + +export function fuelCost(distanceKm: number, config: FuelConfig): { liters: number; cost: number } { + if (!isFuelConfigured(config)) return { liters: 0, cost: 0 }; + const liters = fuelLiters(distanceKm, config.rendimientoKmL as number); + return { liters, cost: liters * (config.precioPorLitro as number) }; +} + +export function formatFuelCost(cost: number, currency: string): string { + return `${currency} ${cost.toFixed(2)}`; +} diff --git a/supabase/proyecto_combustible.sql b/supabase/proyecto_combustible.sql new file mode 100644 index 0000000..48fa542 --- /dev/null +++ b/supabase/proyecto_combustible.sql @@ -0,0 +1,3 @@ +alter table public.rutero_proyectos + add column if not exists rendimiento_km_l numeric null, + add column if not exists precio_por_litro numeric null;