#f{05%1kR%mw5<<%3fV<_y631oF=?ym$n#dlPvVgn`zJ)`v&dpp>h7 zfA&_OLa?PQ@-V%*K9QaUI&NP?H+=if26DY`ivbvquirvg(Em#hfY76l*i)ylol(e$ zu#8o2u3`&aMH|Rn*n;mUrFH>xe41?9Y7a|LIx0P^L{hng1gqbhE3JN#`J9)3_F!ky>f()ufHQ_$q^jW^V2=qscf`rur? z$i5(kVyb~?usj)HGHCH`u!Xbu{)zC|Gt4;Dr1GM!*M)AvxAv^R_0^ZA9Mi%wPYQb& zRHogarKID84{I>eO}f-`qX-T%Y21 R5(u+sOxr{wm)OlNe0&WOfe$%7#e!UX=6O*)*t
98AE!vIQR3Ui-# zmj)&VeoW7(!g0NRJVEK)7TV17exOX9Sigk(SmT>-d`8?(x7v-XeuU_4_=r?{Zr=!o zv7SLsuWwCSE*KQRqHrEXyW1ssdhWDI)<@y<8>WPS>n+3i48Wqf<%FZ(jZSUe3e*_J zs;uLs$)>DKWaL--RVQ_X!63YdE uvJ;>u5#VAGjnr+j zbULTxb&{^2o&Bom>7jjNkMQrK75iSY>t(;gMjulz8=OHY+c*h6)sw`d*)2fVX;we{ zR?E-ev>}XF#c@F!jo_Em2-VHMUx?oreKJi)cB9`l2_S|*^o$U+6f3EoCpD$K5f!ab z{D9f*xoWSr+rt39!Sx4k Fuw6g=lbus%0oZGYf?VhpJ?S;X^GnKaf|-- zc3M71jvj`YMV3LPn$o%6Wc)&EdTfa*bavdidJ)1RLK(&hZbmJl6HAp8IskIkr4*)T zaKUR#v$EPVDpcjoScPqBZ~T4q4Yt2OV1p?4_`a+vZ-p=vdMf8tnYV!I>Qr@=U2s 5%RZcLN!Uwgu&+V7M0{ zzwqnN=&+ih$Xp)2$s#?HUP_vl##&u}IJx11GJo{@0|)C5AnO&yYOn79X#B=O_vo&V zL)+8~iop+erTFD U<&kzFqW`AjqI){gO0`s~`(Rcy;{%;x&0 z52CnJde&@wuh~7@Q95Y@_XrhWKT{YZ{)rHW?|+KmCf7rGJo@feXYtHA@NLf+<=>>7 zUn{Gm8Y6IIg-(0H^ac$#Jcg_9oZ{AV)i>0hwgeNgS645Jr6To4_sTZPh&|Q`1`6V$ z }Q^)2@$=u53buxoW;Qj`V|Y`lh52*Z7`PIQtp6SXS=@Dj?l&g(IFentHv6) zwPqU6j*?p_$9}^^FiYv=`F}Uah=+(CQ`jkW7zG!`0E)NZ#VAqm`_OTE^Zq HOc>Tr#@U*8pld?W5wRqJfHIiyKQYkwf=JjRsqtWL-0Q2o*QKZQcOdw!7XS zM6{qT($KJ_DktQ4YT33I?GzaXI!ROul63S7hzJ7 BAb5#0r8Pi%%7s%2Rf9Gvt3FC2}oE5j>#+q`)QwkTiLa0voaT$4mXwE~z#=K+Qs@H6xg3 Z?`7yXLQr{rZel7cv@N{Xijh_Vid){E5_ZTId`P8&hk^hotsH!u6KX= z`Q8=`Q`xs
v-IyHPHzl|zTz>nV=UJ4VW=S|nYoF>@$lgHPr5FUt* zxsyK{h&kxRvnP_3&J9c{@U%HPs#XG&50W5L=Yxa}Q2>P%)Kn1&51+#3K#(`|a{Z6j z|H2j>AW2dTAfvY(wTFYyQ#B}7@Yk?PL28;a$i|@AV7s2^AumATzaaMA@SXG&=(af( zge`ZSj2M9VTr^A*b{~D9NP_LHgfM`@^}P=mS>%7|iH1oby+BaeTNnu+JtwT<_qm0C z#w#&e#Tup1vMc%n7sKR-TH$8TYqa zA1$qT*RkTAcl4kz&9l=~kh0nLnVq*L%i!Z(D9-l{jf22=B#Vc)!&q>}eal; {ylYU$u;{J1XZt}5XQ=JDm#p8O)cl=6i {6-`t#;kWYQYvH*_vw5q% cX}*{n*TiYVBYZhk4O5y*}@p3AjHYidrf_J^z#23tab9P z9So8bsTnx!6Xal?e#qB}-PNf7oOy2P?!~`cmR7#WRtCi{Y%+``UyW0OQ6C&ctxTqL zUy6QwED;2|_E=$s0SMB2=2@vO_2yNw!@kF@U*GFIV*a-WBOS$_0XG}Dyx62rm?QN` zE gTzJU35DC$+?vtVdkr?JU*PB*vOY3QNMTnLQisma^Cr0Ck!|>2 z__s&!bF5g(W!Jvwm9i~9dDtna7mcC~vq&(@ymeM8;W`@_J#mjU{ov>j>ao40B)hNH zQjv8nr*aVWYgSSiJp&g*{6s;hk>cK)!s>_MQp gDt zu$E{1GbiNH8?5@d>&6B@wXyHF=9*T>w+{V66OGQ6P_I<$bQI!@XV}Xh$I(h~_rZ9~ zdaOy4XctX&UGQ#m!EcJJSiY7}ek;!NjW#Z9OFj14+V6k?oNjEm%6%*?vEfB-5N7Vt zQ2VXcek;B7KYY#eC-pdpA{~AWk`*0M&B`b4o=(@#S4$A%w%Pqm8@+%1Bh!r$p6A9$ z?I~OI2M^6buRM*VG0o?1ioci*)`KMdi#Yg$NHtomUI4Bm35#bfBq#kcyWuZ%+jr#d zPBRb2w 4YTgZt$}f53C* z!Z#>s5gLfPDLoPge~CIlKN-4(ODLng#nV(aN1yS`$o2c25gWS7L^;JXW4DppHa{hb zvum$1+ZFRV F{svhwy)y)O r1ZzC5`IEvF6V_}PMfcC}nQ+$Z7B++5zJS#Zv*4Bia?&}TBEA~=R zQdwKdgSG5b5-~Bl!G&;WqvM{UUS74jed!>1+d#J{rGopB_wL3~|M*VReLv?}>-6@8 z4+FuO!x*DXFuSa(FlVvyG7IK{xzC-9CNei;2&wC82lf4lms;CVd6oege5mWd6yntu z{2m`=e4)SD`sp{S3i)+OXmXf?FnnV?I`I4z!+XeCEtg&N99yw@(H&{Y>}ZL&=NFem zsL-e9IXIGhp`6t)%bv<})%?He^{|T>%nMs7{hzagVOm{iF#8~{KQF32Px3j}k6T`( zjQ>f;22T_GI^iUaI%pmbJRkM-{4DVU=stscz%1tC2Zg2nN3;iE-3oISF(gh;bMcpe z4|B%_^9VOhLM>;Cj-Ic u?jo;SY#zKNqzm9ZzS`3 8OM_>S(8VlEIf(7W(l}S9&Ia}%jfDTak4ST@eFegPT-;=$tuK8>p zHd~u7eeltny1v)uBkIAtEp1K9H!uhtBhPw^JX}?OIUDq0mJ3V6|J*??W>c~ktk@S@ z=^O)*8X;1>23f8?p3 1l$50P;Nd^aT}@i#2zw&D6?K9f(Kgn(W7C+R zks#3%-_w&~;`kwrNiPGQmoxtjyUFA#_u3Wr`bUUMRh3=MVCD6ISX+NAa}q64TD^+t zfD6naDU)7+IV6J;1`sENJdjE!+aRmTXiR&`RG~JIzT>Ct7y}5yZh{)=#RQmRIz7^~ z$^c%bz^EDUkM*bQ1g13Nt0$)Y`5P=H7I346E*V(3#l*jIom3L700#0cPYslae@lqW zt#J1d7tFTf^g;fub}P !1_Z0+S4w zJT0m)8YWSUp}mj1X3(unmDx#LE7W}*Bt4LQyVIcbhcz?33v+^M9A2BRmZ1l8mz+Q2 z?vUKAdFt(l;tyH0-w19(el{h`81Sca5d0FUthjYJXXw58jj;>S3w(8N(-r^DxBm>2 zBM1RZ6E?;GKOR{nn4r-w7 w4Se|6h}>g&1-96!kv<3tcD zDL7K+Cw#|o&46cYcUnfxadu*+jrRzXd* {PO;=o4;-5d~S67wQ&^3p;9QcV~&Y%p{SUQwenH@8)dw5*;DFy zl@F(dsETe(PeyD2{Hl#1NZ9`$8 aY{b_Epem@C+nSMp#b>X+SXUy%*zK^~LcK`kdRh5h7g3$G- z4k4P=W78Sruy<)Nk$vcK`YfDJos=4BTzcE<3wmB2?Wcfje(^Q^5Z@$LZVo94)A$<6 z4^PUYVx+(3Yw|$;c2Ce;3H_Vngy9=!1)r%O&I*{UO&k9-`r~?#PTPT7h2Fr=x221o z5f>(Ml`NGQ2|X#WVA?Zj *E P$jJGQ$dX0O1O}&>lxO*gT{}`E z?DW-#UFQWht|)av3i;7rFteq56>a!+-0Z4(p*(K&7G0{2Ji9nQv4|gk@}xFgMkO?- z#~{@!xX-jfsV^$H5}i}6q;_lYb3XR=x11i_F|JcxgI^?e+5n^oJvBR28yfFVKTS}M zYjE=&%U2lu6l6Bxx=&R2BL36Fbiw=Y`8Rj$-)4uFpIbMHoQ=#89SYuq<$v$KE*9!0 z5vvn75jWPj(hRN2?bu2zRPfPt$huZ})!-MBo*_OsprA4r9j?HB_!^v_Fa2$sE^glm zLA1WON4@P2t$vC&K3WN-9K}{gW`TtL)*>|C@>i+7F?#CABa+|YucCJz#Z!|8N%l=m z+BJ95)0J!+WiZ8|kDKoi`tt<$OotH=T7%x%sO729eN&Xhci?FL2(6IUaA_0Q83p)# ziqiH3>&)U%!hxm3_nL;Q5AWO*sC`lKWk&1gW3FbpFuF}2-bR^=2^_OuenN@r2};AC zewMh 6% B07 zzR2=DRdZzC?Csb2`*j9BwS%{*tK`2_1)Q=tm4p12fWHI7Ne`YrROL&4$&`0QKji%) zvBQCY+GHZewfwL%CF;*SFYnI^ocFwfqO5 1w-RyODH>w#u^aNMEQ)e}FQ3S5~n zO=B~d1Xgd09KX0hjq9+dS4$x$K4--la9oUm*Uf*kd*-KSN3d+2%05u`=dYc7m2P{e zCKSgTo_Uft%6kz`b^vo(u(Xw=oenyCmPKQB!hRCZH~P`Ldg+g=_xIbJB5?FZoURGk zUB4kutl&`3+^d9FDef=71i$n^wD(GAvY^gL!3Ucc_7RvJ{gkgMXGeV1uCBYvEpAaW z(uF`OMaOR$jzWIr-69V@ME-CIM(#4f3t&}D*p_rbFqicN_0z5yS-JL?+>^pRGwj<> z*B7E!A9U}zuPtbolzwvcro0^3)i<*D_*qdl-TUa;DFGhIsG_)Wp#rXY1)MSMdllb) zgIoyNu@gzM520#86Tv|5<_{2zKLt6aXoJ Ga#I$O1m==CrC#4`zQV3kh{ zhB!shZGrPeaueAn|3*qyv>{x9?h~>PQpDr&cg0NWjzsHH&Xj&*y*~E>SX026(7vn7 z-op@*oKEp-)m_EXrmV@n`rPz*T@01Ynl2S>uu<5=#{d!*2f!iR_-6=UEQ&w#A3fLq z>3InjP2~ZHUz0ipf>b{J$wO3CzRbN=o3L tuk0FinU?A12WM*G(#;X*H(WWRD;jwo;SzIq5V4r+=J{2eRSbS;~|4Hq1T}T z9$&0{{1h`UUw`y&bWzK%{wLe6c4?#yaTm(jARM+R`dV{D>YK;S_B5XK*HWFw&Gb3k zOMq~F{+;m_WO@!vXlu_wR7~9Iyr;jhw0f4}YwA!lz;B{b(-i;jl@a9OcCcuHl!ZeI zcp*;awrOgruGVHw2@Bx(;XRLEM=NB!apxyF0VnOUsgC4Vegsr&T!&pSua}vxbnSN0 zqtsW-r LJh15I&%GCE zm2?5G%#ZdTH7OU6Xr~>vzW#YwcslPcrRQ^T!p0rW+9rUWr%Y*2zH%!c7|kT7h!k zyZ)Fy3KLro2dO`Fe$#3C5R4lkJY!ir(|h_;Mf;<%;Lrz8^JDE~
LBC&T)fc zsMjKnR@-ou{WXf?PS@MknXbeg`T3q`nAsV0Mg!NOOQa4_wsj&jN4P;lFOfrL$?~Cf z!v`jK#Tj@wrJm#ExkAh2I|8A_OxXhowvcx$k4(Q8a0G$?7ralACSWqaFTUoPKmPTG z_4%I{o-@g|7mb`YfgE1BMC$Wi@`00Z^`5swBU(L|`L?@*0z-JF&x*K$^p!djMa&rC zKZTg4OQfW4LXS;byrou@8IgTW`8UCQ`)9-{@>j~Z`KZK?fvwL=W<|VZZ~4rnS#R+g zXR4YByV?8#qTv5it@p`m5XLx7kXFAp83@+SQ68kA0&b1)IJ`M!SbC8X1J4oBOx&A4 zQD? iD-}d*`Ac-e!<4ddKYi z$v_#oy`4M502WTEb_G_slc9Z`^E-5_AOmq?S&E*ld^axDci*Dz=(L}1X9{xaT5v61 zfmC*f_Ns}^yC50OZr_qIHlk6xcv#plp5YDqf^GH2daD0uG_{%?bbWRIBH8RCmQF!i z`5PSluv~Gp@wz)-cjZaiA9JWA*^V?w?!_%sdhIxP6IbLmZCG9$LXz)NN5%)6glJ8A zYC+3w_$_Y7JKBfa_Vs +Ce71O__QLP;h!SB;c9=ljA%En< zp$S^-T>q_9WYV!#)ZuT#(n|lXN(T+W9XNqYTCp>=%8v-Uq^Em$i+Y@R(t=1%(uE*0 z=A;c7Ko+On*Hv*fLfVh&Kp(HZ-P((s#5!1P7CgazrFkpaK;+q94ZmY+Jgsl<-xe{Z z4hMxT)#TM=OL!+oB%j{6Z~Bz}{j*)$ZJi!+_&k{Al`ho&6&g|Pf}TN|vYUS5ih=i^ zeno^}byT>D$%)shu-50+z U1*NUR3OHC&%G~^;1N}a#Ya~g%+yS;hD(eM5!)pTX$)%+#N-y z0*S*jx)2b|=i7&DqKHEsE#EO!O*T{BH=!RR6h6n1w#XGiaG2##2^KzUQAaKLIL`h1 zWyoQ9R6kuv+%xX|CmRH$ZXNXn7Wes0Z$W4h3FKzjEuN{SW7)lb{>9vd@=FU;5^oiy zwJc;<|FOD{-rOWY?%EBg|MqhlLMnB>%O&lS(|T*TIIN7;BFDgRX0*%_X3 EU5yy`t`}) zVMAIgJ0b4DUCr_rD0{n~Pfh+-=3GpE<1<~M^GM;?=8boP>jNOiKgAY9fOo8nbzpxR zqT`UR#r>TZV6#Ji)9II=AngxpNePP!=;VODhh5ZJZ=ChSWB@VNc6aDAX7?fg!Qfuv zTFGN)12CB*+72fj2jRz{l9SJW?J )dkP~U2W-m#jHnfheiLsTk*#%O4gGt_ss1k%22j+78f)s#qB+pG zw+p!2{o?1K!;yAG #Q7R(<}FvAry4$ceozjj2a7y}Ph!=pZDlsS0X z8kRJ^G3WF$zsIY=UL=>*T=98bB=<4Doy?mBK;O&$Af5Dy*fAbQ7wC5@!{mnHg^gpM zKe9c2eTQey`0VBnb*63ZO$c$tmYiKN6Kd3I$7_mv>ew!z_;*BRwl-ifOB^{`X4H1V z{902oNYdy;nY40w Rj*b&?v}6(I`})c>BHlXzX8Y<*9=Zb2 z2WJfmy>f$mb!aAt%e#IwX78wgb#F;T>Pe>NNPRN0fT}iNoj|?t;DSM5*85j&Ute9d zNNAREFMS-f-V5`VW&i@34hG-ua8vWrnljO^k=~N1nELsuF;kGHFDl@E3v)po2 zZm%lvCLhUHBX1mn~|FxFVR0bg-rgD JE~hO7@Yzsa5U(%?j>C_WqYbcLAuv6j2c z-0REB-=U9W?3CU i%n zc Vj)v(6ay|Poj&hX!?o|0%KejV$ Gy0ND 1>_1_ i6&g$bgRB#wG!7R%3ZP@~mf| zV8v{C-Q$SNp&Y$K|4pk=2H>t7JM~jan5y9IlV)V^SA91sfa5eGQL3>s2{`a>!F-cT zY!agye bshfT0*iNBGXSbbavz(UOy{K@zk*e!_NdV43io9h)_QNS` z39LV c01n_0*qrib@ZaZl)|e<` *ZtEShxqaMpqk4ZzITX_^+k|R07X91HCoL7~> zGSXmD9;O?@b0YF7_G^(lbnZa`y8(ojNf#vh&S~ozF_SZ?fU{+e+Y`;G=j9x=!(jni z&gmfpzJ=KtZkBxR;S5Jt6+ WndQ!>4I|lFU7fE(feShf?$AmWvFudJ}>n6T< Qm zh%fCXx@T+ZusImPPuxU53!E4e_2V F%7EXqn>e{Q~Y$K*bT2O0@#@ zVq2rj|8OF%<_uZa9&QvZ!z(U87M=YMb;*6mQ;}x|s})u`YilgV>^QI9$)DKd#QsqA zasBHBo3cOln=FlwuU-0h?FaIER_9M7a15kl=eaz KdT~M*PTM#RDavA0o^F$Rg!GSlts*<^xZUn#kzjC(F ze|sl5E#?A@`Fn?wafV}l$>J7^=W*AOy>$*#F MAg4R?L6k)t78qZ zi;G(=-nNsll-&-4;d1(&_b^-`f(<#aTFL CnyG%U+BI98z27p OF=KKF^*Qu=S$N1IjI{rtuqn^=Wk z_sA`T&kqj}N<)2fm{osnT3(ejIhXJk>^d_nD&`-;4JFfDjOD+q%7h?OHQ#-H zFyHCS_roVAd^V*$=vaU#&w+-Yae$B3EK9kB{%8#SR<2C$rGu==oahcE(+umba*yJ_ zO8jEx8CUrDj(kH0{?fI%C|=~vKa@69_ BHH-SaYae6=QkW~m}?b`GSLjLQ3 zH?;NpVu)xv)Ee?@Vsom^h1!H7upR~No+*y``vqMjJc!s~+SBM+Gtebmeg0eN2E4M$ zf7GomRS3ZvgT9onz{id2rme`lxAEOJJ1ATvNs!N;L-g|R)gXTaZi4gl`zs9h1aY!l zSA)wuU=?%;M2&Sy|E4xU^U~Zm^5>y2>9w5YGQVb%PkF>BB#1g)?y~=}_o08FE^OQe zX(^~S76&&n3H!NQx=ahtcQadFUo;!4qQy6HxBMERr1pPy=Kxb(??3MtgO4@KA5#w8 zXw!Z}j%6BZ>y2G!z6`B_8WqVN>T3LTC{Zgc+3|t_N&>QjMg^f#Fe3ERPS_GB@4R^G z*UCxn`y%?yd*V*tSrf-$eT`jiVbxCFw#&|#rLfG71=kXt5U@yHBt5l>g(_Nmcxrsg zIrg4tWOCp|m{`O2ivi<9AF1cbs|%Rq+WPF=C%l`TIVXD+2ZZpt-BZi~-OO5ATATj> z7cS(zd2w n -%GP%A4`de5I?r-_4nTPc|zM1f~PzD=Q@t^&_x033SY4dYP8+K0qi;l7AOzdXR{ zwdP;3R-T=y>zfG@>I++i+mN$J@}pI=ygSzh^i1mW-}&&Fb5+ 5%xd0Ln5 zzByvJ1Vn^9MLtx1LUF1}f2T25_ekq<5~TK0)ZD^pNIAQ8b)+zUo}au$iXq4FwRw!& z$B#*Tt&EeEcxJ92!Sr^>Y(lIL09>f*)db`5)Q!OTXUTdZSB8XS0s}ZMp4GB_p50~G z=1m(aSkZ#OzRYowLl)40eKqQd`qRLrNWc56C0SycJqN+qhm{4K=qGUQPjul>K5N1o z-_zfp2>U*8&YZE_F>BM?WZ87wsbb&lhK*uCMp8b?RPM|?Smc|4I8L+-mF}EECKuHT zqDvePT8~V#yqr<_!Ijqd$a@___D!{;Kbb%h^uZSa#G72mrp2pGlH_kVn&6#UhYHc& zg{;dAAZVQPk$<`c%@UmNP_hC8xRjl{!~m$bfJiU{%r*D{d{6W>H&|W(#+>8Dk+NVS z8~Ti{_l59qP9*ti+TLkb)!C{Nz4uer-(9Y(`05bH8guEwU_3)(w}pd&D)Ck&ZYvJM zNtLcJ`b_q28ygcO;bN_Xe0szzYE-Rn$y|e!DItOpGlE|))Z(~w{cq!I8*@GMEvqcJ z>ALBi7-R39A`-qTBM8Gbzo_!dFH9gpLArhZJ#r}8;0f)8`oso;-BYcgZQeRW7iGJX z2V%Ok0%&}c%u$8k>62 xh9G`@gLJ`w_|@Ux@T;ml?G(BXHsMD%`M GwU)>-rtfbzS#yKlk(JeLTm(ftfiNpYQU1e_q=gd;w@4%ZjM+Z!r3QCt=VM zEO1HWHz-b^$@E KJ>3GEZh~Zvj8Pu zHwtk??x40&+eNSBj*x1|_kwGhj!pt0>$JOW$fw9xDIfQ7@*%I&7oi20oAY}iwf?U? zt~uA@&&E&X rmZSLn_dqczYgH(9-0idBRVgp;Ih>$s58~Ck7jXT z@m#$&8wOTVngla4D!lJ28{*p>m}s*!i&*NU-mJ?ExerCUvcK=8TMw5Pz3Sn(j(FzT z{(unbL(>6QB>Lhoi8-ooj56K?+jy$)*qM4p3)RWe7rkAIFjZMtki3j}EbWn7RU11p zN%vgqb9DZP&+#-w#-ZUG(vZ%^7dQ1@JF@~!a+h~o*bP&SGYhU`hZ Em^!xX=KNLcKteg~{(z5~Oq{_?bHhKLAM8Jr2uqo+1@Szjg9!{Am(oY&<3 zB7H2m2ZR4vQ(fufTy<{v7#VOPem{xSM#6rb=Yl$wlnLBz7^e!%4;DxB1djfC`7Tz* zj5!wMc+SAm)I`g$Ef@S_OJGam?zo1|-TK7MDL)p$3YCCalUt<)Vc_DD!xI6L64`c) zOtWMs&A9r~i*?JlfQL<0{9>l%wBR~4B1#16*dUS4OYzAC_Y6(x?Vzbj0TtO*l}{XC z&WUr1kaA;0+lG>v+mwAyw&$5{cjo6J2lq_#54cajq`PjF8z}x}vw9C6V>$xFO(aQz zdddPTYZdr;ar}Tq*{058c-~G&9_7X+OMOAp^cS8B<3mg *5 zw&0n{(5`YkA+_CNVO%->VyjHlEXK9EIc70ai4QCoT5{zy91!7l)hg&O2>F|){d%zJ z%y!Th%e$vvYmJ?HL32V2h@aqux=x|H?mIW #HW(mHLi68p@IOu#{QYJ8%e4+DfZSR?AF=9q*zTbT$Ye_W9}k;HoI$g z{6*5&_iN+UwU{j};q!~=Y5m3359ScA4Q!@hLVJo }i7eX#WT|v^3 zEgx&Q?DgiP-o`AP_)7ZqZF{=|wA8;yCZWHFDgA`HxPv4R(aMbQvi@7G?K3@_Gc4aH z7Op04_r8L?%~7T_XkKy$OQXY|mAz22I2KC|`LN(S_J%T{Ic>KELm-BO&JM+BG7{0o zl_v7bwl{{+C4c;Z>f ZYsFGsV2)=%NZN;}B5E7^V@>qZxko|K(zlWZmmN;6Vyx5$ zK2SDb!w(Le-r!J^nxINTUHlEK)#%~Cv`-7;0|W5-&!61{%n}_V2ZV1knx3NGKy|^~ z+D1M?MX^=3{Hpt8PxI54;|e)VablX G4TSYvT*kK}6ut|_F^M^Y%By*BKJCStZ9+D-%+J+nt%=;`;0?=c2kV&Qm-3(*U z5K!k_(|?1qtn|U#gZRr_ZFrBk;7Sgp#RITL|C=!=-AjGxp2JVXs=Zq#fV|>f{p=Ep zmJ_Mc>C{qjw !5HsAv_gEC5fg4J)3_8BzIcNUq$&-0xhGKQ$2 zaf8M|)ayq5?NLJK1CQD{&ZfE^)dnU{R^(Qm#i r5WXx(Sm=b}2Y-BkTTuK5<`&eyN2p*hZo$9KT z1BZu$pWWxrAYJVxi|vHi3486HuYRqn^v?37v`paDux$`_0CbMYxh6kBbl~h%VwGjq z|JLBV^Ui?A Ob5j$>6X%s3n;TCz-HLVpeC+ p#N#-844KAT&!e%B38#)u1EP&TjwjA(tFC-=elC_~1x;1wZ}g2< zg3xB%&gOR6ZZT~8`6;IR%02(uq6+FLGtWX)tOhOu;oBp%1f6K{C77535YX)`pa;@{ zIZ;Oks}}rYruj~!d)J Lnw|? zU_F_ddL7mT%C0Yy+iz1yfvR6c !H>2fB*WXN z&=InY@A<|gqsIMs$#$Ez8QJqoEg=B5&C&_@)la!)p!iENcz{foL<7jnVm%xRboGU6 zR61QM7qZivh@`$Y8X_~qktJz=#h!#8q4DZ-GDN$}c~0za_B%(JR_^n;L`aFXg|OG5 zj@`!*yhQ3e&MW*G*~!i2NN(=N=ZpTV11kom%V@zR#r3-}S9zCdQX(1D!&pEFN9tDy zS81~Z&S;2ktM1HPFYK|qmG7Q@K>ibR6B9+*+_Z%L{1U4T9Cl@`7c*I6jek?*heOd! z0-Kbma=yaUIc^!nG+~1Yd0WBvVKfu+0w7PtR(|UEoGd;eo?ky^2isLE2de1X !06^_0WtpfS8NX8~6$%i%~hV%=LDVTJf&xk1)(>6}J$nErF z?rd$RpgZxM7#)HUFcR<+FiDNfR58GBo~`s#{p wp6n4RXfgvm?O3uJc5@H`CBGzUmy<6N;F+~)f+@tHRWIecO;I9t ztvk4L4*lw`zXZwsH1yt&G)5QKcHUpBCf-2D@IS!%_MBnb63Qy*1%2hL1Wj!A&*T6q z<^%bv8fm=&gVA4p_IK~sRaYf^%(POumnduV7NvF0eT9Uc7F`s)uxc$jR{j7vaHICV z6R|lKQwC1~^rCMFJY6Iu{Az|9B_UVNLhPZ-$*)`QUrB-Ttkkzn^`56b52aZ{2pUo^ z!(FQ~d1G!;8$BmY@ZI9=+3mf?pWaiXj!Kys0k+kYDDgQU5QX!K^O&02+Y}W>d0b6T zRVnD%$Tqq9UGu)VJXb2>h<+BZJ{RpI0LcAo tIf?!Hxq?JCe^P>Qf@GusHKG}unT1sXizViA+ z^f7cCb;;PVHJ(bfRgV89TO~*yK3LoR2$t}MpgTXIp~MtCV3;PpTT~l5zZDbx>)g5v zt&A6tXHt@W)2FEJd2+zzP&rL7*>tp3DnN6(OR3#sH^0kjyx({E`#A%lyFH{n6VniF z^aFIC$(aB&(A`!=bsHt9b(zZB_KMVw Ev;T Au1kC7+c>7M-d}p2?yS)+7z_F#!Bw-byu(dEcEK1N@W1l%w9k$_ f=nnFq=F2-#l#9ihEnFyCzPr r^-zNYz&af`F0PhE={p2Vhnof z2LI(hw={B7ygkc#e#z6u7kKP@ty_Z>_MHx>p->H^2aMKG@KmPDNTp71iF9)(3NI@y z-h^_>FgFRwZwZpwP9n%pcWJWz21U@DzC}J=XN0Fa?HfHd=^xlyxiInkhsPnhSlwbV zTcAf1(t;m~7U5Cik8n}7%4=Wm4~n!1EAU~Uvs&)$f<#iG6cpKpwZut%%a!?~5sR&O z$NK|A@I}G+Z~^a5mA*j`qQ?vdB1# CLPgFCUCW2#)GDz zX^BOlmWDS|8n<_P$yG+qmeBablG7jb<9;WcBty7f9L}l>CaRUT6J#0v@%vcs{ }H@98hy|1e6&mKeW zQ!+8G_P2&i>xfZ=bHiovylEyCe>xhk73|C4)2Vdo%fB8m05T{4_9QxJ=TH>C#OpQQ z#z=_L8nsozNi2WfUFk;%2z|eRmK%T@1j(+YMB2sjBHSRT_h^f!PV%xnA$k9Utn)&$ zwg!V{WT-}DEK#5nReKnnVmY%~7R4dvIcDI)_t@cSd9l20ez=Dz|Tl>_6C)!;trt z8oUOr(OX&&OZH51Dy8(-GYR5ovGkjHc>%RQ>ZgB$G@4sxNGH$2H)4-X_^4B-?luD+ z3qlv1UQFJvAA5Q2i2+nLR@revv$U0(z7Or#jTDw9t}IzKW86MgrY?T`=_m1BcI{g1 z#FX>%HnZaZv9o7Fp?4AIbgn_^n+4Ot1W59e4b&|WX9@$_=CkF?H%Aw~Oxone&dpv> z84h5KK2t2*#@O_4T5Jdqg7~YcI_zlN&gf4tbAR6^5EB4#X1QX$0S2l)3} zb=y4V*Iap7&Z~WRq`}A@OO4P|^GAO}Pd6 Td4PRa=@JMx0?B)W;pg6JBQZ^wb-Qh9rs z8y`py)uFEDKxbiHM`!@{0~1|4sobNE!(?R#)@(P1j_xybW<+MIwT39pkq;-b1MiME z00#T?*=3`fIfquxL{yeA4bbXo2UR>IYmYmPYJ;-ewbxX>{#s*HIiMG=IUI^WIwQm8 z*zY~579T5TOTFCc &AD5ow z&bMo h6$Ur?p=e*(IT&TDJT*=Y@=dwT^>ZQg=EbqCHz zBV@?(_|IxUf?Gw+9(C{C&lzMJr!OL%=ut*C1Uk}~$a%JaNGjP?#K?O^e}RF!hv(~c zL$O1Nvf%0f-7@!kq?FbBLl4Jrh^7Ga(E`! wAMLgY`U=pE!A(9NhzL;_U#pF)qDC?LkiuBy0nnGEXyK j;8$?^yZ}8x$aa%K zp3ga210H!L@bUXJAZ3PUI|Tm=#t%1-vuTXG=@psoIe}^%6)crH37ebNx20rEf7RI9 z6PD_^@-tB=BpdeW*hWY|`)9~Yb=spsM+~jHJ{3Djb1G2$8XiaD0%R$-fz{vUmY^oa zw$@QTXL+@u*_mBs9HDA`?kkXqEb8scb%0O#BjiN*DtY;I>0rD{QCbsg`K4i0hR3gE z#)LWcJY7LtO{gf{W8t4g#-7zQi&S>NZp9S w%k<2h!w+>yb2qEu2IPVte@~5=8Ln)EqyrL4i}*x_J~83* 4Myz0aXuO 4nz5=hg3*g^?YJfQduS^ZC0>JN( z`QIQR^j)G`InbfZaaLo=_J!T~4`E YvOQ@0$8YLa55~JMu5}Ged+7Jv zGy{C(dI@5Ng|BKkvr8SFXK0pPRlfFG*ux^!#Xkf al6jBhN~U2yy?8W> zl!C|C1y|Y{zduds)o>7IBC D>8q~vT|xutW^w^>fMZMpJ*)V$?9bC1m3|j2xRdY+ogV;(Pm6m0tKDCRgk>q? zTm87AO&Y0b0XC+SNxBMzqynW4&O&s?pFVEJA0 7yPzagCq_Y{;_O>zV^%S9cR(B>$P{k zK|XT)DcfzarxJHZZ%~ALq}2@TV!w~f-s)wR6ionaf362v7XFv)X&N9q@I*qf6RwN5 z%p!o>Gtm)>x|HwvOd=X1+TVP@FCOuRNHy9%2)okbDScb*gJ9r?QD_d+5M4m(P>*>4 zXi_~BXCIPmS1g`+Yg!w!os)a&3q#L?psr9>IAjDjwrj4T)3q RSgKS>Ypz`=#3UF{Na1YouGsww2| z3_f$7*XqAPe75Az#CJfDp(!zQ-)?TcHlwgrtGddG!$TtfiyA7iK}9pH6)CECdY37p zP<${ZyE)?oB_2I=(+sABh!5yJI&Ml?sl`h~MLgfT5WFEzT%sRH3Jn5&G5Rgph-5-` zCulDE;rOoMymj;+mv|Nk201qcVP64vdD1gY #Y|G47YA@iO*6 zTJSjJ)QSb(OJe`)c;mn%dN4&W{Z+-OHLKf`a%gW?5-l<2N4oQ9d7o&prjZW|=p~oc zGIG1U wU=j`dyF;-yoteK(;DCN9U#ZyHbxBiBFfsx3qW^Kh+q8T>s^9Vb=4IE@rUV z9Qr0n5+dTV-LM(7q%sIs#P=RK5#D3iVLbqDxm?#|@KHI()@Y6LaVN`Lp5^nID{Ef9 z)lQzeAs0z{gVJ^FUR)-$)jnc!Z2jHV2&!g;kJJ)sV%mIcmn!+xd;Xz`FaP@f7aMVZ zP+XihD{l%a2t-k&<*NRP?#E2^TK_CXDA>0<2WvDk3Fy?S+VH4&dNTl0s;64Mu*4w$ z4nWdNZ#TMA;~{nTXZf?;ONM#rULdPP?FQ>}Z-upgT_q5MNybn3nj(j~E-WI~<~9wH zgo#2& D_JfZ~i z`y-ci^4(|4E+@+yCmM!nf#Ck;+%3@pfLZ1AFBV6NuzwUiUK0#Wcz7*rgu2R*iW3y` z?Tc0LU{_((5=?cwL{uZ`-HDTKP5Jz%Hv`l|J6TWPW}WtkBbFQjVTHPR& -FiC&>7^ylIbe=JW8#B*LUkF0fnmv4u z$Kv#(5LW`8LOF;*dgtqV)#V= t+8ozWAtCFjYPc_^$2?8BtN}{ESD1ucd zgt1zoG$2eb)y>ez-MS$Yr# iS`bl*y85OXM`QvF{Tg9pWdW z2ws51HW>FYpOTO_pouy=EX9f{pqKvwolA!XUp(vqddtV=ZqQ0}PYXf;8T?V#S*&@L zx F&)K?nQ)&}43DX0jzo)_MPv+ScF?F(r&pe!(llx;!E4|oiSMPYsa}j!;&~5<< z^Q^Df_FNE8iKLW#I_Q*n@;$i1l?QwgTGmm~JQgfGF)RY5lEZ zWo0ujQy=S`a7gXhb-^xB72(f*&g+djH@tY4IH+L6T=Cg8VH=|WwoIudv8LBKkuJfV z#yrM<;YK=o^p|^|`@N&b_S^6PIgr4~JYY9%v`(H@UCM^G2+%{`D*9lA{ZOx9f(Twi zN}YSr_ZY7zW!pQew{rdZdn`@qKN;tbxA+4Y^uA>q{-#e*oXgaa(Z?Ovb7RkT2`;_J z3rNv0pb1@D%>|ESUQN%QEu)lL)%uj;@8&I~Fg$+oh{0JjjA8xh4v3p8Rm`wu;%egZ zf>i|EpZL=OAAGAWAeX0cwb=ez*~B8~j#uU_)roh4tk%gYe9mN?9VP( Xi04FV}{5MGEcGBdpEPtvRCwTlz5mAVwO}?42bE=sY2u>gIxU`0(s&bw; z`2n2g&BHKR>HtEKfQlz`fBw3Z^~v*)MZr8+YxIvx>1GZwNA-*ikF59fLfkht|J!j5 z|A^bWq6Eydu%KOoOerimquVj(6uXNPLy#7;+!5fu8Tvsf) VM#4p7lAKZ$=&@bEe^3=2biBMs0y zOc=g*t)SI#-~Wtxw)jk%cd?~fF>QXJkG!i=Vt#*CxpD%aPD$abSpW_E>aWb%|LSG8 zLX#mx85|})2tRUgk90komNpQ+dB?jbccY
b9TjX+vN>t0Iit*fB5d4;y{@k@$1O68fl z&yq$e$;{q(tmUzgE|=AyaQnX1&XZ{by}R)K1T{krwE??9?V>{o1SAWOp9cz`ZiqO^ z4n?fInS0n!3AiUVV5*2NMuBeXWz9FTfFNO>S_49)S&0Jp*$F$`5ZF;p4^cRNV*m4z zF`5(D-)@N)f4L Rr^cw^PYVN$d79<5$V?us2&w*?J8q)#-c5^-qvwZglW~&1WfYQ 2w_(UfhI~(|JY%oHcKN9%zx0orH)|s{!qXF8}tB>+zk_e+jfN& zIo;TT+6HX>HjHaRfB^=;Mw*RaMe&XUsJLDjFQOZ^cV7{R69uT?LZva9( ^Bzxpn_hvxMDPXb;yab95rW%?=K({%a0b
v`VtnL{m+kDh1o)%pC2FpHAm7Ib1&24I<2f&Gtb2ZSg9MRgb z8znI=!@G}V9Q==6!k&C5a8@qRo65(_)t`Bu?0$IK?6uVUx(x1s&+oeVcwN|9I_KAX ze0t6VpJ|eB^{1oDp|VI Zs9h6L+)47i?OfLa@|ZLD=Id1Vmgc+4OesCc>c@TH zcnP}iyP>#UhLsUZHWleM$c<7ug9PGrU`KHV{gV+ycBTN}({c^5m)g_~9ELC0ZM`(W z6;J{_U4l@ Fj-bpfnD-FZ#@oV=8 z0@I(}g@x;a>#yJQKwiJtXfSc;>`WIL#Kv%Z8q^A-6eC2}=eUT^i;2#w98amDugV XQOFbL7JA7M0MT3BDhK84`t+sElYE+JA~Eg 1<9= Rxu(PJI&hjbFGFw)cfTyUX 8@gC$BP$Ds}Q4Gz}_>w=8 z?haqHK^K@El^knbu7CbjY`;d~*i?{a)5oN2_#a)kS!+w(XqiiEbF!|NXM$MbEIZo; z7tJ R6q2#0%e~YDpnpxifcVwPy^j`XgRh-mNLLb)VAx6;mvdI!-+=l5X(b{QlL> zy&E(Ho^3C}P2AD=?joY%%F8ba<=cEQDxtDAj$gOq+y4x@uw8SUjgW`)VH)wSy #FYxD3M)m%hIk7`%C-E{xDFYs|q?aq`-C zrQR&eZbjw0_5vtmviAKsDG9^z1COAIhfgx_u(lvWudDr-%v7m|p;84;6TfqvgL7!L zi!u9J{)>L;U$snsegBWxw|@*Jqg62dQt|Uz(dZ#B2X1PS*+*Z5IWb;Vn?8&Ihml2S zB6@=&BaSt%5zUz9+?K1;+``{E3|Y`0V<~C-G^767r#WD4t#-5(&D-(zv9%9Wd%oV7 zj?pgtHy@x`{`xUfSMKHKUHmhiP2W^k3G+--j`~fQhFD(t^m*3}dw6)<^b6kPQ)38C zFNS4z3M1Ppf%yI##6i6 &I4(dJ2%(n>X_%z5BiN7+cgkUg4eI58;FGUr@D92TpVU4CjU|2l>;9s1KFD z;Nf_wBU^$=_xU_5TnD3vpYvU9#095J14AYtyEz&(=<#`aqH<>r?GKM;#YK0qiZ3+} zu8J(@Z1 0 S)lQ4?)}97~Zq#Hy~HoHu|2urvXtO$Dmqp6)Jm>jI9O6rpl`K>e_HiTKaR=#ujPc%Yb;px@pK9)}0x)Jz!H z*?Kh&*WRs^ma>E2B#1gE(XLoL1g>3XBq53VsuoJ*gb*!Z+du&oAMHdL%~M1(F^?o^ z_e`jORcr}Dp?!wLUxwoI|NTewc)uhqKB{L9JWi%Bp9`?-GApn;dJ?Id&=srR&06o! zdrRtQsU5Z_@dS3b2$cK}Z!lM&PrXj0sp7zf{3Yu2Zu)7%!q5p95FCrI0jjx?maxPB zP`!U1A$OzzDqW`=cGw|9fe67JNZiP;_kKdRnV{4^r%mZMg%EeBSlFH|u!4V=f-0-! zp&dWqe=FaoHUs8xQ~LGap!VGpaZNTrv~Qe2jj{bJ1r&&By$!zz^g>xO6pos8AhJqv ziQa%LQD!8;@J3m@HSDtD2zCfs-J7t}EPyY$7kc{w$xd%{CplzkjoD!0z~ET$;5&T~ zh{$tf^%}M08d;lk==P#_UAho5j(H@tFLiVcUtKqM^Cua06;idKgml#t1r5Du!-w1# zeWXv)Ay5 FOK8=qb0yNAr_FO%YeOY zQo7iX`9_E8?p}qeCrD#@5!;DjCClNLHTm(|`U4iL#!?Fd>b;Svw?KR<|CnHTriBsE zckKBd0j*f-FmTi!DxtoX9C$6PkyB-80gXA+9RTUE-r7(5A6~IeyJj*S{0+dRgBS4_ zUy{A=A?XH@T8*mUizhO5Nxkn=bV3~?hFGDd?sb$hxaR46`_{mO(>_GEA?%p8yzP#s z{_tZ7JQ{^@L|&Mhbbly<_ZZm8FgHL;V?VBjUy(dkvoR2keL{N>^w0Sz;B@FS-3%(% z{tca9n->~Rg*s1g&Amtb&~;6D6C9@vq^)# }i+nse{G#CFo+xA_<~CKhOBl7Tg3#vzjy9aUWkE~cbx{a8u??HP;^(lwi7 ztp_4dZCGG(!*1wuq0jv69!;{6#){<4WAsELXcf>LXwdd#s-M!wl$uj}7T|>X_0D`L zX8AAM{n~&>29S3-z9WD2Y^EtMdefC&Gj@;Me8AZQhFI?ddA{N1?3p04-?-*EvJWv( zZIjK7=jp|@9bi|jLkzqo4vCFjhjWw6;g)z0+_XE;cy{Qx@-bcG8K_-+{?qc2a>)zQ zB4GnoN^)PCpXyxnkE%h^J%nqA%}O6;^Y-k@7oQ8T)zveEwuDaqc}VA!2c*|T0LkM_ zo%5V<1#qmo>P0zKxdF}vBf65~{Nyv QS5)6s+h z884+N>tS!`Y ?Y0ujgI-+VJJlC6f|8?S!gdHGt$8;{Xxbh#w75wyGU}G{BY??jb9rMHP42 zO+U5T+`SDJK0M%kuqzOC?4(vcWHC^d@uP(B2brA!ZbQe-@ytyPy_QLpY?r=fc0vff zx$ @ 6I3Y^ZAN`RM?6U!fzFXJlu8@@Msqn#o9+`M{mp#Bqk+0YL+H z&=J!sTQQepLhSi=^gI8cDlD+8HkJM7R9D&+^fW!ypu3lXv_-D#H7gtm-f^eOJflpz zUp#5XEW)&?KZAs8aNyU{MNckpZd$n *~*c&uW+p?nh3#QjLnDG&BAl+t6mU8nsOVrb^X?Q>CBeh!CJr z;$KF~bg@|q#-5At0XNYH(JL-1Z&}YGGX9<|O22fxV+>S5Oc& 2_ALFu`VM zDY0bHBw8K1xMkTg=H-~+@cQ;ApUQmpjH~&fCSq0t(8~D^nP@5<@inr9*m*1MPG#2Z zOuCen*X`*=uflMuf_rQ^dMbcN vGWT@_3I<=9^ddKpqr;F&QZ-g#1JeK6UxYmK9Bg>9X4RpLb zbH#;eZPoxj@UDM3VNclmyqIK1{Ck=-EjIWiqFWL9D&8LbdV9k7dBex1_soIE;Dn(> zAlyp-SZhde^G|@FOaQ{}$Zv*#HV^v*bC7zK5)GtoE%Uhn@Jc?w&g&vv;J!p2f^Rp5 zi)?}>`gZ!vZ5&tT>qzFz(!Q=sV5RLZ)pQ2tVSNmG;l-WMEPqB=W!OMvlZ$pFRLb2} zGaJ3(Z>v<{S!ZcgQEl-Dxg @7c zTOs^po}*d92{+4zlPc^=;mingUc@2Zf7*SO5-G@Tzk>)u3~4eE*YNp0<`Yln__Ldp zOJt2I>y*By7&MFb(?~k4PuPEpc%FU+b8G$oL;r!>ozh=e1I{r4vMK5zz9?#C?8Z@b zZT#)ZcHX&Hbu5(zWxYZrJD5m*m;4W8W&Fpu60PyNDMXr+*K 5q*s)T{-fi(= z`h_4r+uC=oWo~u0cMEwlM~<$DyaVec>R#<6oWlr^?HAEJa2jG7|CCy%Z{fG=FSaV> zGuX~}-<4&xT22q~XN|Dh5f9w$l8J2PBxdE+zQ#4dZps?z$P1;ltr3^tf)G<}%k{QI zN!nLLBf@d}Jm 1tRtz^_7oEF^Uu4HwO7AH^)ej`hEz(m{xhd%{lx zBh?d@vK!Wf>(PDKG0amKC+$;#ZVtYi5sA17gg71YQ+J3B=^FV4F>YZ{r_1&?C~13c z+~c9Zz39Q4Lu$OXx2F56*fgKVqaL0Gg{1aZUeV{XXs1usM)#uQp J@ zQCB;s!{ix6lC{)(7kYb9EO4`togIlBH86)>_|Apv)BduH1>x$A!u6XC?b{|l(oZ~= z(3AANRG~(9^x3|gOJ6MKd6~aF?4en@7+b==S$ZDYAr%*O4+!T~3O9MxY;vajTu&@+ z>B%;YGaUTr3GHDml{#u%lS(qnQL}q;uR67Vk)6v=JPQOaL%1SF<{{6>i2Qb8mh@?z zx~y*v^((q#6_jpM #Hx@B3zI*^0zAf&aAy{Z zfh+eBhlbRaOgO;xKTKPcnnrR4oCX{wSe`+%lsZv3cpk6~CNq~8<%vkD?}(iN-9SFx zjmXes!p~6UunV%AQ>W#9Gw>g_tXd*_{5p{i6K93A(O&>$K{Zb10@2I#=k5z8joWkf zMkXO?)91O21-QZeoK0s9L=KM2b1*kGZz2XU?9JLs6sO)Cc56k+nk%C391Q `tS=AOVrs<-&rE1C@w2 MH9v+i1R-@)KwR!9o&m1ua9YemH6Z$@y10x zHSlude?Rpb2yy$jF7bc)ocRC0%!mKK6P^FJeWm^P;pBhopZ>SK$A5i4wExej&;RxB M^{@9$`+M^L0O^5dF8}}l literal 0 HcmV?d00001 diff --git a/src/components/SettingsModals.tsx b/src/components/SettingsModals.tsx new file mode 100644 index 0000000..c850351 --- /dev/null +++ b/src/components/SettingsModals.tsx @@ -0,0 +1,201 @@ +import React, { useEffect, useState } from "react"; +import { X } from "lucide-react"; + +type Theme = "system" | "light" | "dark"; + +export interface AppSettings { + theme: Theme; + compactMode: boolean; +} + +const STORAGE_KEY = "water_project_settings_v1"; + +export const defaultSettings: AppSettings = { + theme: "system", + compactMode: false, +}; + +export const loadSettings = (): AppSettings => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return defaultSettings; + const parsed = JSON.parse(raw) as Partial ; + return { + theme: parsed.theme ?? defaultSettings.theme, + compactMode: parsed.compactMode ?? defaultSettings.compactMode, + }; + } catch { + return defaultSettings; + } +}; + +export const saveSettings = (s: AppSettings) => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); +}; + +const applyTheme = (theme: Theme) => { + const root = document.documentElement; + root.classList.remove("dark"); + + if (theme === "dark") root.classList.add("dark"); + + if (theme === "system") { + const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)")?.matches; + if (prefersDark) root.classList.add("dark"); + } +}; + +export default function SettingsModal({ + open, + onClose, + settings, + setSettings, +}: { + open: boolean; + onClose: () => void; + settings: AppSettings; + setSettings: (s: AppSettings) => void; +}) { + const [local, setLocal] = useState (settings); + + useEffect(() => { + if (open) setLocal(settings); + }, [open, settings]); + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + if (open) window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [open, onClose]); + + if (!open) return null; + + const onSave = () => { + setSettings(local); + saveSettings(local); + applyTheme(local.theme); + onClose(); + }; + + const onReset = () => setLocal(defaultSettings); + + return ( + + {/* Overlay */} + + + {/* Panel */} ++ ); +} diff --git a/src/components/layout/TopMenu.tsx b/src/components/layout/TopMenu.tsx index d1fa31c..881a36b 100644 --- a/src/components/layout/TopMenu.tsx +++ b/src/components/layout/TopMenu.tsx @@ -1,16 +1,65 @@ -import React from "react"; -import { Bell, User, Settings } from "lucide-react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { Bell, User, LogOut } from "lucide-react"; interface TopMenuProps { page: string; subPage: string; setSubPage: (subPage: string) => void; + + userName?: string; + userEmail?: string; + avatarUrl?: string | null; + + onLogout?: () => void; + onOpenProfile?: () => void; } -const TopMenu: React.FC++++ +Configuración
+ ++ {/* Tema */} ++ +++ + {/* Compact mode */} +Tema
++ {([ + { key: "system", label: "Sistema" }, + { key: "light", label: "Claro" }, + { key: "dark", label: "Oscuro" }, + ] as const).map((t) => { + const active = local.theme === t.key; + return ( + + ); + })} ++++++ + +Modo compacto
++ Reduce paddings/espaciado en tablas y tarjetas. +
++ + +++ + ++= ({ page, subPage, setSubPage }) => { +const TopMenu: React.FC = ({ + page, + subPage, + setSubPage, + + userName = "Usuario", + userEmail, + avatarUrl = null, + + onLogout, + onOpenProfile, +}) => { + const [openUserMenu, setOpenUserMenu] = useState(false); + + const menuRef = useRef (null); + + const initials = useMemo(() => { + const parts = (userName || "").trim().split(/\s+/).filter(Boolean); + const a = parts[0]?.[0] ?? "U"; + const b = parts[1]?.[0] ?? ""; + return (a + b).toUpperCase(); + }, [userName]); + + // Cerrar al click afuera + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (!openUserMenu) return; + const el = menuRef.current; + if (el && !el.contains(e.target as Node)) setOpenUserMenu(false); + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [openUserMenu]); + + // Cerrar con ESC + useEffect(() => { + function handleEsc(e: KeyboardEvent) { + if (e.key === "Escape") setOpenUserMenu(false); + } + document.addEventListener("keydown", handleEsc); + return () => document.removeEventListener("keydown", handleEsc); + }, []); + return ( = ({ page, subPage, setSubPage }) => { - + {/* USER MENU */} + ); }; +function MenuItem({ + label, + onClick, + disabled, + tone = "default", + left, +}: { + label: string; + onClick: () => void; + disabled?: boolean; + tone?: "default" | "danger"; + left?: React.ReactNode; +}) { + return ( + + ); +} + export default TopMenu; + diff --git a/src/components/layout/common/ConfirmModal.tsx b/src/components/layout/common/ConfirmModal.tsx index e69de29..042679a 100644 --- a/src/components/layout/common/ConfirmModal.tsx +++ b/src/components/layout/common/ConfirmModal.tsx @@ -0,0 +1,99 @@ +import React, { useEffect, useRef } from "react"; + +export default function ConfirmModal({ + open, + title = "Confirmar", + message = "¿Estás seguro?", + confirmText = "Confirmar", + cancelText = "Cancelar", + danger = false, + loading = false, + onConfirm, + onClose, +}: { + open: boolean; + title?: string; + message?: string; + confirmText?: string; + cancelText?: string; + danger?: boolean; + loading?: boolean; + onConfirm: () => void | Promise+ --+ {openUserMenu && ( + + {/* Header usuario */} ++ )}++ + {/* Items (solo 2) */} ++++ {avatarUrl ? ( ++ ++ ) : ( + + {initials} + + )} +
+++ {userName} ++ {userEmail ? ( ++ {userEmail} ++ ) : ( +—+ )} +; + onClose: () => void; +}) { + const panelRef = useRef (null); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open, onClose]); + + useEffect(() => { + if (!open) return; + // enfoque inicial para accesibilidad + const t = setTimeout(() => panelRef.current?.focus(), 0); + return () => clearTimeout(t); + }, [open]); + + if (!open) return null; + + return ( + + {/* Backdrop */} + + + {/* Panel */} ++ ); +} diff --git a/src/components/layout/common/ProfileModal.tsx b/src/components/layout/common/ProfileModal.tsx new file mode 100644 index 0000000..44c70fa --- /dev/null +++ b/src/components/layout/common/ProfileModal.tsx @@ -0,0 +1,284 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; + +type ProfileForm = { + name: string; + email: string; + organismName?: string; // "Empresa" / "Organismo" (CESPT, etc.) +}; + +export default function ProfileModal({ + open, + loading = false, + initial, + avatarUrl = null, + onClose, + onSave, + onUploadAvatar, +}: { + open: boolean; + loading?: boolean; + initial: ProfileForm; + avatarUrl?: string | null; + onClose: () => void; + onSave: (next: ProfileForm) => void | Promise++++++ +{title}+++ +{message}
++ + + ++; + onUploadAvatar?: (file: File) => void | Promise ; +}) { + const [name, setName] = useState(initial?.name ?? ""); + const [email, setEmail] = useState(initial?.email ?? ""); + const [organismName, setOrganismName] = useState(initial?.organismName ?? ""); + + // Avatar preview local (si el usuario selecciona imagen) + const [localAvatar, setLocalAvatar] = useState (null); + const lastPreviewUrlRef = useRef (null); + const fileInputRef = useRef (null); + + // Mantener el form sincronizado cuando se abre o cambia initial + useEffect(() => { + if (!open) return; + setName(initial?.name ?? ""); + setEmail(initial?.email ?? ""); + setOrganismName(initial?.organismName ?? ""); + setLocalAvatar(null); + }, [open, initial?.name, initial?.email, initial?.organismName]); + + // Cerrar con ESC + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open, onClose]); + + // Limpieza de object URLs + useEffect(() => { + return () => { + if (lastPreviewUrlRef.current) URL.revokeObjectURL(lastPreviewUrlRef.current); + }; + }, []); + + const initials = useMemo(() => { + const parts = (name || "").trim().split(/\s+/).filter(Boolean); + const a = parts[0]?.[0] ?? "U"; + const b = parts[1]?.[0] ?? ""; + return (a + b).toUpperCase(); + }, [name]); + + const computedAvatarSrc = useMemo(() => { + const src = localAvatar ?? avatarUrl; + if (!src) return null; + if (src.startsWith("blob:")) return src; + // cache-bust por si el backend mantiene la misma URL + const sep = src.includes("?") ? "&" : "?"; + return `${src}${sep}t=${Date.now()}`; + }, [localAvatar, avatarUrl]); + + const triggerFilePicker = () => { + if (!onUploadAvatar) return; + fileInputRef.current?.click(); + }; + + const handleFileChange = async (e: React.ChangeEvent ) => { + const file = e.target.files?.[0]; + if (!file) return; + + const isImage = file.type.startsWith("image/"); + const maxMb = 5; + const sizeOk = file.size <= maxMb * 1024 * 1024; + + if (!isImage) { + alert("Selecciona un archivo de imagen."); + e.target.value = ""; + return; + } + if (!sizeOk) { + alert(`La imagen debe pesar máximo ${maxMb}MB.`); + e.target.value = ""; + return; + } + + // Preview inmediato + const previewUrl = URL.createObjectURL(file); + if (lastPreviewUrlRef.current) URL.revokeObjectURL(lastPreviewUrlRef.current); + lastPreviewUrlRef.current = previewUrl; + setLocalAvatar(previewUrl); + + try { + await onUploadAvatar?.(file); + } catch (err) { + console.error(err); + alert("No se pudo subir la imagen. Intenta de nuevo."); + } finally { + e.target.value = ""; + } + }; + + const handleSubmit = async () => { + if (!name.trim()) { + alert("El nombre es obligatorio."); + return; + } + if (!email.trim()) { + alert("El correo es obligatorio."); + return; + } + + await onSave({ + name: name.trim(), + email: email.trim(), + organismName: organismName.trim() || undefined, + }); + }; + + if (!open) return null; + + return ( + + {/* Backdrop */} + + + {/* Modal */} ++ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( ++++ {/* Header */} ++++ + {/* Body */} +Editar perfil+++ + {/* Footer */} ++ {/* LEFT: Avatar */} ++++ + {/* RIGHT: Form */} ++++ {computedAvatarSrc ? ( ++ ++ ) : ( +
+ {initials} ++ )} +++ + + + ++ {name || "Usuario"} +++ {email || "correo@ejemplo.gob.mx"} +++ {/* “correo electronico” como en tu dibujo */} +++ correo electrónico ++ ++++ setName(e.target.value)} + className="w-full rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm text-slate-900 outline-none focus:ring-2 focus:ring-slate-200" + placeholder="Nombre del usuario" + /> + + ++ setEmail(e.target.value)} + className="w-full rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm text-slate-900 outline-none focus:ring-2 focus:ring-slate-200" + placeholder="correo@organismo.gob.mx" + /> + + ++ setOrganismName(e.target.value)} + className="w-full rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm text-slate-900 outline-none focus:ring-2 focus:ring-slate-200" + placeholder="Organismo operador" + /> + ++ + + ++++ ); +} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index fd5780c..e380092 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -1,5 +1,5 @@ import { Cpu, Settings, BarChart3, Bell } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { BarChart, Bar, @@ -9,58 +9,152 @@ import { ResponsiveContainer, CartesianGrid, } from "recharts"; -import { fetchMeters, Meter } from "../api/meters"; -import { Page } from "../App"; +import { fetchMeters, type Meter } from "../api/meters"; +import type { Page } from "../App"; +import grhWatermark from "../assets/images/grhWatermark.jpg"; -export default function Home({ - setPage, - navigateToMetersWithProject -}: { +/* ================= TYPES ================= */ + +type OrganismStatus = "ACTIVO" | "INACTIVO"; + +type Organism = { + name: string; + region: string; + projects: number; + meters: number; + activeAlerts: number; + lastSync: string; + contact: string; + status: OrganismStatus; +}; + +type AlertItem = { company: string; type: string; time: string }; + +type HistoryItem = { + user: string; + action: string; + target: string; + time: string; +}; + +/* ================= COMPONENT ================= */ + +export default function Home({ + setPage, + navigateToMetersWithProject, +}: { setPage: (page: Page) => void; navigateToMetersWithProject: (projectName: string) => void; }) { - const [allProjects, setAllProjects] = useState{label}+ {children} +([]); + /* ================= ORGANISMS (MOCK) ================= */ + + const organismsData: Organism[] = [ + { + name: "CESPT TIJUANA", + region: "Tijuana, BC", + projects: 6, + meters: 128, + activeAlerts: 0, + lastSync: "Hace 12 min", + contact: "Operaciones CESPT", + status: "ACTIVO", + }, + { + name: "CESPT TECATE", + region: "Tecate, BC", + projects: 3, + meters: 54, + activeAlerts: 1, + lastSync: "Hace 40 min", + contact: "Mantenimiento", + status: "ACTIVO", + }, + { + name: "CESPT MEXICALI", + region: "Mexicali, BC", + projects: 4, + meters: 92, + activeAlerts: 0, + lastSync: "Hace 1 h", + contact: "Supervisión", + status: "ACTIVO", + }, + ]; + + const [selectedOrganism, setSelectedOrganism] = useState ( + organismsData[0]?.name ?? "CESPT TIJUANA" + ); + const [showOrganisms, setShowOrganisms] = useState(false); + const [organismQuery, setOrganismQuery] = useState(""); + + /* ================= METERS ================= */ + const [meters, setMeters] = useState ([]); - const loadMeters = async () => { - const data = await fetchMeters(); - setMeters(data); - const projectsArray = [...new Set(data.map((record: Meter) => record["areaName"]))]; - setAllProjects(projectsArray); - } + try { + const data = await fetchMeters(); + setMeters(data); + } catch (err) { + console.error("Error loading meters:", err); + setMeters([]); + } + }; + useEffect(() => { loadMeters(); }, []); - const chartData = allProjects.map((projectName) => ({ - name: projectName, - meterCount: meters.filter((meter) => meter.areaName === projectName).length, - })); + // TODO: Reemplazar cuando el backend mande el organismo real (ej: meter.organismName) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const getOrganismFromMeter = (_m: Meter): string => { + return "CESPT TIJUANA"; + }; + + const filteredMeters = useMemo( + () => meters.filter((m) => getOrganismFromMeter(m) === selectedOrganism), + [meters, selectedOrganism] + ); + + const filteredProjects = useMemo( + () => [...new Set(filteredMeters.map((m) => m.areaName))], + [filteredMeters] + ); + + const chartData = useMemo( + () => + filteredProjects.map((projectName) => ({ + name: projectName, + meterCount: filteredMeters.filter((m) => m.areaName === projectName) + .length, + })), + [filteredProjects, filteredMeters] + ); // eslint-disable-next-line @typescript-eslint/no-explicit-any const handleBarClick = (data: any) => { - if (data.activeLabel) { + if (data?.activeLabel) { navigateToMetersWithProject(data.activeLabel); } }; - // Datos de ejemplo para empresas - const companies = [ - { name: "Empresa A", tomas: 12, alerts: 2, consumption: 320 }, - { name: "Empresa B", tomas: 8, alerts: 0, consumption: 210 }, - { name: "Empresa C", tomas: 15, alerts: 1, consumption: 450 }, - ]; + /* ================= ORGANISM FILTER (DRAWER) ================= */ - // Alertas recientes - const alerts = [ + const filteredOrganisms = useMemo(() => { + const q = organismQuery.trim().toLowerCase(); + if (!q) return organismsData; + return organismsData.filter((o) => o.name.toLowerCase().includes(q)); + }, [organismQuery]); + + /* ================= MOCK ALERTS / HISTORY ================= */ + + const alerts: AlertItem[] = [ { company: "Empresa A", type: "Fuga", time: "Hace 2 horas" }, { company: "Empresa C", type: "Consumo alto", time: "Hace 5 horas" }, { company: "Empresa B", type: "Inactividad", time: "Hace 8 horas" }, ]; - // Historial tipo Google - const history = [ + const history: HistoryItem[] = [ { user: "GRH", action: "Creó un nuevo medidor", @@ -93,68 +187,258 @@ export default function Home({ }, ]; + /* ================= KPIs (Optional) ================= */ + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const totalMeters = filteredMeters.length; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const totalProjects = filteredProjects.length; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const totalActiveAlerts = 0; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const avgMetersPerProject = + totalProjects > 0 ? totalMeters / totalProjects : 0; + return ( - {/* Título */} --+ {/* Título + Selector */} +- Sistema de Tomas de Agua -
-- Monitorea, administra y controla tus operaciones en un solo lugar. -
-+ {/* ✅ Título + logo a la derecha */} ++- {/* Resumen de tomas por empresa */} -+- {/* Cards de Secciones */} -+ Sistema de Tomas de Agua +
++ Monitorea, administra y controla tus operaciones en un solo lugar. +
+-+ {/* ✅ Logo con z-index bajo para NO tapar menús */} +setPage("meters")} - > --- Tomas - --- Alertas - --- Mantenimiento - --- Reportes - +
- {companies.map((c) => ( + {/* Cards de Secciones */} ++ + {/* Organismos Operadores */} +setPage("meters")} > - {c.name} - - {c.tomas} Tomas - - 0 ? "text-red-500" : "text-green-500" - }`} - > - {c.alerts} Alertas - +- ))} + ++ Tomas ++ ++ Alertas + ++ ++ Mantenimiento + +++ Reportes + +- {/* Gráfica de consumo */} + {/* Gráfica */}++ + {showOrganisms && ( +++ + +Organismos Operadores
++ Seleccionado:{" "} + {selectedOrganism} +
++ {/* Overlay */} +{ + setShowOrganisms(false); + setOrganismQuery(""); + }} + /> + + {/* Panel */} ++ )} ++ {/* Header */} ++++ + {/* Search */} +++ + ++ Organismos Operadores +
++ Selecciona un organismo para filtrar la información del + dashboard. +
++ setOrganismQuery(e.target.value)} + placeholder="Buscar organismo…" + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-200" + /> ++ + {/* List */} ++ {filteredOrganisms.map((o) => { + const active = o.name === selectedOrganism; + + return ( ++ + {/* Footer */} +++ ); + })} + + {filteredOrganisms.length === 0 && ( +++ +++ + + {o.status} + ++ {o.name} +
+{o.region}
+++ ++ Proyectos + + {o.projects} + ++ ++ Medidores + + {o.meters} + ++ ++ Alertas activas + + {o.activeAlerts} + ++ ++ Última sync + + {o.lastSync} + ++ ++ Responsable + + {o.contact} + +++ +++ No se encontraron organismos. ++ )} ++ Nota: Las propiedades están en modo demostración hasta integrar + backend. ++-- {/* Historial tipo Google */} + {/* Historial */}- Número de Medidores por Proyecto -
++++ Número de Medidores por Proyecto +
+ + Click en barra para ver tomas + +- + Historial Reciente
diff --git a/src/pages/concentrators/ConcentratorsPage.tsx b/src/pages/concentrators/ConcentratorsPage.tsx index 4e45f4a..c6dfdfa 100644 --- a/src/pages/concentrators/ConcentratorsPage.tsx +++ b/src/pages/concentrators/ConcentratorsPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react"; import MaterialTable from "@material-table/core"; import { @@ -8,15 +8,29 @@ import { deleteConcentrator, type Concentrator, } from "../../api/concentrators"; +import ConfirmModal from "../../components/layout/common/ConfirmModal"; /* ================= TYPES ================= */ interface User { name: string; role: "SUPER_ADMIN" | "USER"; - project?: string; // asignado si no es superadmin + project?: string; } +type ProjectStatus = "ACTIVO" | "INACTIVO"; + +type ProjectCard = { + name: string; + region: string; + projects: number; + concentrators: number; + activeAlerts: number; + lastSync: string; + contact: string; + status: ProjectStatus; +}; + interface GatewayData { "Gateway ID": number; "Gateway EUI": string; @@ -31,46 +45,120 @@ export default function ConcentratorsPage() { // Simulación de usuario actual const currentUser: User = { name: "Admin GRH", - role: "SUPER_ADMIN", // cambiar a USER para probar otro caso + role: "SUPER_ADMIN", project: "CESPT", }; + // ✅ Modal confirmación delete (bonito) + const [confirmOpen, setConfirmOpen] = useState(false); + const [deleting, setDeleting] = useState(false); + const [allProjects, setAllProjects] = useState
([]); const [loadingProjects, setLoadingProjects] = useState(true); const [loadingConcentrators, setLoadingConcentrators] = useState(true); - // Proyectos visibles según el usuario - const visibleProjects = useMemo(() => - currentUser.role === "SUPER_ADMIN" - ? allProjects - : currentUser.project - ? [currentUser.project] - : [], + const [selectedProject, setSelectedProject] = useState(""); + const [concentrators, setConcentrators] = useState ([]); + const [filteredConcentrators, setFilteredConcentrators] = useState< + Concentrator[] + >([]); + + const [activeConcentrator, setActiveConcentrator] = + useState (null); + const [search, setSearch] = useState(""); + const [projectQuery, setProjectQuery] = useState(""); + + const [showModal, setShowModal] = useState(false); + const [editingSerial, setEditingSerial] = useState (null); + + /* ================= PROJECTS VISIBLE ================= */ + const visibleProjects = useMemo( + () => + currentUser.role === "SUPER_ADMIN" + ? allProjects + : currentUser.project + ? [currentUser.project] + : [], [allProjects, currentUser.role, currentUser.project] ); - const [selectedProject, setSelectedProject] = useState(""); - const [concentrators, setConcentrators] = useState ([]); - const [filteredConcentrators, setFilteredConcentrators] = useState ([]); - - useEffect(() => { - if (selectedProject) { - const filtered = concentrators.filter( - (c) => c["Area Name"] === selectedProject - ); - setFilteredConcentrators(filtered); - } else { - setFilteredConcentrators(concentrators); - } - }, [selectedProject, concentrators]); - + /* ================= LOAD ================= */ const loadConcentrators = async () => { setLoadingConcentrators(true); + setLoadingProjects(true); try { - const data = await fetchConcentrators(); - const projectsArray = [...new Set(data.map((record) => record["Area Name"]))]; + const raw = await fetchConcentrators(); + + // ============================================================ + // ✅ DEBUG: Ver payload crudo y comparar por proyecto/Area Name + // ============================================================ + console.log("RAW concentrators sample (first 5):", raw.slice(0, 5)); + + const byArea = raw.reduce >((acc, c: any) => { + const area = c["Area Name"] ?? "SIN AREA"; + (acc[area] ||= []).push(c); + return acc; + }, {}); + + Object.entries(byArea).forEach(([area, rows]) => { + const first: any = rows[0]; + console.log(`AREA=${area} COUNT=${rows.length}`); + console.log("keys:", Object.keys(first)); + console.log("Device Name:", first["Device Name"]); + console.log("Device S/N:", first["Device S/N"]); + console.log("Possible alt fields:", { + deviceName: first.deviceName, + name: first.name, + device_code: first["Device Code"], + device_alias: first["Device Alias"], + device_label: first["Device Label"], + device_display_name: first["Device Display Name"], + deviceDescription: first["Device Description"], + }); + }); + + // ============================================================ + // ✅ NORMALIZE: Forzar que "Device Name" sea el nombre “humano” + // - Prioriza posibles campos alternos + // - Deja el "Device Name" original al final como fallback + // ============================================================ + const normalized = raw.map((c: any) => { + const preferredName = + c["Device Alias"] || + c["Device Label"] || + c["Device Display Name"] || + c.deviceName || + c.name || + c["Device Name"] || + ""; + + return { + ...c, + "Device Name": preferredName, + }; + }); + + console.log("NORMALIZED sample (first 5):", normalized.slice(0, 5)); + + const projectsArray = [ + ...new Set(normalized.map((r: any) => r["Area Name"])), + ].filter(Boolean) as string[]; + setAllProjects(projectsArray); - setConcentrators(data); + setConcentrators(normalized); + + // ✅ FIX: si no hay proyecto seleccionado, autoselecciona el primero visible + setSelectedProject((prev) => { + if (prev) return prev; + + // si es USER y tiene proyecto asignado, respétalo + if (currentUser.role !== "SUPER_ADMIN" && currentUser.project) { + return currentUser.project; + } + + // para SUPER_ADMIN: si hay visibles, toma el primero + return projectsArray[0] ?? ""; + }); } catch (error) { console.error("Error loading concentrators:", error); setAllProjects([]); @@ -83,14 +171,63 @@ export default function ConcentratorsPage() { useEffect(() => { loadConcentrators(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const [activeConcentrator, setActiveConcentrator] = useState (null); - const [search, setSearch] = useState(""); + // Si el usuario solo tiene 1 proyecto visible, lo auto-selecciona + useEffect(() => { + if (!selectedProject && visibleProjects.length === 1) { + setSelectedProject(visibleProjects[0]); + } + }, [visibleProjects, selectedProject]); - const [showModal, setShowModal] = useState(false); - const [editingSerial, setEditingSerial] = useState (null); + // ============================================================ + // ✅ MISMA LÓGICA QUE TU SEGUNDO CÓDIGO: + // - Si hay selectedProject => filtra por Area Name + // - Si NO hay selectedProject => muestra TODOS (no vacío) + // ============================================================ + useEffect(() => { + if (selectedProject) { + const filtered = concentrators.filter( + (c) => c["Area Name"] === selectedProject + ); + setFilteredConcentrators(filtered); + } else { + setFilteredConcentrators(concentrators); + } + }, [selectedProject, concentrators]); + /* ================= SIDEBAR (HOME-LIKE LIST ALWAYS OPEN) ================= */ + const projectsData: ProjectCard[] = useMemo(() => { + const counts = concentrators.reduce >((acc, c) => { + const area = c["Area Name"] ?? "SIN PROYECTO"; + acc[area] = (acc[area] ?? 0) + 1; + return acc; + }, {}); + + const baseRegion = "Baja California"; + const baseContact = "Operaciones"; + const baseLastSync = "Hace 1 h"; + + return visibleProjects.map((name) => ({ + name, + region: baseRegion, + projects: 1, + concentrators: counts[name] ?? 0, + activeAlerts: 0, + lastSync: baseLastSync, + contact: baseContact, + status: "ACTIVO", + })); + }, [concentrators, visibleProjects]); + + const filteredProjects = useMemo(() => { + const q = projectQuery.trim().toLowerCase(); + if (!q) return projectsData; + return projectsData.filter((p) => p.name.toLowerCase().includes(q)); + }, [projectQuery, projectsData]); + + /* ================= FORM HELPERS ================= */ const getEmptyConcentrator = (): Omit => ({ "Area Name": selectedProject, "Device S/N": "", @@ -111,17 +248,22 @@ export default function ConcentratorsPage() { "Antenna Placement": "Indoor", }); - const [form, setForm] = useState >(getEmptyConcentrator()); - const [gatewayForm, setGatewayForm] = useState (getEmptyGatewayData()); + // ✅ FIX: gatewayForm debe inicializarse con el OBJETO, no con la función + const [form, setForm] = useState >( + getEmptyConcentrator() + ); + const [gatewayForm, setGatewayForm] = useState ( + getEmptyGatewayData() + ); const [errors, setErrors] = useState<{ [key: string]: boolean }>({}); /* ================= CRUD ================= */ - const createOrUpdateGateway = async (gatewayData: GatewayData): Promise => { - //await fetch('/api/gateways', { method: 'POST', body: JSON.stringify(gatewayData) }) - + const createOrUpdateGateway = async ( + gatewayData: GatewayData + ): Promise => { return new Promise((resolve) => { setTimeout(() => { - console.log('Gateway data that would be sent to API:', gatewayData); + console.log("Gateway data that would be sent to API:", gatewayData); resolve(); }, 500); }); @@ -133,37 +275,47 @@ export default function ConcentratorsPage() { if (!form["Device Name"].trim()) newErrors["Device Name"] = true; if (!form["Device S/N"].trim()) newErrors["Device S/N"] = true; if (!form["Operator"].trim()) newErrors["Operator"] = true; - if (!form["Instruction Manual"].trim()) newErrors["Instruction Manual"] = true; + if (!form["Instruction Manual"].trim()) + newErrors["Instruction Manual"] = true; if (!form["Installed Time"]) newErrors["Installed Time"] = true; if (!form["Device Time"]) newErrors["Device Time"] = true; if (!form["Communication Time"]) newErrors["Communication Time"] = true; - if (!gatewayForm["Gateway ID"] || gatewayForm["Gateway ID"] === 0) { + if (!gatewayForm["Gateway ID"] || gatewayForm["Gateway ID"] === 0) newErrors["Gateway ID"] = true; - } if (!gatewayForm["Gateway EUI"].trim()) newErrors["Gateway EUI"] = true; if (!gatewayForm["Gateway Name"].trim()) newErrors["Gateway Name"] = true; - if (!gatewayForm["Gateway Description"].trim()) newErrors["Gateway Description"] = true; + if (!gatewayForm["Gateway Description"].trim()) + newErrors["Gateway Description"] = true; setErrors(newErrors); return Object.keys(newErrors).length === 0; }; const handleSave = async () => { - if (!validateForm()) { - return; - } + if (!validateForm()) return; try { let savedConcentrator: Concentrator; - if (editingSerial) { - const concentratorToUpdate = concentrators.find(c => c["Device S/N"] === editingSerial); - if (!concentratorToUpdate) { - throw new Error("Concentrator to update not found"); - } + // ✅ DEBUG: ver qué se manda al API + console.log("FORM SENT:", form); + console.log("editingSerial:", editingSerial); + + if (editingSerial) { + const concentratorToUpdate = concentrators.find( + (c) => c["Device S/N"] === editingSerial + ); + if (!concentratorToUpdate) throw new Error("Concentrator not found"); + + const updatedConcentrator = await updateConcentrator( + concentratorToUpdate.id, + form + ); + + // ✅ DEBUG: ver respuesta del API + console.log("UPDATED RESPONSE:", updatedConcentrator); - const updatedConcentrator = await updateConcentrator(concentratorToUpdate.id, form); setConcentrators((prev) => prev.map((c) => c.id === concentratorToUpdate.id ? updatedConcentrator : c @@ -172,6 +324,10 @@ export default function ConcentratorsPage() { savedConcentrator = updatedConcentrator; } else { const newConcentrator = await createConcentrator(form); + + // ✅ DEBUG: ver respuesta del API al crear + console.log("CREATED RESPONSE:", newConcentrator); + setConcentrators((prev) => [...prev, newConcentrator]); savedConcentrator = newConcentrator; } @@ -182,10 +338,9 @@ export default function ConcentratorsPage() { concentratorId: savedConcentrator.id, }; await createOrUpdateGateway(gatewayDataWithRef); - console.log('Gateway data saved successfully'); } catch (gatewayError) { - console.error('Error saving gateway data:', gatewayError); - alert('Concentrator saved, but there was an error saving gateway data.'); + console.error("Error saving gateway data:", gatewayError); + alert("Concentrator saved, but there was an error saving gateway data."); } setShowModal(false); @@ -195,7 +350,7 @@ export default function ConcentratorsPage() { setErrors({}); setActiveConcentrator(null); } catch (error) { - console.error('Error saving concentrator:', error); + console.error("Error saving concentrator:", error); alert( `Error saving concentrator: ${ error instanceof Error ? error.message : "Please try again." @@ -204,18 +359,15 @@ export default function ConcentratorsPage() { } }; + // ✅ MISMA lógica de delete, solo sin window.confirm (el confirm lo hace el modal) const handleDelete = async () => { if (!activeConcentrator) return; - const confirmDelete = window.confirm( - `Are you sure you want to delete the concentrator "${activeConcentrator["Device Name"]}"?` - ); - - if (!confirmDelete) return; - try { await deleteConcentrator(activeConcentrator.id); - setConcentrators((prev) => prev.filter((c) => c.id !== activeConcentrator.id)); + setConcentrators((prev) => + prev.filter((c) => c.id !== activeConcentrator.id) + ); setActiveConcentrator(null); } catch (error) { console.error("Error deleting concentrator:", error); @@ -227,67 +379,202 @@ export default function ConcentratorsPage() { } }; - const searchFiltered = filteredConcentrators.filter( - (c) => - c["Device Name"].toLowerCase().includes(search.toLowerCase()) || - c["Device S/N"].toLowerCase().includes(search.toLowerCase()) - ); + // ============================================================ + // ✅ MISMA LÓGICA DE TABLA/BÚSQUEDA QUE TU SEGUNDO CÓDIGO: + // - filtra sobre filteredConcentrators (que ya puede ser "all") + // - búsqueda case-insensitive sin romper por undefined + // ============================================================ + const searchFiltered = filteredConcentrators.filter((c) => { + const name = (c["Device Name"] ?? "").toLowerCase(); + const sn = (c["Device S/N"] ?? "").toLowerCase(); + const q = search.toLowerCase(); + return name.includes(q) || sn.includes(q); + }); /* ================= UI ================= */ return ( - {/* LEFT INFO SIDEBAR */} --+ {/* MAIN */} -- Project Information -
+ {/* SIDEBAR */} +- {/* HEADER */} ++ - {/* MODAL */} + {/* MODAL ADD/EDIT */} {showModal && (+ {/* HEADER + ACTIONS */} @@ -347,129 +640,291 @@ export default function ConcentratorsPage() { placeholder="Search concentrator..." value={search} onChange={(e) => setSearch(e.target.value)} + disabled={!selectedProject} /> {/* TABLE */} -Concentrator Management
-Concentradores registrados
++ {selectedProject + ? `Proyecto: ${selectedProject}` + : "Selecciona un proyecto desde el panel izquierdo"} +
+ {/* ✅ EDIT */} + {/* ✅ Delete confirm modal */}rowData["Device Name"] || "-" }, - { title: "Device S/N", field: "Device S/N", render: (rowData) => rowData["Device S/N"] || "-" }, - { - title: "Device Status", - field: "Device Status", - render: (rowData) => ( - - {rowData["Device Status"] || "-"} - - ), - }, - { title: "Operator", field: "Operator", render: (rowData) => rowData["Operator"] || "-" }, - { title: "Area Name", field: "Area Name", render: (rowData) => rowData["Area Name"] || "-" }, - { title: "Installed Time", field: "Installed Time", type: "date", render: (rowData) => rowData["Installed Time"] || "-" }, - ]} - data={searchFiltered} - onRowClick={(_, rowData) => setActiveConcentrator(rowData as Concentrator)} - options={{ - actionsColumnIndex: -1, - search: false, - paging: true, - sorting: true, - rowStyle: (rowData) => ({ - backgroundColor: - activeConcentrator?.id === (rowData as Concentrator).id - ? "#EEF2FF" - : "#FFFFFF", - }), - }} - localization={{ - body: { - emptyDataSourceMessage: loadingConcentrators - ? "Loading concentrators..." - : "No concentrators found. Click 'Add' to create your first concentrator.", - }, + ++ + {/* ✅ ConfirmModal bonito */} +rowData["Device Name"] || "-", + }, + { + title: "Device S/N", + field: "Device S/N", + render: (rowData: any) => rowData["Device S/N"] || "-", + }, + { + title: "Device Status", + field: "Device Status", + render: (rowData: any) => ( + + {rowData["Device Status"] || "-"} + + ), + }, + { + title: "Operator", + field: "Operator", + render: (rowData: any) => rowData["Operator"] || "-", + }, + { + title: "Area Name", + field: "Area Name", + render: (rowData: any) => rowData["Area Name"] || "-", + }, + { + title: "Installed Time", + field: "Installed Time", + type: "date", + render: (rowData: any) => rowData["Installed Time"] || "-", + }, + ]} + data={searchFiltered} + onRowClick={(_, rowData) => + setActiveConcentrator(rowData as Concentrator) + } + options={{ + actionsColumnIndex: -1, + search: false, + paging: true, + sorting: true, + rowStyle: (rowData) => ({ + backgroundColor: + activeConcentrator?.id === (rowData as Concentrator).id + ? "#EEF2FF" + : "#FFFFFF", + }), + }} + localization={{ + body: { + emptyDataSourceMessage: !selectedProject + ? "Select a project to view concentrators." + : loadingConcentrators + ? "Loading concentrators..." + : "No concentrators found. Click 'Add' to create your first concentrator.", + }, + }} + /> + setConfirmOpen(false)} + onConfirm={async () => { + setDeleting(true); + try { + await handleDelete(); + setConfirmOpen(false); + } finally { + setDeleting(false); + } }} /> - -+{editingSerial ? "Edit Concentrator" : "Add Concentrator"}
+ {/* ================= FORM ================= */}Concentrator Information
- -- { - setForm({ ...form, "Device Name": e.target.value }); - if (errors["Device Name"]) { - setErrors({ ...errors, "Device Name": false }); - } - }} - required - /> - {errors["Device Name"] && ( -This field is required
- )} + ++-+ ++ ++ El proyecto seleccionado define el Area Name. +
++ { + setForm({ ...form, "Device S/N": e.target.value }); + if (errors["Device S/N"]) + setErrors({ ...errors, "Device S/N": false }); + }} + required + /> + {errors["Device S/N"] && ( ++ This field is required +
+ )} +- { - setForm({ ...form, "Device S/N": e.target.value }); - if (errors["Device S/N"]) { - setErrors({ ...errors, "Device S/N": false }); ++-+ { + setForm({ ...form, "Device Name": e.target.value }); + if (errors["Device Name"]) + setErrors({ ...errors, "Device Name": false }); + }} + required + /> + {errors["Device Name"] && ( ++ ++ This field is required +
+ )} ++ +- { - setForm({ ...form, "Operator": e.target.value }); - if (errors["Operator"]) { - setErrors({ ...errors, "Operator": false }); - } - }} - required - /> - {errors["Operator"] && ( -This field is required
- )} +++ ++ { + setForm({ ...form, Operator: e.target.value }); + if (errors["Operator"]) + setErrors({ ...errors, Operator: false }); + }} + required + /> + {errors["Operator"] && ( ++ ++ This field is required +
+ )} ++ { + setForm({ ...form, "Installed Time": e.target.value }); + if (errors["Installed Time"]) + setErrors({ ...errors, "Installed Time": false }); + }} + required + /> + {errors["Installed Time"] && ( +++ This field is required +
+ )} +++ { + setForm({ + ...form, + "Device Time": fromDatetimeLocalValue(e.target.value), + }); + if (errors["Device Time"]) + setErrors({ ...errors, "Device Time": false }); + }} + required + /> + {errors["Device Time"] && ( ++ ++ This field is required +
+ )} ++ { + setForm({ + ...form, + "Communication Time": fromDatetimeLocalValue( + e.target.value + ), + }); + if (errors["Communication Time"]) + setErrors({ ...errors, "Communication Time": false }); + }} + required + /> + {errors["Communication Time"] && ( ++ This field is required +
+ )} +@@ -481,168 +936,118 @@ export default function ConcentratorsPage() { value={form["Instruction Manual"]} onChange={(e) => { setForm({ ...form, "Instruction Manual": e.target.value }); - if (errors["Instruction Manual"]) { + if (errors["Instruction Manual"]) setErrors({ ...errors, "Instruction Manual": false }); - } }} required /> {errors["Instruction Manual"] && ( -- - - -This field is required
- )} -- { - setForm({ ...form, "Installed Time": e.target.value }); - if (errors["Installed Time"]) { - setErrors({ ...errors, "Installed Time": false }); - } - }} - required - /> - {errors["Installed Time"] && ( -- -This field is required
- )} -- { - setForm({ - ...form, - "Device Time": new Date(e.target.value).toISOString(), - }); - if (errors["Device Time"]) { - setErrors({ ...errors, "Device Time": false }); - } - }} - required - /> - {errors["Device Time"] && ( -- -This field is required
- )} -- { - setForm({ - ...form, - "Communication Time": new Date(e.target.value).toISOString(), - }); - if (errors["Communication Time"]) { - setErrors({ ...errors, "Communication Time": false }); - } - }} - required - /> - {errors["Communication Time"] && ( -This field is required
++ This field is required +
)}- Gateway Information + Gateway Configuration
-- { - setGatewayForm({ - ...gatewayForm, - "Gateway ID": parseInt(e.target.value) || 0, - }); - if (errors["Gateway ID"]) { - setErrors({ ...errors, "Gateway ID": false }); - } - }} - required - min="1" - /> - {errors["Gateway ID"] && ( -This field is required
- )} ++-+ { + setGatewayForm({ + ...gatewayForm, + "Gateway ID": parseInt(e.target.value) || 0, + }); + if (errors["Gateway ID"]) + setErrors({ ...errors, "Gateway ID": false }); + }} + required + min={1} + /> + {errors["Gateway ID"] && ( ++ ++ This field is required +
+ )} ++ { + setGatewayForm({ + ...gatewayForm, + "Gateway EUI": e.target.value, + }); + if (errors["Gateway EUI"]) + setErrors({ ...errors, "Gateway EUI": false }); + }} + required + /> + {errors["Gateway EUI"] && ( ++ This field is required +
+ )} +- { - setGatewayForm({ ...gatewayForm, "Gateway EUI": e.target.value }); - if (errors["Gateway EUI"]) { - setErrors({ ...errors, "Gateway EUI": false }); - } - }} - required - /> - {errors["Gateway EUI"] && ( -+This field is required
- )} -++ { + setGatewayForm({ + ...gatewayForm, + "Gateway Name": e.target.value, + }); + if (errors["Gateway Name"]) + setErrors({ ...errors, "Gateway Name": false }); + }} + required + /> + {errors["Gateway Name"] && ( +-+ This field is required +
+ )} +- { - setGatewayForm({ ...gatewayForm, "Gateway Name": e.target.value }); - if (errors["Gateway Name"]) { - setErrors({ ...errors, "Gateway Name": false }); ++ +@@ -657,31 +1062,17 @@ export default function ConcentratorsPage() { ...gatewayForm, "Gateway Description": e.target.value, }); - if (errors["Gateway Description"]) { + if (errors["Gateway Description"]) setErrors({ ...errors, "Gateway Description": false }); - } }} required /> {errors["Gateway Description"] && ( -- -This field is required
++ This field is required +
)}@@ -707,4 +1098,25 @@ export default function ConcentratorsPage() { )}); + + function toDatetimeLocalValue(value?: string) { + if (!value) return ""; + const d = new Date(value); + if (Number.isNaN(d.getTime())) return ""; + const pad = (n: number) => String(n).padStart(2, "0"); + const yyyy = d.getFullYear(); + const mm = pad(d.getMonth() + 1); + const dd = pad(d.getDate()); + const hh = pad(d.getHours()); + const mi = pad(d.getMinutes()); + return `${yyyy}-${mm}-${dd}T${hh}:${mi}`; + } + + function fromDatetimeLocalValue(value: string) { + if (!value) return ""; + // interpreta como hora local del navegador y lo pasa a ISO + const d = new Date(value); + if (Number.isNaN(d.getTime())) return ""; + return d.toISOString(); + } } diff --git a/src/pages/meters/MeterPage.tsx b/src/pages/meters/MeterPage.tsx index 80c0e54..daef8f9 100644 --- a/src/pages/meters/MeterPage.tsx +++ b/src/pages/meters/MeterPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react"; import MaterialTable from "@material-table/core"; import { @@ -8,21 +8,36 @@ import { deleteMeter, type Meter, } from "../../api/meters"; +import ConfirmModal from "../../components/layout/common/ConfirmModal"; // ✅ NUEVO interface DeviceData { "Device ID": number; "Device EUI": string; "Join EUI": string; - "AppKey": string; + AppKey: string; meterId?: string; } +type ProjectStatus = "ACTIVO" | "INACTIVO"; + +type ProjectCard = { + name: string; + region: string; + projects: number; // placeholder + meters: number; + activeAlerts: number; + lastSync: string; + contact: string; + status: ProjectStatus; +}; + /* ================= COMPONENT ================= */ -export default function MeterManagement({ selectedProject: initialProject }: { selectedProject?: string } = {}) { +export default function MeterManagement({ + selectedProject: initialProject, +}: { selectedProject?: string } = {}) { const [allProjects, setAllProjects] = useState([]); const [loadingProjects, setLoadingProjects] = useState(true); - const [selectedProject, setSelectedProject] = useState(initialProject || ""); const [meters, setMeters] = useState ([]); @@ -31,9 +46,15 @@ export default function MeterManagement({ selectedProject: initialProject }: { s const [activeMeter, setActiveMeter] = useState (null); const [search, setSearch] = useState(""); + const [projectQuery, setProjectQuery] = useState(""); + const [showModal, setShowModal] = useState(false); const [editingId, setEditingId] = useState (null); + // ✅ NUEVO: confirm modal delete + const [confirmOpen, setConfirmOpen] = useState(false); + const [deleting, setDeleting] = useState(false); + const emptyMeter: Omit = { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), @@ -60,29 +81,32 @@ export default function MeterManagement({ selectedProject: initialProject }: { s "Device ID": 0, "Device EUI": "", "Join EUI": "", - "AppKey": "", + AppKey: "", }; - useEffect(() => { - if (selectedProject) { - const filtered = meters.filter((meter) => meter.areaName === selectedProject); - setFilteredMeters(filtered); - } else { - setFilteredMeters(meters); - } - }, [selectedProject, meters]); - const [form, setForm] = useState >(emptyMeter); const [deviceForm, setDeviceForm] = useState (emptyDeviceData); const [errors, setErrors] = useState<{ [key: string]: boolean }>({}); + /* ================= LOAD ================= */ const loadMeters = async () => { setLoadingMeters(true); + setLoadingProjects(true); try { const data = await fetchMeters(); - const projectsArray = [...new Set(data.map((record) => record["areaName"]))]; + + const projectsArray = [...new Set(data.map((r) => r.areaName))] + .filter(Boolean) as string[]; + setAllProjects(projectsArray); setMeters(data); + + // ✅ FIX: si no hay proyecto seleccionado, autoselecciona el primero disponible + setSelectedProject((prev) => { + if (prev) return prev; + if (initialProject) return initialProject; + return projectsArray[0] ?? ""; + }); } catch (error) { console.error("Error loading meters:", error); setAllProjects([]); @@ -95,39 +119,74 @@ export default function MeterManagement({ selectedProject: initialProject }: { s useEffect(() => { loadMeters(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { - if (initialProject) { - setSelectedProject(initialProject); - } + if (initialProject) setSelectedProject(initialProject); }, [initialProject]); + // Filtrado por proyecto + useEffect(() => { + if (!selectedProject) { + setFilteredMeters([]); + return; + } + setFilteredMeters(meters.filter((m) => m.areaName === selectedProject)); + }, [selectedProject, meters]); + + /* ================= SIDEBAR PROJECT CARDS (ALWAYS OPEN) ================= */ + const projectsData: ProjectCard[] = useMemo(() => { + const counts = meters.reduce >((acc, m) => { + const area = m.areaName ?? "SIN PROYECTO"; + acc[area] = (acc[area] ?? 0) + 1; + return acc; + }, {}); + + const baseRegion = "Baja California"; + const baseContact = "Operaciones"; + const baseLastSync = "Hace 1 h"; + + return allProjects.map((name) => ({ + name, + region: baseRegion, + projects: 1, + meters: counts[name] ?? 0, + activeAlerts: 0, + lastSync: baseLastSync, + contact: baseContact, + status: "ACTIVO", + })); + }, [meters, allProjects]); + + const filteredProjects = useMemo(() => { + const q = projectQuery.trim().toLowerCase(); + if (!q) return projectsData; + return projectsData.filter((p) => p.name.toLowerCase().includes(q)); + }, [projectQuery, projectsData]); + + /* ================= DEVICE CONFIG MOCK ================= */ const createOrUpdateDevice = async (deviceData: DeviceData): Promise => { - //await fetch('/api/devices', { method: 'POST', body: JSON.stringify(deviceData) }) - return new Promise((resolve) => { setTimeout(() => { - console.log('Device data that would be sent to API:', deviceData); + console.log("Device data that would be sent to API:", deviceData); resolve(); }, 500); }); }; + /* ================= VALIDATION ================= */ const validateForm = (): boolean => { const newErrors: { [key: string]: boolean } = {}; - // Required fields if (!form.meterName.trim()) newErrors["meterName"] = true; if (!form.meterSerialNumber.trim()) newErrors["meterSerialNumber"] = true; if (!form.areaName.trim()) newErrors["areaName"] = true; if (!form.deviceName.trim()) newErrors["deviceName"] = true; if (!form.protocolType.trim()) newErrors["protocolType"] = true; - // Device Configuration - Required - if (!deviceForm["Device ID"] || deviceForm["Device ID"] === 0) { + if (!deviceForm["Device ID"] || deviceForm["Device ID"] === 0) newErrors["Device ID"] = true; - } if (!deviceForm["Device EUI"].trim()) newErrors["Device EUI"] = true; if (!deviceForm["Join EUI"].trim()) newErrors["Join EUI"] = true; if (!deviceForm["AppKey"].trim()) newErrors["AppKey"] = true; @@ -136,25 +195,20 @@ export default function MeterManagement({ selectedProject: initialProject }: { s return Object.keys(newErrors).length === 0; }; + /* ================= CRUD ================= */ const handleSave = async () => { - if (!validateForm()) { - return; - } + if (!validateForm()) return; try { let savedMeter: Meter; if (editingId) { - const meterToUpdate = meters.find(m => m.id === editingId); - if (!meterToUpdate) { - throw new Error("Meter to update not found"); - } + const meterToUpdate = meters.find((m) => m.id === editingId); + if (!meterToUpdate) throw new Error("Meter to update not found"); const updatedMeter = await updateMeter(editingId, form); setMeters((prev) => - prev.map((m) => - m.id === editingId ? updatedMeter : m - ) + prev.map((m) => (m.id === editingId ? updatedMeter : m)) ); savedMeter = updatedMeter; } else { @@ -164,15 +218,11 @@ export default function MeterManagement({ selectedProject: initialProject }: { s } try { - const deviceDataWithRef = { - ...deviceForm, - meterId: savedMeter.id, - }; + const deviceDataWithRef = { ...deviceForm, meterId: savedMeter.id }; await createOrUpdateDevice(deviceDataWithRef); - console.log('Device data saved successfully'); } catch (deviceError) { - console.error('Error saving device data:', deviceError); - alert('Meter saved, but there was an error saving device data.'); + console.error("Error saving device data:", deviceError); + alert("Meter saved, but there was an error saving device data."); } setShowModal(false); @@ -182,24 +232,23 @@ export default function MeterManagement({ selectedProject: initialProject }: { s setErrors({}); setActiveMeter(null); } catch (error) { - console.error('Error saving meter:', error); + console.error("Error saving meter:", error); alert( `Error saving meter: ${ error instanceof Error ? error.message : "Please try again." }` + + ); + } + }; + // ✅ MISMA lógica de delete, solo sin window.confirm const handleDelete = async () => { if (!activeMeter) return; - const confirmDelete = window.confirm( - `Are you sure you want to delete the meter "${activeMeter.meterName}" (${activeMeter.meterSerialNumber})?` - ); - - if (!confirmDelete) return; - try { await deleteMeter(activeMeter.id); setMeters((prev) => prev.filter((m) => m.id !== activeMeter.id)); @@ -219,63 +268,204 @@ export default function MeterManagement({ selectedProject: initialProject }: { s setActiveMeter(null); }; + /* ================= SEARCH (CLIENT) ================= */ + const searchFiltered = filteredMeters.filter((m) => { + const q = search.trim().toLowerCase(); + if (!q) return true; + + return ( + (m.meterName ?? "").toLowerCase().includes(q) || + (m.meterSerialNumber ?? "").toLowerCase().includes(q) || + (m.deviceId ?? "").toLowerCase().includes(q) || + (m.areaName ?? "").toLowerCase().includes(q) + ); + }); + /* ================= UI ================= */ return ( - {/* LEFT INFO SIDEBAR */} --+ {/* MAIN */} -- Project Information -
+ {/* SIDEBAR */} +++ {/* MODAL */} -{showModal && ( -{/* HEADER */} @@ -345,328 +536,420 @@ export default function MeterManagement({ selectedProject: initialProject }: { s placeholder="Search by meter name, serial number, device ID, or area..." value={search} onChange={(e) => setSearch(e.target.value)} + disabled={!selectedProject} /> {/* TABLE */} -Meter Management
-Medidores registrados
++ {selectedProject + ? `Proyecto: ${selectedProject}` + : "Selecciona un proyecto desde el panel izquierdo"} +
+ {/* ✅ CAMBIO: antes llamaba handleDelete, ahora abre modal */}rowData.areaName || "-" }, - { title: "Account Number", field: "accountNumber", render: (rowData) => rowData.accountNumber || "-" }, - { title: "User Name", field: "userName", render: (rowData) => rowData.userName || "-" }, - { title: "User Address", field: "userAddress", render: (rowData) => rowData.userAddress || "-" }, - { title: "Meter S/N", field: "meterSerialNumber", render: (rowData) => rowData.meterSerialNumber || "-" }, - { title: "Meter Name", field: "meterName", render: (rowData) => rowData.meterName || "-" }, - { title: "Protocol Type", field: "protocolType", render: (rowData) => rowData.protocolType || "-" }, - { title: "Device ID", field: "deviceId", render: (rowData) => rowData.deviceId || "-" }, - { title: "Device Name", field: "deviceName", render: (rowData) => rowData.deviceName || "-" }, - ]} - data={filteredMeters} - onRowClick={(_, rowData) => setActiveMeter(rowData as Meter)} - options={{ - actionsColumnIndex: -1, - search: false, - paging: true, - sorting: true, - rowStyle: (rowData) => ({ - backgroundColor: - activeMeter?.id === (rowData as Meter).id - ? "#EEF2FF" - : "#FFFFFF", - }), - }} - localization={{ - body: { - emptyDataSourceMessage: loadingMeters - ? "Loading meters..." - : "No meters found. Click 'Add' to create your first meter.", - }, + ++ + {/* ✅ NUEVO: ConfirmModal para borrar */} +rowData.areaName || "-", + }, + { + title: "Account Number", + field: "accountNumber", + render: (rowData) => rowData.accountNumber || "-", + }, + { + title: "User Name", + field: "userName", + render: (rowData) => rowData.userName || "-", + }, + { + title: "User Address", + field: "userAddress", + render: (rowData) => rowData.userAddress || "-", + }, + { + title: "Meter S/N", + field: "meterSerialNumber", + render: (rowData) => rowData.meterSerialNumber || "-", + }, + { + title: "Meter Name", + field: "meterName", + render: (rowData) => rowData.meterName || "-", + }, + { + title: "Protocol Type", + field: "protocolType", + render: (rowData) => rowData.protocolType || "-", + }, + { + title: "Device ID", + field: "deviceId", + render: (rowData) => rowData.deviceId || "-", + }, + { + title: "Device Name", + field: "deviceName", + render: (rowData) => rowData.deviceName || "-", + }, + ]} + data={searchFiltered} + onRowClick={(_, rowData) => setActiveMeter(rowData as Meter)} + options={{ + actionsColumnIndex: -1, + search: false, + paging: true, + sorting: true, + rowStyle: (rowData) => ({ + backgroundColor: + activeMeter?.id === (rowData as Meter).id + ? "#EEF2FF" + : "#FFFFFF", + }), + }} + localization={{ + body: { + emptyDataSourceMessage: !selectedProject + ? "Select a project to view meters." + : loadingMeters + ? "Loading meters..." + : "No meters found. Click 'Add' to create your first meter.", + }, + }} + /> + setConfirmOpen(false)} + onConfirm={async () => { + setDeleting(true); + try { + await handleDelete(); + setConfirmOpen(false); + } finally { + setDeleting(false); + } }} /> - --- {editingId ? "Edit Meter" : "Add Meter"} -
+ {showModal && ( ++++ {editingId ? "Edit Meter" : "Add Meter"} +
--- Meter Information -
+ {/* ✅ FORMULARIO (REINTEGRADO) */} +++ Meter Information +
--- { - setForm({ ...form, areaName: e.target.value }); - if (errors["areaName"]) { - setErrors({ ...errors, "areaName": false }); - } - }} - required - /> - {errors["areaName"] && ( -+This field is required
- )} -++