From ab42d86127ac308499b6c933a359f237a761a986 Mon Sep 17 00:00:00 2001 From: Iliyan Angelov Date: Wed, 10 Dec 2025 01:41:57 +0200 Subject: [PATCH] updates --- .../add_shift_update_to_notification_type.py | 86 + Backend/src/__pycache__/main.cpython-312.pyc | Bin 28268 -> 28965 bytes .../__pycache__/admin_session.cpython-312.pyc | Bin 0 -> 3761 bytes Backend/src/auth/models/admin_session.py | 95 ++ .../admin_security_routes.cpython-312.pyc | Bin 0 -> 12466 bytes .../__pycache__/auth_routes.cpython-312.pyc | Bin 41083 -> 37969 bytes .../src/auth/routes/admin_security_routes.py | 259 +++ Backend/src/auth/routes/auth_routes.py | 159 +- .../admin_security_service.cpython-312.pyc | Bin 0 -> 10949 bytes .../auth/services/admin_security_service.py | 296 ++++ .../staff_shift_routes.cpython-312.pyc | Bin 26479 -> 34371 bytes .../routes/staff_shift_routes.py | 214 ++- .../shift_automation_service.cpython-312.pyc | Bin 0 -> 22169 bytes .../shift_scheduler.cpython-312.pyc | Bin 0 -> 5676 bytes .../services/shift_automation_service.py | 603 +++++++ .../services/shift_scheduler.py | 120 ++ Backend/src/main.py | 12 + Backend/src/models/__init__.py | 3 +- .../__pycache__/__init__.cpython-312.pyc | Bin 8605 -> 8705 bytes .../__pycache__/notification.cpython-312.pyc | Bin 7216 -> 7244 bytes .../src/notifications/models/notification.py | 1 + .../__pycache__/step_up_auth.cpython-312.pyc | Bin 7150 -> 7214 bytes .../src/security/middleware/step_up_auth.py | 17 +- Frontend/src/App.tsx | 4 +- .../auth/components/StepUpAuthModal.tsx | 9 +- .../services/accountantSecurityService.ts | 9 - .../security/services/adminSecurityService.ts | 68 + .../staffShifts/services/staffShiftService.ts | 14 + .../pages/admin/StaffShiftDashboardPage.tsx | 1401 +++++++++++++++++ .../pages/admin/StaffShiftManagementPage.tsx | 620 -------- Frontend/src/routes/adminRoutes.tsx | 2 + .../src/shared/components/SidebarAdmin.tsx | 4 +- 32 files changed, 3235 insertions(+), 761 deletions(-) create mode 100644 Backend/alembic/versions/add_shift_update_to_notification_type.py create mode 100644 Backend/src/auth/models/__pycache__/admin_session.cpython-312.pyc create mode 100644 Backend/src/auth/models/admin_session.py create mode 100644 Backend/src/auth/routes/__pycache__/admin_security_routes.cpython-312.pyc create mode 100644 Backend/src/auth/routes/admin_security_routes.py create mode 100644 Backend/src/auth/services/__pycache__/admin_security_service.cpython-312.pyc create mode 100644 Backend/src/auth/services/admin_security_service.py create mode 100644 Backend/src/hotel_services/services/__pycache__/shift_automation_service.cpython-312.pyc create mode 100644 Backend/src/hotel_services/services/__pycache__/shift_scheduler.cpython-312.pyc create mode 100644 Backend/src/hotel_services/services/shift_automation_service.py create mode 100644 Backend/src/hotel_services/services/shift_scheduler.py create mode 100644 Frontend/src/features/security/services/adminSecurityService.ts create mode 100644 Frontend/src/pages/admin/StaffShiftDashboardPage.tsx delete mode 100644 Frontend/src/pages/admin/StaffShiftManagementPage.tsx diff --git a/Backend/alembic/versions/add_shift_update_to_notification_type.py b/Backend/alembic/versions/add_shift_update_to_notification_type.py new file mode 100644 index 00000000..7a4edc18 --- /dev/null +++ b/Backend/alembic/versions/add_shift_update_to_notification_type.py @@ -0,0 +1,86 @@ +"""add_shift_update_to_notification_type + +Revision ID: add_shift_update_001 +Revises: add_staff_shifts_001 +Create Date: 2025-12-09 20:55:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'add_shift_update_001' +down_revision = 'add_staff_shifts_001' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add 'shift_update' to notification_type enum in notifications table + op.execute(""" + ALTER TABLE notifications + MODIFY COLUMN notification_type ENUM( + 'booking_confirmation', + 'payment_receipt', + 'pre_arrival_reminder', + 'check_in_reminder', + 'check_out_reminder', + 'marketing_campaign', + 'loyalty_update', + 'shift_update', + 'system_alert', + 'custom' + ) NOT NULL + """) + + # Also update notification_templates table if it exists + op.execute(""" + ALTER TABLE notification_templates + MODIFY COLUMN notification_type ENUM( + 'booking_confirmation', + 'payment_receipt', + 'pre_arrival_reminder', + 'check_in_reminder', + 'check_out_reminder', + 'marketing_campaign', + 'loyalty_update', + 'shift_update', + 'system_alert', + 'custom' + ) NOT NULL + """) + + +def downgrade() -> None: + # Remove 'shift_update' from notification_type enum + op.execute(""" + ALTER TABLE notifications + MODIFY COLUMN notification_type ENUM( + 'booking_confirmation', + 'payment_receipt', + 'pre_arrival_reminder', + 'check_in_reminder', + 'check_out_reminder', + 'marketing_campaign', + 'loyalty_update', + 'system_alert', + 'custom' + ) NOT NULL + """) + + op.execute(""" + ALTER TABLE notification_templates + MODIFY COLUMN notification_type ENUM( + 'booking_confirmation', + 'payment_receipt', + 'pre_arrival_reminder', + 'check_in_reminder', + 'check_out_reminder', + 'marketing_campaign', + 'loyalty_update', + 'system_alert', + 'custom' + ) NOT NULL + """) + diff --git a/Backend/src/__pycache__/main.cpython-312.pyc b/Backend/src/__pycache__/main.cpython-312.pyc index 05eb17505341c5bbcba73d24cfc569237ec26629..f8f1698e3987f9410075f9f68faf5e56442b67ae 100644 GIT binary patch delta 3012 zcmaKtdrTBZ9LINdx#Ny|c=nFR9o(zZBKT?ytwP!qDi0Mzt+9qtOHX%z!yTY|2Z+41ZtiA&^ZEVe zJHPqOWcO}A_-+6g&ln6b9DTgfbnjBnm&OF}+D|F>^l75B1%D2=0!}oFrP4Qe8{CGs z!|iwn+<|w(op=}ArTEGu@}}X{Rd^rVhXXL6uyW~VydUnz2jBr^?3E7UL+}tj3=iWY z@QA`Hq+jq+cvP`P=@?z>xMHu6PT(fkgqvYAZhO)Ud)6xvEbJ%xS0jFj)p{SVI>v!0*iJeu&CO~qD?Hy zqabmOjURnHJlfB8kPtJIhb(nc7t6}T$jrKlx$h1$OQDe&`;bWsnF_BnGdskj!%UhU z$I5Kj#-Kdf_nczZvBJs5c&5lR5%zDU)j3r4ZiADD^cfO8!syVEC z!K0n*jEQ3`Yr#gA2${&P(y8Z2uVd|-M=j3@&tYDsjm*oxmsLC-oo7q)sGIFDq05Z( z+Q>Qs9`&*{cob$kNP41Gps!1EUF{|YyD9oATC@RF93wX}&19NAF8bG}#%1=nRoBrC z@q-oPIc@`xQ$7CH2^f%#6rME*n?RAn3C&EPf*02AVgSU7N-K6@#k$3*n zt-?<~O-5{V>&X{_=cfMe^s}t#WU?bW{x)5>9^Doj>U12p8;r!kJ7hz29NFS9fG%aj z#~em*i8SXVYCno>5e}k3GUS-08lWlnL?Y&{M&qa*W~>w8{R}>+%(yn%L*waM(`;%q zC=2KgX>u+G#epPO?|8794A1Q2j|k-AtUWF*Cjv2AGGJqa063nj7j{1uYly0ks)&tf zkk|q@(2(ZY35+@&oT*#brbVE zUfR3-RW6_3Bl}&?&UP*MM>*3)iss}1Ga++oCWKSUtNchR@gcd^yBzr@KQaEBk9?n~ zijo(q=DozVeK<~2hcr8dP|O*+ch;eg=>zRn&;+PG;dicZgp#OlZDo_DY;Ie&8nuP&8ecKEm?@zDcU@T z(Dur>JgYDzXpXppRux4-N5ma;Yx5I?VA{BRv(Rpcc-ze?O4<{sH_$b=6MzR~`}_=^ z1Eg>MODZ+3z?p#R?LMF~Q1e>gw*@``h62S4V*t(8i~_JvILF$#PYV zpPrLP#NJAJY|Cq8uYYC92o1+7IB delta 2932 zcmb7GZA=_R7~a|C_&&alkGlhUrvwVUN};8(&_Jby*cNG#kYc8h0^TBp9l*PzMlGeH z{Hi0yXXqwm%ac*g26KBkNx$Hdi%=Lq zzC}0TO*90D&@Ffi4Z~qH0!Ng_YUw*P3P%-OBaNYPIIdu;^gZc(Lcy;|KcGoCiKgI` zQnyJB=r+7fupLf|g^i%%5-tv~=%(8p7kT$gaU9?t@y!hW4udxaT6R)&)>WT;il;mqBWnI~P#ASS%M6x{lyN^fGwWFFz{>#$;c7h$F+H^F)N1syHT3lL z>SpYphYWs+q0?rd>+fKkizL-!8f1>?1J!fe#?;V<(|xZ*F?hy#J`|~TpG;8qNl;X% z+oQ_&;S_T`I~cx`^cFCUH$-m(v$!Tik1L`hz>f<0I69*KC-GHF6BSd z2kGhfbDs=7yi58%F5b1)z3Ins7r}ZcyoVdT!tv+^Ef~ScX}Z7@*80c&5kF%%dB4%$ zdeS{$xF{~#p2j151Wq?4Y34}tggYhj9WjRBNlM=_xXKj1L0nVS-f;0i`2Iqti|I8 zpGp^OHN>Co0eQ^$*k36Zt+kS9vkRndHk)lFZeDA5l-L}#HAlo_VwJK7$x0UbpWTwRS#ZXA z62`e%Ll|eW;KnIF*%rZ>9+Mp>xXkWWSDc10E(>Xux91N7@DK|Hsr+9Y&M1i02&DU7 zxx8Q&XnYAAm-imB1MrL7UFZvdr#$e|8y;X)&V1R(%V&-p<~`k(Z32E!Vj`0gUV6;8 zfnBwf94w zT{@OgpepJ=z(Ed`;fsP^C|E#r7e#1W{lQ99l-`qN-eU=i0zhS5K^)wqV9zf#{0uf|^1V~U17(qE`gyfJR z%A(OD_ZVS0Y((UUA<2>vm7_*Xjs?&*q{oefoZxLyPZ~)%3GE&d)>B5W+#3*r!g+y2 z9tcFDy-z}ZJh_i+qo9p-Y16CmV&eYqW3$B2%z{mATeHl9!_@L^&8!p*i%`9=Y%v%r zSDEIlg38n!i^1r0>4p;P=fcePE~Zo=U^CX zxlp#uGG(Sc9oxq|i%~E9v87iH(~Dd%9a^Ex3tw?CnkUUeq$SOuUS!U)z?bR8&RL9V z74srp^~A+1zr5`ATrdf}=ZQ=7o>L5X2}X6*fmrrUZ3X%g0Ur+d*vD-Fg*d0e%KR_H&mK;%wF0IdGQsd87f;z=g+ag=yK|m`3g?2Xa?1Cq*qg+FiJeyrU5>s@W}FQde!q z?*L?Y`G5C7@2zNz+Fq>LP*mq~@vtW;im4h@QM{O<0QgjOY$p`uPF2;LBN0U*RvFYu zMR9O5d_BXXW_TaHphlR4y<;d4aJ^)6+ZD&UO#wKx6<8913~Uz1@tD7)3T(O8Pv~8( zOqFFo9m?Q{Ko{ZA#sFr@up$;g$M2v(xL`?i5w1FA)B2hfv6HnOx}sDeO4YduPPH-s z5Aph2>dvxR6z%(3`5Dwv0i;wf1{HvFU}xBI43a=Q5%XloE?%m+d_`pyTTwQJ7oZhh zeNwt<8MIU}@0I4MecQ2CN}pH`)u#Y75z|X^P_)z}C7YE?m|cm3yj?nQ#M3LQEDi3s zz#X#Z0n{D{d*aC-GDp`g{WaSAFtV|Tm}CT{+NhB(MQfAhuq?6)^+88YL`Q&S8^k(%Np+NOYm0NNs+@K&r1pDp5vlX!bpsB4@P4=1b6oEF){NjDjF z#yQ#WOuEUaGv43MG}VsT3#8K50&BTROF%%lf^-YkJv}3g#k_)S2 z#c{817HMS`F%|&lQ>$|SG>9hIaOlOy*jrv^1=S=4wMsMxpK_XRPkRIY^={2nbgN=7 zu>n{w8v=2TTQo&6JNGKOLl#W;!y5c?SrUPuSTJB+#liX$kFqCoKQMmxaY z4$OfTpJ6^apGLdkH`$q08$chR+E%KqHq-3eicarR-5Ywt9A|ZfddYSu>|QP&V)#5^ z$cbzkMG3`EQM4(8{S3Qp&hTO_I0hy`{$Q6-aHhz#-4kk;nYMz@OZFa)V-2zQQJh3^ z5d~+8MQm}LPq$~cfWE^Z>=c^ehH$KBA41EEG&A9t&(7fBM<~vsm__k%OJGoYwh7bc ze#jL6YnVm=j(j)VnEYt>(AlRmjcdPh)4%5U#?kxm<6ms|)R)}M9ANjzp^c;4S$E`Q zL(FlMd^?np;Ah z8eTtBAFe0d)B=!@56hD^tvLM-s*I9p_zkqI zRi=^kzk**QtQQ?{^5sW+@VH+|r~P-)8Fm%y;IsuB>*jeV5D5Gj32b&-DKl)KLX!~Q& zo#EwB^e9O?0B6oU_ug~g=X~c}er&f}33xKGtHY6OcD&q z=;CBTH>pcdlT<=KsZSUt4GH6|g+nq^uawA>6B?p+?A-AtVy^h-H^62wzwzZo%G5y9j{H)P1ecf?D6_U!(@X@JK~Lr zrpYFmcE+0%Et4%Ip(9u)+j^Z;dOzudw2pBxHPhsh`?{{gZDZSF!+4W*S zD^hlxn$j^|*2vVd0j7@iGWD!^%FHypX}DezG`R+(?^UH@ifP0WkaYOfFin_)zjC{k zE#VgQOmjsG?W^>^rBcqlK~Kt&4%W(~7u zX)Rtm7?pu(2VTcg=XKbb=#hC{E9z9HzTR9LnQoYgApF*`R<^qlNJiUBRC8qV;fUR$zJmREqP5nM5?{=h?_C7ZuJ7_<4by>6xAJhi8T38-UOA(Nxl( z2q(kSY=TV+8~ovj5IqTH{qfW^-)lX>&V;$J!1|}SR01+1soA6uP6}n2eokpkNP(Cr zN5zRm!g=m6(fEEE5#%WdvF~@+i zA_@jG&2mAVVt+>M|7G$-G@C?O)hwpjns7Ip;_V!RQ%79Q5p*o+L74%!0+MU&e@c{G4Hx z5~gwq<0>W0lR$|G|4wZABh;fvev3Kt+?hKw0VK#JsB$I ze7bH%kIThsv~pu^4X!TceVTis8)jY~t9v?!v6b=~p3W1RF)!AFDfNrB{fXTKf%Qa_W>UR*-`CP{|=JN8i7(k(fzm(I|fC1cSZRmQ;eTL@tjZY|c2DfPSR z$P9Rm=Byd(ij(JLT$Lv;IhiqX`x!Sw$6My;3?199O2<^j7<(Sm>Q_Y%FuOZcKBm6B zPFX87RcHk#`zmaTxz{;R_cP@qGEfn9QBLO z8A%9bB**v|Ta;wlmi&0Fju2}J;aOa(o#2}1Y?W&yY{7XM8$Q*QbIQV=7>L;rugU zo@DIx<3&0V80Vw#|Y?Nb}YdXos3t?fF z4@FW8D;XIUz)_rY!T7I{5-nFMp&7}7V1wMKWX4TO_7Xl*GSbeYJ*DD36R3NVM@A5U zHe?j1l$to0wj$utGdvC54eB{$HY5`QHlZjZS)((6M;HJsykwP`0Q_JY7SNDtUPzt{ z$D@qDJOj}Sz~|5|NE>E_sh+KA7cPT)neN^b6^^r7HE;Ww3^?o{SpyH;5$BT@0xDan{%0sKJ%`WiGV zy+Iw3>=^Zigfla&ZUgV`!3G+uGl7A0A*2tdL`3wHq5XbFPS2#)QKp7VFSQy zz}v>DXk1`9d66U?Y#R%LvB)uCB`bS+2DUe(1rC8T$v7)Sk}$buI3=*jj-8S0@h~qa z<-w7FF~KBChN&pW3(Ddns3bWe91EZwZ(qq6iAQ0IB>nLeltZ{lvMK`)!E#B~m&5T{ zR-O-uf&!B9ScE$>1EYu>XCo(OV0GCfI%pgMGm;B0La4MV5)D3p+9@OCC!zx9L4_@< z$6*GqAbuFV;7axJT%-@3l|IEkQ@ju8hT;i=^MicE-kCF!qcq8-dE&ke%_3~d zJ3#CHk@(^b;)#o(y%)w7*DTvQ?mC*UQrTx;UUs~aHNR5SPmqSed*&_oz5cwnUG%mu zelzFYG;b+*+VY;YqG#>mi#boC0{$&b93Nv}NY~>07A}Qu)zW#nD%@ld0V3%>02zX2R84Xl=W4^zzZgc&_!CeCvSN zI#6)6pgwbcFdzH5ECb=yVP_Qxi@&+%t7 z;jBM@{6Z#Qze%j$l&{|`)^E<$Z_PQj{jTHJ9lz-+S_pgdLz-~;3jW@_e^B%fF4#Z! zJ)8H9h`y0RePf}e8+E?0`T1WZ|08+n)RnWB&*t0uKW*#3-Ck(xyz=_x*C8pk_1_rG zwGBP77~0$yY(<&~Y{>h!iT-UD><^6uU0c){?7PXk&7Jw?O=9z=8*6UFf0g=0>isjh z=9ey*@6@;5ZR)x)k{vp-+%%E(P82Dk!4KVX)cp_LSnxDn+_A7@vHNFz@Alp3%B>l= zIhG%MNgRABKX_CeJeqy=JGsH|vgg>{y0tf`?BK!Wy78=Q{Bw8P)sgJhW6SPHmX16z z5}sh*y-jp)D>!QkO*R+4`34yzPhfyJp8*^m#g4zxkK^%}wu}ym35RyYqJYJiTllmpAz@M{bhujV?R3 zXU*IHS~Nl3zwrpeoNGETK>lRZHdar36x_IfBXO&7cO4`??js@NiqW{ep<_)O(klI-=A;{@Q9 z?3gnO-VyP5>aI_bSs^8+2bfh?)(Ud_sWg|=Z7{Y4hF3pa@i{&CqOg^yaG1UM*vfC zBakcAV!&B}iCIQ+W44OaB&!0p7=+q}P+c=tI@d@J%?5^=Q>+j{y^!MI4x`rudVK@E_M;clfI(9=E@OcU!BS-ip?H)BuvkiQ zuRpXiS`O^WhS@@JV?H=421g5r zUdbPdeR?RiIGyht5<7?ToiB==FD`eEWoLxK(BAye;ZKJSUz%2$%KHXH-@vkOFnjb{ zUz+)_))sL5iLlw7MGAWVq=j(#7h^XzWIJ{)JBPC7p)X{MtTPP$orc%|RsP1K0Mj{g zZ!P)esBzRrd}KxRM>fk|H}#Qs+g>|$%WeSrmYV{8y0vU#`@V~}LzJ28N=E1kEZRVv~90!XDLmaMQYVpVSDXLdzEi& zPry>@A(B>(XfqU}Uouo^F3_i;G8%vpp+i>nU|FCO%XXPDEtRA7vJS>Kv}&xV9O0H@ zMFx4pdKimPj%Q~Kc!p)IZB;cf^iUlstyN>i*zI$MvxX|I?MV9% z%lpC~_VY7rBsvw1tW@3=syg~3Sn71m5k<%NlPLid`z&ZTG=kA|3(in>8neRP50V^; z)}?)llP?!}O7jl*CsfUQ5WG+=tt;CKWVN|H=!LW)C+pvr5T$C(BPh92bq0-G0&%Nc z+7pN;>qsL|ijGuNq(d1Bc4)a2nEZcE@cmLi`6;Mf~W#;v6~BPrkX!y30v?=s@&` zPWMP3^lM24jSumh4x9iDm|to)nwQXuzBR0|xo z@}bwd6k`lRS((5^V@WB>N7e`ct~AmWO2w>ILj`33w5)T~SqSOkAhis%Z0Y{Pr6U`U ze_gyK*_9PyAUq4uDqVX-xy_YBaS+8trh)ON-B@19NObc-4qf%6*H#}})O6{(UGe8u zoLyNX91PkxIr!C}$PdBpkT0F_apD#5^wsB9fjoNk+)4%oAXvN(?yPrDrE_kEz{V=R_P}qSx%t{}Hs9<9Z~r!fPTrBPIC;AQ_JIC0})oq5RalTU>{;zAvQOgoaJZ3@d#3T_(2v$#sLnP*Qam*{M!gTGo*8?9@V$d zI-GE8SGKxpMet?IM^NR;w!UxacxjPil48x zZ_+yKSlPf`F#KB}4Wr1AX_ytbK{aV%3K+jvZ39zUo;;R}6^Is!$dvE@V<;|L^<>C$ zT@7S`<gsKI#+HG70*1{D zy98vEcMKQ>#`un&F}<>}Ty&rpJ*EmVJEuuc;NjyBtv~?UC31&39#Ol{3n^nI zBw$QUP4TQCcaxh&?BnS5<#X^lDiTF6gfCW)2sqR!Q0GlGnVj0^H;|k+(d!(%Br{Bq z5-@;!!)XNwqf>-zs`@C_wWb_1fb~azG~>A+K+!+IkN+=Fnu=}pu1A=7`+b-DqItob zZ4BmI>+-HmqH9xuZpqW_B8{&x=q-;aU5$0#@DL1XUBTOUacp5M8`zW&42prl_pM^! z#hiEl{GNiXHfsy4o`2s}d(pOFd)p4?+g*3529P)_)~ua37M$L<&YnM;_4Vd`+eF{C zyl;o-+mZ7P<($KL=RVQ7?{;0zc_{CES#-XfedSv@=eP6Du;>iuoRN9`eNQc1=XmNL z5q1blj1*`ug7k&D#*0%6Q-wf#KF}`)`U~Cb^W8&Y_fWwXxRSh_#Ghv0mF<_e7aE$c zm@k_#vs9#`JKr%Nb_^5-cmD3+e;&LPK^619&7yDfvTtkl(8R-fx)ovyG>R!e<4=4L zQz&>_?l<}h4J`$D`#Y{0-nHK#MgOK7tk|~wp~cYbcxW(pTAvVRm#s*cEw(S7xMh4& z8F|>Q+#lTBnyuY^TR^a6LIajmMjrMVAo8$zbUX3U015O*+b#Qa)GhtCz0Xs(o;Lt} zTSoyuxLz`aLQEc`CK}5=5YFU{H+Y!$AG(cQbmP24( zYQUF(y$SgLLYzGX(t1${kwaUYLud#7#;9B!BfZ7dAWj=l=zovub#mz@vBXz!ZELCd zSs@A+&-j0lq2p|P1}>VF+txq|*`3{pq20#wpNfi7qRd!Tm)GLP!CH=8fDlVVYYs6zHNyw-9 zFXllQ`D~os!My`Kgn9THut{*IM3N766sd>1Lvob-Z(`3qV$D6G`5w`FkJxaJ*!;i5 z3-<`$J!1Dg;`w{Tv-gNipIJN?_T?5T4cMrbMJLnS1LE?IgI>Z|&OIw>G+qFC7 zDs>3Ha);oHBy<#YKovVnBg)b(IkNRLSM7!HrQshr?$p#>;4Xz1N0w{4@Ax|wnV;J3 z1UeUmpElpo8nyjdXvAtSQt-WRa}*8mg$~y=Uh*v1izbvZqm*~SQM90x6-~7bQ8XGB Qs*gJ5d#D47Lotv42h@_%S^xk5 literal 0 HcmV?d00001 diff --git a/Backend/src/auth/routes/__pycache__/auth_routes.cpython-312.pyc b/Backend/src/auth/routes/__pycache__/auth_routes.cpython-312.pyc index 4f2206e4efed9db0c08f1edf63422acb36430899..6cf040d8077cfb7542de0da0a94f467d9188608b 100644 GIT binary patch delta 7851 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 delta 10451 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 datetime.utcnow() + ).order_by(AdminSession.last_activity.desc()).first() + + if active_session: + session_token = active_session.session_token + else: + new_session = admin_security_service.create_session( + db=db, + user_id=current_user.id, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get('User-Agent') + ) + session_token = new_session.session_token + + # Verify MFA if token provided + if mfa_token: + try: + is_valid = mfa_service.verify_mfa(db, current_user.id, mfa_token) + if not is_valid: + raise HTTPException(status_code=401, detail='Invalid MFA token') + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + # Or verify password if provided + elif password: + import bcrypt + if not bcrypt.checkpw(password.encode('utf-8'), current_user.password.encode('utf-8')): + raise HTTPException(status_code=401, detail='Invalid password') + else: + raise HTTPException(status_code=400, detail='Either mfa_token or password is required') + + # Complete step-up authentication + success = admin_security_service.complete_step_up( + db=db, + session_token=session_token, + user_id=current_user.id + ) + + if not success: + raise HTTPException(status_code=400, detail='Failed to complete step-up authentication') + + # Log step-up activity + client_ip = request.client.host if request.client else None + user_agent = request.headers.get('User-Agent') + + admin_security_service.log_activity( + db=db, + user_id=current_user.id, + activity_type='step_up_authentication', + activity_description='Admin step-up authentication completed', + ip_address=client_ip, + user_agent=user_agent, + risk_level='low', + metadata={'method': 'mfa' if mfa_token else 'password'} + ) + + db.commit() + + return success_response( + data={'step_up_completed': True}, + message='Step-up authentication completed successfully' + ) + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f'Error verifying admin step-up: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get('/sessions') +async def get_active_sessions( + current_user: User = Depends(authorize_roles('admin')), + db: Session = Depends(get_db) +): + """Get active admin sessions for current user.""" + try: + from ..models.admin_session import AdminSession + + sessions = db.query(AdminSession).filter( + AdminSession.user_id == current_user.id, + AdminSession.is_active == True + ).order_by(AdminSession.last_activity.desc()).all() + + session_list = [] + for session in sessions: + session_list.append({ + 'id': session.id, + 'ip_address': session.ip_address, + 'user_agent': session.user_agent, + 'country': session.country, + 'city': session.city, + 'last_activity': session.last_activity.isoformat() if session.last_activity else None, + 'step_up_authenticated': session.step_up_authenticated, + 'step_up_expires_at': session.step_up_expires_at.isoformat() if session.step_up_expires_at else None, + 'created_at': session.created_at.isoformat() if session.created_at else None, + 'expires_at': session.expires_at.isoformat() if session.expires_at else None + }) + + return success_response(data={'sessions': session_list}) + except Exception as e: + logger.error(f'Error fetching admin sessions: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post('/sessions/{session_id}/revoke') +async def revoke_session( + session_id: int, + current_user: User = Depends(authorize_roles('admin')), + db: Session = Depends(get_db) +): + """Revoke a specific admin session.""" + try: + from ..models.admin_session import AdminSession + + session = db.query(AdminSession).filter( + AdminSession.id == session_id, + AdminSession.user_id == current_user.id + ).first() + + if not session: + raise HTTPException(status_code=404, detail='Session not found') + + session.is_active = False + db.commit() + + return success_response(message='Session revoked successfully') + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f'Error revoking admin session: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post('/sessions/revoke-all') +async def revoke_all_sessions( + current_user: User = Depends(authorize_roles('admin')), + db: Session = Depends(get_db) +): + """Revoke all active admin sessions for current user.""" + try: + count = admin_security_service.revoke_all_user_sessions(db, current_user.id) + db.commit() + + return success_response( + data={'revoked_count': count}, + message=f'Successfully revoked {count} active session(s)' + ) + except Exception as e: + db.rollback() + logger.error(f'Error revoking all admin sessions: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get('/activity-logs') +async def get_activity_logs( + page: int = Query(1, ge=1), + limit: int = Query(50, ge=1, le=100), + risk_level: Optional[str] = Query(None), + is_unusual: Optional[bool] = Query(None), + current_user: User = Depends(authorize_roles('admin')), + db: Session = Depends(get_db) +): + """Get activity logs for current admin user.""" + try: + from ..models.admin_session import AdminActivityLog + + query = db.query(AdminActivityLog).filter( + AdminActivityLog.user_id == current_user.id + ) + + if risk_level: + query = query.filter(AdminActivityLog.risk_level == risk_level) + if is_unusual is not None: + query = query.filter(AdminActivityLog.is_unusual == is_unusual) + + total = query.count() + offset = (page - 1) * limit + logs = query.order_by(AdminActivityLog.created_at.desc()).offset(offset).limit(limit).all() + + log_list = [] + for log in logs: + log_list.append({ + 'id': log.id, + 'activity_type': log.activity_type, + 'activity_description': log.activity_description, + 'ip_address': log.ip_address, + 'user_agent': log.user_agent, + 'country': log.country, + 'city': log.city, + 'risk_level': log.risk_level, + 'is_unusual': log.is_unusual, + 'activity_metadata': log.activity_metadata, + 'created_at': log.created_at.isoformat() if log.created_at else None + }) + + return success_response(data={ + 'logs': log_list, + 'pagination': { + 'total': total, + 'page': page, + 'limit': limit, + 'total_pages': (total + limit - 1) // limit + } + }) + except Exception as e: + logger.error(f'Error fetching admin activity logs: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + diff --git a/Backend/src/auth/routes/auth_routes.py b/Backend/src/auth/routes/auth_routes.py index cc18eea4..a84e7e25 100644 --- a/Backend/src/auth/routes/auth_routes.py +++ b/Backend/src/auth/routes/auth_routes.py @@ -41,98 +41,6 @@ def get_limiter(request: Request) -> Limiter: return limiter -@router.post('/admin/step-up/verify') -async def verify_admin_step_up( - request: Request, - step_up_data: dict, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """ - Step-up verification for admins: accept password or MFA token. - Uses the accountant security session store but bypasses accountant role checks. - """ - if not is_admin(current_user, db): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Forbidden') - - try: - from ...payments.models.accountant_session import AccountantSession - - mfa_token = step_up_data.get('mfa_token') - password = step_up_data.get('password') - session_token = step_up_data.get('session_token') - - if not session_token: - session_token = request.headers.get('X-Session-Token') or request.cookies.get('session_token') - - if not session_token: - active_session = db.query(AccountantSession).filter( - AccountantSession.user_id == current_user.id, - AccountantSession.is_active == True, - AccountantSession.expires_at > datetime.utcnow() - ).order_by(AccountantSession.last_activity.desc()).first() - - if active_session: - session_token = active_session.session_token - else: - new_session = accountant_security_service.create_session( - db=db, - user_id=current_user.id, - ip_address=request.client.host if request.client else None, - user_agent=request.headers.get('User-Agent') - ) - session_token = new_session.session_token - - if mfa_token: - try: - is_valid = mfa_service.verify_mfa(db, current_user.id, mfa_token) - if not is_valid: - raise HTTPException(status_code=401, detail='Invalid MFA token') - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - elif password: - import bcrypt - if not bcrypt.checkpw(password.encode('utf-8'), current_user.password.encode('utf-8')): - raise HTTPException(status_code=401, detail='Invalid password') - else: - raise HTTPException(status_code=400, detail='Either mfa_token or password is required') - - success = accountant_security_service.complete_step_up( - db=db, - session_token=session_token, - user_id=current_user.id - ) - - if not success: - raise HTTPException(status_code=400, detail='Failed to complete step-up authentication') - - client_ip = request.client.host if request.client else None - user_agent = request.headers.get('User-Agent') - - accountant_security_service.log_activity( - db=db, - user_id=current_user.id, - activity_type='admin_step_up_authentication', - activity_description='Admin step-up authentication completed', - ip_address=client_ip, - user_agent=user_agent, - risk_level='low', - metadata={'method': 'mfa' if mfa_token else 'password'} - ) - - db.commit() - - return JSONResponse( - status_code=status.HTTP_200_OK, - content={'status': 'success', 'data': {'step_up_completed': True}, 'message': 'Step-up authentication completed successfully'} - ) - except HTTPException: - raise - except Exception as e: - db.rollback() - logger.error(f'Error verifying admin step-up: {str(e)}', exc_info=True) - raise HTTPException(status_code=500, detail=str(e)) - def get_base_url(request: Request) -> str: return os.getenv('SERVER_URL') or f'http://{request.headers.get('host', 'localhost:8000')}' @@ -352,14 +260,17 @@ async def login( ) return {'status': 'success', 'requires_mfa': True, 'user_id': result['user_id']} - # After successful login (MFA passed if required), check MFA for accountant role + # After successful login (MFA passed if required), handle role-specific security if not requires_mfa_setup: user = db.query(User).filter(User.id == result['user']['id']).first() if user: try: from ...payments.services.accountant_security_service import accountant_security_service - from ...shared.utils.role_helpers import is_accountant + from ...auth.services.admin_security_service import admin_security_service + from ...shared.utils.role_helpers import is_accountant, is_admin + from ...shared.config.settings import settings + # Handle accountant role if is_accountant(user, db): # Check if MFA is required but not enabled is_enforced, reason = accountant_security_service.is_mfa_enforced(user, db) @@ -378,23 +289,20 @@ async def login( status='success' ) logger.info(f'User {user.id} logged in but MFA setup required: {reason}') - # Always create an accountant security session so step-up auth - # works even if MFA is not yet enabled (password re-auth fallback). + # Always create an accountant security session so step-up auth works try: accountant_session = accountant_security_service.create_session( db=db, user_id=user.id, ip_address=client_ip, user_agent=user_agent, - device_fingerprint=None # Can be enhanced with device fingerprinting + device_fingerprint=None ) - # Commit the session to database so it's available for step-up auth db.commit() # Store session_token in cookie for step-up authentication - from ...shared.config.settings import settings - session_max_age = 4 * 60 * 60 # 4 hours (matches ACCOUNTANT_SESSION_TIMEOUT_HOURS) + session_max_age = 4 * 60 * 60 # 4 hours samesite_value = 'strict' if settings.is_production else 'lax' response.set_cookie( key='session_token', @@ -417,7 +325,7 @@ async def login( db=db, user_id=user.id, activity_type='login', - activity_description='Accountant/admin login successful', + activity_description='Accountant login successful', ip_address=client_ip, user_agent=user_agent, risk_level='low', @@ -426,8 +334,55 @@ async def login( except Exception as e: db.rollback() logger.warning(f'Error creating accountant session: {e}') + + # Handle admin role (separate from accountant) + elif is_admin(user, db): + try: + admin_session = admin_security_service.create_session( + db=db, + user_id=user.id, + ip_address=client_ip, + user_agent=user_agent, + device_fingerprint=None + ) + + db.commit() + + # Store admin session_token in cookie for step-up authentication + session_max_age = 8 * 60 * 60 # 8 hours + samesite_value = 'strict' if settings.is_production else 'lax' + response.set_cookie( + key='admin_session_token', + value=admin_session.session_token, + httponly=True, + secure=settings.is_production, + samesite=samesite_value, + max_age=session_max_age, + path='/' + ) + + # Log login activity + is_unusual = admin_security_service.detect_unusual_activity( + db=db, + user_id=user.id, + ip_address=client_ip + ) + + admin_security_service.log_activity( + db=db, + user_id=user.id, + activity_type='login', + activity_description='Admin login successful', + ip_address=client_ip, + user_agent=user_agent, + risk_level='low', + is_unusual=is_unusual + ) + except Exception as e: + db.rollback() + logger.warning(f'Error creating admin session: {e}') except Exception as e: - logger.warning(f'Error enforcing MFA for accountant: {e}') + logger.warning(f'Error handling role-specific security: {e}') from ...shared.config.settings import settings max_age = 7 * 24 * 60 * 60 if login_request.rememberMe else 1 * 24 * 60 * 60 diff --git a/Backend/src/auth/services/__pycache__/admin_security_service.cpython-312.pyc b/Backend/src/auth/services/__pycache__/admin_security_service.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a7d968b12d05d4746534ef8f58fa7b960c277c21 GIT binary patch literal 10949 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 literal 0 HcmV?d00001 diff --git a/Backend/src/auth/services/admin_security_service.py b/Backend/src/auth/services/admin_security_service.py new file mode 100644 index 00000000..4ca4e809 --- /dev/null +++ b/Backend/src/auth/services/admin_security_service.py @@ -0,0 +1,296 @@ +""" +Service for admin-specific security controls: step-up auth, session management, activity logs. +Separate from accountant security to maintain clear separation of concerns. +""" +from sqlalchemy.orm import Session +from typing import Optional, Dict, Any, Tuple +from datetime import datetime, timedelta +from ...auth.models.user import User +from ..models.admin_session import AdminSession, AdminActivityLog +from ...shared.utils.role_helpers import is_admin +from ...shared.config.logging_config import get_logger +import secrets + +logger = get_logger(__name__) + + +class AdminSecurityService: + """Service for admin security controls.""" + + # Session timeout for admins (shorter than default user sessions) + ADMIN_SESSION_TIMEOUT_HOURS = 8 # 8 hours for admin sessions + ADMIN_IDLE_TIMEOUT_MINUTES = 60 # 60 minutes idle timeout + + # Step-up authentication validity + STEP_UP_VALIDITY_MINUTES = 15 # Step-up auth valid for 15 minutes + + @staticmethod + def requires_mfa(user: User, db: Session) -> bool: + """Check if admin user requires MFA (optional for admins, not enforced).""" + # MFA is optional for admins, but recommended + return False # Not enforced, but can be enabled + + @staticmethod + def is_mfa_enforced(user: User, db: Session) -> Tuple[bool, Optional[str]]: + """ + Check if MFA is enforced for admin user. + Returns (is_enforced: bool, reason: str | None) + Note: MFA is optional for admins, not enforced by default. + """ + # Admins can use MFA if enabled, but it's not required + return False, None + + @staticmethod + def create_session( + db: Session, + user_id: int, + ip_address: Optional[str] = None, + user_agent: Optional[str] = None, + device_fingerprint: Optional[str] = None, + country: Optional[str] = None, + city: Optional[str] = None + ) -> AdminSession: + """Create a new admin session.""" + # Generate session token + session_token = secrets.token_urlsafe(32) + + # Calculate expiration + expires_at = datetime.utcnow() + timedelta(hours=AdminSecurityService.ADMIN_SESSION_TIMEOUT_HOURS) + + session = AdminSession( + user_id=user_id, + session_token=session_token, + ip_address=ip_address, + user_agent=user_agent, + device_fingerprint=device_fingerprint, + country=country, + city=city, + is_active=True, + last_activity=datetime.utcnow(), + step_up_authenticated=False, + expires_at=expires_at + ) + + db.add(session) + db.flush() + + return session + + @staticmethod + def validate_session( + db: Session, + session_token: str, + update_activity: bool = True + ) -> Optional[AdminSession]: + """Validate and update session activity.""" + session = db.query(AdminSession).filter( + AdminSession.session_token == session_token, + AdminSession.is_active == True + ).first() + + if not session: + return None + + # Check if session expired + if session.expires_at < datetime.utcnow(): + session.is_active = False + db.flush() + return None + + # Check idle timeout + idle_timeout = datetime.utcnow() - timedelta(minutes=AdminSecurityService.ADMIN_IDLE_TIMEOUT_MINUTES) + if session.last_activity < idle_timeout: + session.is_active = False + db.flush() + return None + + # Update last activity + if update_activity: + session.last_activity = datetime.utcnow() + db.flush() + + return session + + @staticmethod + def require_step_up( + db: Session, + user_id: int, + session_token: Optional[str] = None, + action_description: str = "high-risk action" + ) -> Tuple[bool, Optional[str]]: + """ + Check if step-up authentication is required for admin action. + Returns (requires_step_up: bool, reason: str | None) + """ + # If no session token provided, try to find the most recent active session for this user + if not session_token: + active_session = db.query(AdminSession).filter( + AdminSession.user_id == user_id, + AdminSession.is_active == True, + AdminSession.expires_at > datetime.utcnow() + ).order_by(AdminSession.last_activity.desc()).first() + + if active_session: + session_token = active_session.session_token + else: + return True, "Step-up authentication required for this action" + + session = AdminSecurityService.validate_session(db, session_token, update_activity=False) + if not session: + return True, "Invalid or expired session" + + if session.user_id != user_id: + return True, "Session user mismatch" + + # Check if step-up is still valid + if session.step_up_authenticated and session.step_up_expires_at: + if session.step_up_expires_at > datetime.utcnow(): + return False, None # Step-up still valid + else: + session.step_up_authenticated = False + db.flush() + + return True, f"Step-up authentication required for {action_description}" + + @staticmethod + def complete_step_up( + db: Session, + session_token: str, + user_id: int + ) -> bool: + """Mark step-up authentication as completed.""" + session = db.query(AdminSession).filter( + AdminSession.session_token == session_token, + AdminSession.user_id == user_id, + AdminSession.is_active == True + ).first() + + if not session: + return False + + session.step_up_authenticated = True + session.step_up_expires_at = datetime.utcnow() + timedelta( + minutes=AdminSecurityService.STEP_UP_VALIDITY_MINUTES + ) + + # Use flush to ensure changes are visible in the same transaction + db.flush() + return True + + @staticmethod + def log_activity( + db: Session, + user_id: int, + activity_type: str, + activity_description: str, + session_id: Optional[int] = None, + ip_address: Optional[str] = None, + user_agent: Optional[str] = None, + country: Optional[str] = None, + city: Optional[str] = None, + risk_level: str = 'low', + is_unusual: bool = False, + metadata: Optional[Dict[str, Any]] = None + ) -> AdminActivityLog: + """Log admin activity for security monitoring.""" + log = AdminActivityLog( + user_id=user_id, + session_id=session_id, + activity_type=activity_type, + activity_description=activity_description, + ip_address=ip_address, + user_agent=user_agent, + country=country, + city=city, + risk_level=risk_level, + is_unusual=is_unusual, + activity_metadata=metadata or {} + ) + + db.add(log) + db.flush() + + # Alert on high-risk or unusual activity + if risk_level in ['high', 'critical'] or is_unusual: + logger.warning( + f"High-risk admin activity detected: {activity_type} by user {user_id}", + extra={ + 'user_id': user_id, + 'activity_type': activity_type, + 'risk_level': risk_level, + 'is_unusual': is_unusual, + 'ip_address': ip_address + } + ) + + return log + + @staticmethod + def detect_unusual_activity( + db: Session, + user_id: int, + ip_address: Optional[str] = None, + country: Optional[str] = None + ) -> bool: + """Detect if current activity is unusual based on user history.""" + # Get user's recent activity (last 30 days) + thirty_days_ago = datetime.utcnow() - timedelta(days=30) + + recent_activities = db.query(AdminActivityLog).filter( + AdminActivityLog.user_id == user_id, + AdminActivityLog.created_at >= thirty_days_ago + ).all() + + if not recent_activities: + # First activity - not unusual + return False + + # Check for new IP address + if ip_address: + known_ips = set(act.ip_address for act in recent_activities if act.ip_address) + if ip_address not in known_ips and len(known_ips) > 0: + return True + + # Check for new country + if country: + known_countries = set(act.country for act in recent_activities if act.country) + if country not in known_countries and len(known_countries) > 0: + return True + + return False + + @staticmethod + def revoke_session( + db: Session, + session_token: str + ) -> bool: + """Revoke an admin session.""" + session = db.query(AdminSession).filter( + AdminSession.session_token == session_token + ).first() + + if not session: + return False + + session.is_active = False + db.flush() + return True + + @staticmethod + def revoke_all_user_sessions( + db: Session, + user_id: int + ) -> int: + """Revoke all active sessions for an admin user.""" + count = db.query(AdminSession).filter( + AdminSession.user_id == user_id, + AdminSession.is_active == True + ).update({'is_active': False}) + + db.flush() + return count + + +# Singleton instance +admin_security_service = AdminSecurityService() + diff --git a/Backend/src/hotel_services/routes/__pycache__/staff_shift_routes.cpython-312.pyc b/Backend/src/hotel_services/routes/__pycache__/staff_shift_routes.cpython-312.pyc index 6308753dc23500cbbd72df7e7acde269f7df3d03..169c31b0288cef18eae7ca988ec22625efcbc080 100644 GIT binary patch delta 11654 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 ass(Ira#r>T_WzV3*sJ=c3hN})z5fA;|2mlf delta 5737 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 diff --git a/Backend/src/hotel_services/routes/staff_shift_routes.py b/Backend/src/hotel_services/routes/staff_shift_routes.py index e6f14984..0328df4f 100644 --- a/Backend/src/hotel_services/routes/staff_shift_routes.py +++ b/Backend/src/hotel_services/routes/staff_shift_routes.py @@ -12,6 +12,8 @@ from ..models.staff_shift import ( StaffShift, StaffTask, ShiftType, ShiftStatus, StaffTaskPriority, StaffTaskStatus ) +from ..services.shift_automation_service import shift_automation_service +from ..services.shift_scheduler import get_shift_scheduler logger = get_logger(__name__) router = APIRouter(prefix='/staff-shifts', tags=['staff-shifts']) @@ -24,21 +26,27 @@ async def get_shifts( status: Optional[str] = Query(None), department: Optional[str] = Query(None), page: int = Query(1, ge=1), - limit: int = Query(20, ge=1, le=100), + limit: int = Query(20, ge=1), current_user: User = Depends(authorize_roles('admin', 'staff')), db: Session = Depends(get_db) ): """Get staff shifts with filtering""" try: + # Check if user is admin or staff to set appropriate limit + role = db.query(Role).filter(Role.id == current_user.role_id).first() + is_admin = role and role.name == 'admin' + is_staff = role and role.name == 'staff' + + # Admin can request more records for analytics, staff is limited to 100 + max_limit = 1000 if is_admin else 100 + if limit > max_limit: + limit = max_limit + query = db.query(StaffShift).options( joinedload(StaffShift.staff), joinedload(StaffShift.assigner) ) - # Check if user is staff (not admin) - staff should only see their own shifts - role = db.query(Role).filter(Role.id == current_user.role_id).first() - is_staff = role and role.name == 'staff' - if is_staff: query = query.filter(StaffShift.staff_id == current_user.id) elif staff_id: @@ -117,13 +125,14 @@ async def create_shift( if not staff_id: staff_id = current_user.id + # Always create shifts as 'scheduled' - automation will handle status changes shift = StaffShift( staff_id=staff_id, shift_date=datetime.fromisoformat(shift_data['shift_date'].replace('Z', '+00:00')), shift_type=ShiftType(shift_data.get('shift_type', 'custom')), start_time=time.fromisoformat(shift_data['start_time']) if isinstance(shift_data.get('start_time'), str) else shift_data.get('start_time'), end_time=time.fromisoformat(shift_data['end_time']) if isinstance(shift_data.get('end_time'), str) else shift_data.get('end_time'), - status=ShiftStatus(shift_data.get('status', 'scheduled')), + status=ShiftStatus.scheduled, # Always start as scheduled - automation handles the rest break_duration_minutes=shift_data.get('break_duration_minutes', 30), department=shift_data.get('department'), notes=shift_data.get('notes'), @@ -167,7 +176,14 @@ async def update_shift( if not is_admin and shift.staff_id != current_user.id: raise HTTPException(status_code=403, detail='You can only update your own shifts') - # Update fields + # Update fields - status changes are handled automatically by scheduler + # Only allow editing of scheduled or cancelled shifts (not in_progress or completed) + if shift.status in [ShiftStatus.in_progress, ShiftStatus.completed]: + raise HTTPException( + status_code=400, + detail='Cannot edit shift that is in progress or completed. Only scheduled or cancelled shifts can be edited.' + ) + if 'shift_date' in shift_data: shift.shift_date = datetime.fromisoformat(shift_data['shift_date'].replace('Z', '+00:00')) if 'shift_type' in shift_data: @@ -176,12 +192,7 @@ async def update_shift( shift.start_time = time.fromisoformat(shift_data['start_time']) if isinstance(shift_data['start_time'], str) else shift_data['start_time'] if 'end_time' in shift_data: shift.end_time = time.fromisoformat(shift_data['end_time']) if isinstance(shift_data['end_time'], str) else shift_data['end_time'] - if 'status' in shift_data: - shift.status = ShiftStatus(shift_data['status']) - if shift_data['status'] == 'in_progress' and not shift.actual_start_time: - shift.actual_start_time = datetime.utcnow() - elif shift_data['status'] == 'completed' and not shift.actual_end_time: - shift.actual_end_time = datetime.utcnow() + # Status changes are automatic - do not allow manual status updates via update endpoint if 'break_duration_minutes' in shift_data: shift.break_duration_minutes = shift_data['break_duration_minutes'] if 'department' in shift_data: @@ -462,3 +473,180 @@ async def get_workload_summary( logger.error(f'Error fetching workload: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=f'Failed to fetch workload: {str(e)}') + +@router.delete('/{shift_id}') +async def delete_shift( + shift_id: int, + current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')), + db: Session = Depends(get_db) +): + """Delete a shift - admin can delete any, staff/housekeeping can delete their own""" + try: + shift = db.query(StaffShift).filter(StaffShift.id == shift_id).first() + if not shift: + raise HTTPException(status_code=404, detail='Shift not found') + + # Check permissions - admin can delete any, staff/housekeeping can delete their own + role = db.query(Role).filter(Role.id == current_user.role_id).first() + is_admin = role and role.name == 'admin' + is_staff = role and role.name in ['staff', 'housekeeping'] + + if not is_admin and (not is_staff or shift.staff_id != current_user.id): + raise HTTPException( + status_code=403, + detail='You can only delete your own shifts' + ) + + # Cannot delete completed shifts + if shift.status == ShiftStatus.completed: + raise HTTPException( + status_code=400, + detail='Cannot delete completed shifts. Completed shifts are archived for record keeping.' + ) + + # Log deletion in audit log + try: + from ...analytics.models.audit_log import AuditLog + audit_log = AuditLog( + user_id=current_user.id, + action="shift_deleted", + resource_type="staff_shift", + resource_id=shift_id, + details={ + "shift_date": shift.shift_date.isoformat() if shift.shift_date else None, + "staff_id": shift.staff_id, + "status": shift.status.value, + "deleted_at": datetime.utcnow().isoformat() + }, + status="success" + ) + db.add(audit_log) + except Exception as e: + logger.warning(f"Failed to log shift deletion: {str(e)}") + + db.delete(shift) + db.commit() + + logger.info(f"Shift {shift_id} deleted by user {current_user.id}") + + return { + 'status': 'success', + 'message': 'Shift deleted successfully' + } + except HTTPException: + raise + except Exception as e: + logger.error(f'Error deleting shift: {str(e)}', exc_info=True) + db.rollback() + raise HTTPException(status_code=500, detail=f'Failed to delete shift: {str(e)}') + + +# Removed manual start/complete endpoints - automation handles these automatically + + +@router.post('/{shift_id}/cancel') +async def cancel_shift( + shift_id: int, + cancel_data: dict = {}, + current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')), + db: Session = Depends(get_db) +): + """ + Cancel a shift - only manual action allowed + Admin can cancel any shift, staff/housekeeping can cancel their own shifts + """ + try: + shift = db.query(StaffShift).filter(StaffShift.id == shift_id).first() + if not shift: + raise HTTPException(status_code=404, detail='Shift not found') + + # Check permissions - admin can cancel any, staff/housekeeping can cancel their own + role = db.query(Role).filter(Role.id == current_user.role_id).first() + is_admin = role and role.name == 'admin' + is_staff = role and role.name in ['staff', 'housekeeping'] + + if not is_admin and (not is_staff or shift.staff_id != current_user.id): + raise HTTPException( + status_code=403, + detail='You can only cancel your own shifts' + ) + + # Only allow cancellation of scheduled or in_progress shifts + if shift.status not in [ShiftStatus.scheduled, ShiftStatus.in_progress]: + raise HTTPException( + status_code=400, + detail=f'Cannot cancel shift with status: {shift.status.value}. Only scheduled or in-progress shifts can be cancelled.' + ) + + reason = cancel_data.get('reason', 'MANUAL_CANCEL') + notes = cancel_data.get('notes') + + success = shift_automation_service.manual_status_change( + db=db, + shift=shift, + new_status=ShiftStatus.cancelled, + user_id=current_user.id, + reason=reason, + notes=notes + ) + + if not success: + raise HTTPException( + status_code=400, + detail=f'Cannot cancel shift. Current status: {shift.status.value}' + ) + + db.refresh(shift) + + return { + 'status': 'success', + 'message': 'Shift cancelled successfully', + 'data': { + 'shift_id': shift.id, + 'status': shift.status.value, + } + } + except HTTPException: + raise + except Exception as e: + logger.error(f'Error cancelling shift: {str(e)}', exc_info=True) + db.rollback() + raise HTTPException(status_code=500, detail=f'Failed to cancel shift: {str(e)}') + + +@router.post('/automation/trigger') +async def trigger_automation( + current_user: User = Depends(authorize_roles('admin')), + db: Session = Depends(get_db) +): + """Manually trigger shift automation (admin only)""" + try: + stats = shift_automation_service.process_shift_automation(db) + + return { + 'status': 'success', + 'message': 'Shift automation completed', + 'data': stats + } + except Exception as e: + logger.error(f'Error triggering automation: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=f'Failed to trigger automation: {str(e)}') + + +@router.get('/automation/status') +async def get_automation_status( + current_user: User = Depends(authorize_roles('admin')), +): + """Get shift automation scheduler status (admin only)""" + try: + scheduler = get_shift_scheduler() + status = scheduler.get_status() + + return { + 'status': 'success', + 'data': status + } + except Exception as e: + logger.error(f'Error getting automation status: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=f'Failed to get automation status: {str(e)}') + diff --git a/Backend/src/hotel_services/services/__pycache__/shift_automation_service.cpython-312.pyc b/Backend/src/hotel_services/services/__pycache__/shift_automation_service.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e91ea079b1ce8d81d2b55ab021a835fc880879f7 GIT binary patch literal 22169 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 Tuple[datetime, datetime]: + """ + Convert shift date and time to full datetime objects + Returns (start_datetime, end_datetime) + """ + shift_date = shift.shift_date.date() if isinstance(shift.shift_date, datetime) else shift.shift_date + + # Combine date with time + start_datetime = datetime.combine(shift_date, shift.start_time) + end_datetime = datetime.combine(shift_date, shift.end_time) + + # Handle overnight shifts (end time is next day) + if shift.end_time < shift.start_time: + end_datetime += timedelta(days=1) + + return start_datetime, end_datetime + + @staticmethod + def should_auto_start(shift: StaffShift, current_time: datetime) -> bool: + """Check if shift should be automatically started - at exact start time""" + if shift.status != ShiftStatus.scheduled: + return False + + start_datetime, _ = ShiftAutomationService.get_shift_datetime(shift) + + # Auto-start at exact start time (or after) + return current_time >= start_datetime + + @staticmethod + def should_auto_complete(shift: StaffShift, current_time: datetime) -> bool: + """Check if shift should be automatically completed - at exact end time""" + if shift.status != ShiftStatus.in_progress: + return False + + _, end_datetime = ShiftAutomationService.get_shift_datetime(shift) + + # Auto-complete at exact end time (or after) + return current_time >= end_datetime + + @staticmethod + def should_mark_no_show(shift: StaffShift, current_time: datetime) -> bool: + """Check if shift should be marked as no_show""" + if shift.status != ShiftStatus.scheduled: + return False + + start_datetime, _ = ShiftAutomationService.get_shift_datetime(shift) + + # Mark as no_show if past start time + threshold and no actual start recorded + no_show_time = start_datetime + timedelta(minutes=ShiftAutomationService.NO_SHOW_THRESHOLD_MINUTES) + + return current_time >= no_show_time and shift.actual_start_time is None + + @staticmethod + def auto_start_shift( + db: Session, + shift: StaffShift, + current_time: Optional[datetime] = None, + triggered_by: Optional[int] = None + ) -> bool: + """ + Automatically start a shift + Returns True if status was changed, False otherwise + """ + if shift.status != ShiftStatus.scheduled: + return False + + current_time = current_time or datetime.utcnow() + + try: + old_status = shift.status + shift.status = ShiftStatus.in_progress + shift.actual_start_time = current_time + shift.updated_at = current_time + + db.commit() + + # Log audit trail + ShiftAutomationService._log_status_change( + db=db, + shift_id=shift.id, + old_status=old_status, + new_status=ShiftStatus.in_progress, + user_id=triggered_by, + reason="AUTO_START", + details={"triggered_at": current_time.isoformat()} + ) + + # Send notification to staff (with fallback) + try: + try: + NotificationService.send_notification( + db=db, + user_id=shift.staff_id, + notification_type=NotificationType.shift_update, + channel=NotificationChannel.in_app, + content=f"Your shift has automatically started at {current_time.strftime('%H:%M')}", + subject="Shift Started", + meta_data={ + "shift_id": shift.id, + "status": "in_progress", + "auto_started": True + } + ) + except Exception as enum_error: + # Fallback to system_alert if shift_update enum not in database yet + NotificationService.send_notification( + db=db, + user_id=shift.staff_id, + notification_type=NotificationType.system_alert, + channel=NotificationChannel.in_app, + content=f"Your shift has automatically started at {current_time.strftime('%H:%M')}", + subject="Shift Started", + meta_data={ + "shift_id": shift.id, + "status": "in_progress", + "auto_started": True + } + ) + except Exception as e: + logger.warning(f"Failed to send notification for shift {shift.id}: {str(e)}") + try: + db.rollback() + except: + pass + + logger.info(f"Shift {shift.id} automatically started at {current_time.isoformat()}") + return True + + except Exception as e: + logger.error(f"Error auto-starting shift {shift.id}: {str(e)}", exc_info=True) + db.rollback() + return False + + @staticmethod + def auto_complete_shift( + db: Session, + shift: StaffShift, + current_time: Optional[datetime] = None, + triggered_by: Optional[int] = None + ) -> bool: + """ + Automatically complete a shift + Returns True if status was changed, False otherwise + """ + if shift.status != ShiftStatus.in_progress: + return False + + current_time = current_time or datetime.utcnow() + + try: + old_status = shift.status + shift.status = ShiftStatus.completed + shift.actual_end_time = current_time + shift.updated_at = current_time + + # If no actual start time was recorded, set it to scheduled start + if not shift.actual_start_time: + start_datetime, _ = ShiftAutomationService.get_shift_datetime(shift) + shift.actual_start_time = start_datetime + + db.commit() + + # Log audit trail + ShiftAutomationService._log_status_change( + db=db, + shift_id=shift.id, + old_status=old_status, + new_status=ShiftStatus.completed, + user_id=triggered_by, + reason="AUTO_COMPLETE", + details={ + "triggered_at": current_time.isoformat(), + "auto_completed": True + } + ) + + # Send notification to staff (with fallback) + try: + try: + NotificationService.send_notification( + db=db, + user_id=shift.staff_id, + notification_type=NotificationType.shift_update, + channel=NotificationChannel.in_app, + content=f"Your shift has been automatically completed at {current_time.strftime('%H:%M')}", + subject="Shift Completed", + meta_data={ + "shift_id": shift.id, + "status": "completed", + "auto_completed": True + } + ) + except Exception as enum_error: + # Fallback to system_alert if shift_update enum not in database yet + NotificationService.send_notification( + db=db, + user_id=shift.staff_id, + notification_type=NotificationType.system_alert, + channel=NotificationChannel.in_app, + content=f"Your shift has been automatically completed at {current_time.strftime('%H:%M')}", + subject="Shift Completed", + meta_data={ + "shift_id": shift.id, + "status": "completed", + "auto_completed": True + } + ) + except Exception as e: + logger.warning(f"Failed to send notification for shift {shift.id}: {str(e)}") + try: + db.rollback() + except: + pass + + logger.info(f"Shift {shift.id} automatically completed at {current_time.isoformat()}") + return True + + except Exception as e: + logger.error(f"Error auto-completing shift {shift.id}: {str(e)}", exc_info=True) + db.rollback() + return False + + @staticmethod + def mark_no_show( + db: Session, + shift: StaffShift, + current_time: Optional[datetime] = None, + triggered_by: Optional[int] = None + ) -> bool: + """ + Mark a shift as no_show + Returns True if status was changed, False otherwise + """ + if shift.status != ShiftStatus.scheduled: + return False + + current_time = current_time or datetime.utcnow() + + try: + old_status = shift.status + shift.status = ShiftStatus.no_show + shift.updated_at = current_time + + db.commit() + + # Log audit trail + ShiftAutomationService._log_status_change( + db=db, + shift_id=shift.id, + old_status=old_status, + new_status=ShiftStatus.no_show, + user_id=triggered_by, + reason="AUTO_NO_SHOW", + details={ + "triggered_at": current_time.isoformat(), + "auto_marked": True + } + ) + + # Send notification to staff and admin (with fallback) + try: + try: + NotificationService.send_notification( + db=db, + user_id=shift.staff_id, + notification_type=NotificationType.shift_update, + channel=NotificationChannel.in_app, + content=f"Your shift was marked as no-show. Please contact your supervisor.", + subject="Shift No-Show", + meta_data={ + "shift_id": shift.id, + "status": "no_show", + "auto_marked": True + } + ) + except Exception as enum_error: + # Fallback to system_alert if shift_update enum not in database yet + NotificationService.send_notification( + db=db, + user_id=shift.staff_id, + notification_type=NotificationType.system_alert, + channel=NotificationChannel.in_app, + content=f"Your shift was marked as no-show. Please contact your supervisor.", + subject="Shift No-Show", + meta_data={ + "shift_id": shift.id, + "status": "no_show", + "auto_marked": True + } + ) + except Exception as e: + logger.warning(f"Failed to send notification for shift {shift.id}: {str(e)}") + try: + db.rollback() + except: + pass + + logger.warning(f"Shift {shift.id} marked as no-show at {current_time.isoformat()}") + return True + + except Exception as e: + logger.error(f"Error marking shift {shift.id} as no-show: {str(e)}", exc_info=True) + db.rollback() + return False + + @staticmethod + def manual_status_change( + db: Session, + shift: StaffShift, + new_status: ShiftStatus, + user_id: int, + reason: Optional[str] = None, + notes: Optional[str] = None + ) -> bool: + """ + Manually change shift status - ONLY for cancellation + All other status changes (start, complete) are handled automatically by scheduler + Returns True if status was changed, False otherwise + """ + old_status = shift.status + current_time = datetime.utcnow() + + # Only allow manual cancellation - all other status changes are automatic + if new_status != ShiftStatus.cancelled: + logger.warning( + f"Manual status change to {new_status.value} not allowed. " + f"Only cancellation is allowed manually. Shift {shift.id} by user {user_id}" + ) + return False + + # Validate status transition (only cancellation allowed) + if not ShiftAutomationService._is_valid_transition(old_status, new_status): + logger.warning( + f"Invalid status transition from {old_status.value} to {new_status.value} " + f"for shift {shift.id} by user {user_id}" + ) + return False + + try: + shift.status = new_status + shift.updated_at = current_time + + # Update timestamps based on status + if new_status == ShiftStatus.in_progress and not shift.actual_start_time: + shift.actual_start_time = current_time + elif new_status == ShiftStatus.completed and not shift.actual_end_time: + shift.actual_end_time = current_time + # If no actual start time, set it to scheduled start + if not shift.actual_start_time: + start_datetime, _ = ShiftAutomationService.get_shift_datetime(shift) + shift.actual_start_time = start_datetime + + # Update notes if provided + if notes: + if shift.notes: + shift.notes += f"\n[{current_time.strftime('%Y-%m-%d %H:%M')}] {notes}" + else: + shift.notes = f"[{current_time.strftime('%Y-%m-%d %H:%M')}] {notes}" + + db.commit() + + # Log audit trail + ShiftAutomationService._log_status_change( + db=db, + shift_id=shift.id, + old_status=old_status, + new_status=new_status, + user_id=user_id, + reason=reason or "MANUAL", + details={ + "notes": notes, + "changed_at": current_time.isoformat() + } + ) + + # Send notification for cancellation (use system_alert as fallback if shift_update not in DB) + try: + cancellation_message = f"Your shift has been cancelled" + if notes: + cancellation_message += f". Reason: {notes}" + + # Try shift_update first, fallback to system_alert if enum not updated in DB + try: + NotificationService.send_notification( + db=db, + user_id=shift.staff_id, + notification_type=NotificationType.shift_update, + channel=NotificationChannel.in_app, + content=cancellation_message, + subject="Shift Cancelled", + meta_data={ + "shift_id": shift.id, + "status": "cancelled", + "changed_by": user_id, + "reason": reason, + "notes": notes + } + ) + except Exception as enum_error: + # Fallback to system_alert if shift_update enum not in database yet + logger.warning(f"shift_update enum not available, using system_alert: {str(enum_error)}") + NotificationService.send_notification( + db=db, + user_id=shift.staff_id, + notification_type=NotificationType.system_alert, + channel=NotificationChannel.in_app, + content=cancellation_message, + subject="Shift Cancelled", + meta_data={ + "shift_id": shift.id, + "status": "cancelled", + "changed_by": user_id, + "reason": reason, + "notes": notes + } + ) + except Exception as e: + # Log but don't fail the operation if notification fails + logger.warning(f"Failed to send notification for shift cancellation: {str(e)}") + # Rollback notification transaction if it failed + try: + db.rollback() + except: + pass + + logger.info( + f"Shift {shift.id} status changed from {old_status.value} to {new_status.value} " + f"by user {user_id}" + ) + return True + + except Exception as e: + shift_id_str = str(shift.id) if shift and hasattr(shift, 'id') else 'unknown' + logger.error(f"Error changing shift {shift_id_str} status: {str(e)}", exc_info=True) + try: + db.rollback() + except: + pass + return False + + @staticmethod + def _is_valid_transition(old_status: ShiftStatus, new_status: ShiftStatus) -> bool: + """ + Validate if a status transition is allowed + Enterprise business rules for status transitions + """ + # Same status is always valid (no-op) + if old_status == new_status: + return True + + # Define valid transitions + valid_transitions = { + ShiftStatus.scheduled: [ + ShiftStatus.in_progress, + ShiftStatus.cancelled, + ShiftStatus.no_show + ], + ShiftStatus.in_progress: [ + ShiftStatus.completed, + ShiftStatus.cancelled + ], + ShiftStatus.completed: [], # Terminal state - cannot transition from + ShiftStatus.cancelled: [], # Terminal state - cannot transition from + ShiftStatus.no_show: [ + ShiftStatus.cancelled # Can cancel a no-show + ] + } + + allowed = valid_transitions.get(old_status, []) + return new_status in allowed + + @staticmethod + def _log_status_change( + db: Session, + shift_id: int, + old_status: ShiftStatus, + new_status: ShiftStatus, + user_id: Optional[int], + reason: str, + details: Optional[Dict[str, Any]] = None + ): + """Log shift status change to audit log""" + try: + audit_log = AuditLog( + user_id=user_id, + action="shift_status_change", + resource_type="staff_shift", + resource_id=shift_id, + details={ + "old_status": old_status.value, + "new_status": new_status.value, + "reason": reason, + **(details or {}) + }, + status="success" + ) + db.add(audit_log) + db.commit() + except Exception as e: + logger.error(f"Failed to log status change for shift {shift_id}: {str(e)}") + # Don't fail the operation if audit logging fails + + @staticmethod + def process_shift_automation(db: Session, current_time: Optional[datetime] = None) -> Dict[str, Any]: + """ + Process all shifts that need automatic status updates + This is the main method called by the scheduler + Returns statistics about processed shifts + """ + current_time = current_time or datetime.utcnow() + stats = { + "processed": 0, + "auto_started": 0, + "auto_completed": 0, + "marked_no_show": 0, + "errors": 0 + } + + try: + # Get all shifts that need processing + # Only process shifts from today and future (not too far in the past) + cutoff_date = current_time.date() - timedelta(days=1) + + shifts_to_process = db.query(StaffShift).filter( + and_( + func.date(StaffShift.shift_date) >= cutoff_date, + StaffShift.status.in_([ShiftStatus.scheduled, ShiftStatus.in_progress]) + ) + ).all() + + logger.info(f"Processing {len(shifts_to_process)} shifts for automation") + + for shift in shifts_to_process: + try: + stats["processed"] += 1 + + # Check for no-show first (highest priority) + if ShiftAutomationService.should_mark_no_show(shift, current_time): + if ShiftAutomationService.mark_no_show(db, shift, current_time): + stats["marked_no_show"] += 1 + continue + + # Check for auto-complete + if ShiftAutomationService.should_auto_complete(shift, current_time): + if ShiftAutomationService.auto_complete_shift(db, shift, current_time): + stats["auto_completed"] += 1 + continue + + # Check for auto-start + if ShiftAutomationService.should_auto_start(shift, current_time): + if ShiftAutomationService.auto_start_shift(db, shift, current_time): + stats["auto_started"] += 1 + continue + + except Exception as e: + logger.error(f"Error processing shift {shift.id}: {str(e)}", exc_info=True) + stats["errors"] += 1 + + logger.info( + f"Shift automation completed: {stats['auto_started']} started, " + f"{stats['auto_completed']} completed, {stats['marked_no_show']} no-shows, " + f"{stats['errors']} errors" + ) + + return stats + + except Exception as e: + logger.error(f"Error in shift automation process: {str(e)}", exc_info=True) + stats["errors"] += 1 + return stats + + +# Singleton instance +shift_automation_service = ShiftAutomationService() + diff --git a/Backend/src/hotel_services/services/shift_scheduler.py b/Backend/src/hotel_services/services/shift_scheduler.py new file mode 100644 index 00000000..de5c224b --- /dev/null +++ b/Backend/src/hotel_services/services/shift_scheduler.py @@ -0,0 +1,120 @@ +""" +Staff Shift Automation Scheduler +Background scheduler for automatic shift status management +""" + +import threading +import time +from datetime import datetime, timedelta +from typing import Optional +from sqlalchemy.orm import Session + +from ...shared.config.database import get_db +from ...shared.config.logging_config import get_logger +from .shift_automation_service import shift_automation_service + +logger = get_logger(__name__) + + +class ShiftScheduler: + """Background scheduler for automatic shift status updates""" + + def __init__(self): + self.running = False + self.thread: Optional[threading.Thread] = None + self.check_interval_seconds = 60 # Check every minute + self.last_run_time: Optional[datetime] = None + + def start(self): + """Start the background shift scheduler""" + if self.running: + logger.warning("Shift Scheduler is already running") + return + + self.running = True + self.thread = threading.Thread(target=self._scheduler_loop, daemon=True) + self.thread.start() + logger.info("Shift Scheduler started - automatic shift management enabled") + + def stop(self): + """Stop the background shift scheduler""" + if not self.running: + return + + self.running = False + if self.thread: + self.thread.join(timeout=5.0) + logger.info("Shift Scheduler stopped") + + def _scheduler_loop(self): + """Main scheduler loop that runs shift automation periodically""" + logger.info("Shift Scheduler loop started") + + # Wait a bit for the app to fully start + time.sleep(10) + + while self.running: + try: + current_time = datetime.utcnow() + + # Run shift automation + logger.debug("Running scheduled shift automation check...") + stats = self._run_shift_automation() + + self.last_run_time = current_time + + # Log summary if there were changes + if any([ + stats.get("auto_started", 0) > 0, + stats.get("auto_completed", 0) > 0, + stats.get("marked_no_show", 0) > 0 + ]): + logger.info( + f"Shift automation run completed: " + f"{stats.get('auto_started', 0)} started, " + f"{stats.get('auto_completed', 0)} completed, " + f"{stats.get('marked_no_show', 0)} no-shows" + ) + + # Sleep until next check + time.sleep(self.check_interval_seconds) + + except Exception as e: + logger.error(f"Error in shift scheduler loop: {str(e)}", exc_info=True) + # Sleep for 30 seconds before retrying on error + time.sleep(30) + + def _run_shift_automation(self) -> dict: + """Run shift automation process with database session management""" + db_gen = get_db() + db = next(db_gen) + + try: + stats = shift_automation_service.process_shift_automation(db) + return stats + except Exception as e: + logger.error(f"Error running shift automation: {str(e)}", exc_info=True) + return {"processed": 0, "auto_started": 0, "auto_completed": 0, "marked_no_show": 0, "errors": 1} + finally: + db.close() + + def get_status(self) -> dict: + """Get scheduler status information""" + return { + "running": self.running, + "last_run_time": self.last_run_time.isoformat() if self.last_run_time else None, + "check_interval_seconds": self.check_interval_seconds + } + + +# Singleton instance +_shift_scheduler: Optional[ShiftScheduler] = None + + +def get_shift_scheduler() -> ShiftScheduler: + """Get or create the singleton shift scheduler instance""" + global _shift_scheduler + if _shift_scheduler is None: + _shift_scheduler = ShiftScheduler() + return _shift_scheduler + diff --git a/Backend/src/main.py b/Backend/src/main.py index af99f453..230ff0d4 100644 --- a/Backend/src/main.py +++ b/Backend/src/main.py @@ -293,6 +293,7 @@ from .payments.routes.approval_routes import router as financial_approval_routes from .payments.routes.gl_routes import router as gl_routes from .payments.routes.reconciliation_routes import router as reconciliation_routes from .payments.routes.accountant_security_routes import router as accountant_security_routes +from .auth.routes.admin_security_routes import router as admin_security_routes from .hotel_services.routes import service_routes, service_booking_routes, inventory_routes, guest_request_routes, staff_shift_routes from .content.routes import ( banner_routes, page_content_routes, home_routes, about_routes, @@ -330,6 +331,7 @@ app.include_router(financial_approval_routes, prefix=api_prefix) app.include_router(gl_routes, prefix=api_prefix) app.include_router(reconciliation_routes, prefix=api_prefix) app.include_router(accountant_security_routes, prefix=api_prefix) +app.include_router(admin_security_routes, prefix=api_prefix) app.include_router(banner_routes.router, prefix=api_prefix) app.include_router(favorite_routes.router, prefix=api_prefix) app.include_router(service_routes.router, prefix=api_prefix) @@ -439,6 +441,16 @@ async def startup_event(): logger.error(f'Failed to start AI Training Scheduler: {str(e)}', exc_info=True) # Don't fail app startup if scheduler fails + # Start Shift Automation Scheduler for automatic shift management + try: + from .hotel_services.services.shift_scheduler import get_shift_scheduler + shift_scheduler = get_shift_scheduler() + shift_scheduler.start() + logger.info('Shift Automation Scheduler started - automatic shift management enabled') + except Exception as e: + logger.error(f'Failed to start Shift Automation Scheduler: {str(e)}', exc_info=True) + # Don't fail app startup if scheduler fails + logger.info(f'{settings.APP_NAME} started successfully') logger.info(f'Environment: {settings.ENVIRONMENT}') logger.info(f'Debug mode: {settings.DEBUG}') diff --git a/Backend/src/models/__init__.py b/Backend/src/models/__init__.py index 5a805f6a..94b7b9d9 100644 --- a/Backend/src/models/__init__.py +++ b/Backend/src/models/__init__.py @@ -8,6 +8,7 @@ from ..auth.models.role import Role from ..auth.models.user import User from ..auth.models.refresh_token import RefreshToken from ..auth.models.password_reset_token import PasswordResetToken +from ..auth.models.admin_session import AdminSession, AdminActivityLog # Room models from ..rooms.models.room_type import RoomType @@ -111,7 +112,7 @@ from ..integrations.models.webhook import Webhook, WebhookDelivery, WebhookEvent __all__ = [ # Auth - 'Role', 'User', 'RefreshToken', 'PasswordResetToken', + 'Role', 'User', 'RefreshToken', 'PasswordResetToken', 'AdminSession', 'AdminActivityLog', # Rooms 'RoomType', 'Room', 'RoomAttribute', 'RoomMaintenance', 'MaintenanceType', 'MaintenanceStatus', 'RoomInspection', 'InspectionType', 'InspectionStatus', 'RatePlan', 'RatePlanRule', 'RatePlanType', 'RatePlanStatus', diff --git a/Backend/src/models/__pycache__/__init__.cpython-312.pyc b/Backend/src/models/__pycache__/__init__.cpython-312.pyc index ef8cd9736238300b9a4af96a855abbc9b5c04322..b4c0312b90e8154e8506fc4c3c475d023f9083c7 100644 GIT binary patch delta 2437 zcmaKsZE#f88OL)QSdwhAWV6}#_uae-2?Rn2MTC&>CINL?KptMgvb#wxInUnY?j>PC zTmccCsnztsq5{64U~3z!#QMp0rc)K6(~oLh1u9|%)G{5Xop#y}z0V2BhYr)7+28!1 z|NlAXo;`ccJ=uAn%l3zgin1c}(>~Cy-d{gz^C*$g`p!IB!KxIcl++ASjulWLb&0Uy zOqeNksi;Id*rhHLRp`nbj#W=*5P`fKPuk5tb^i*xB)icM%aj(V3YJh;z8UDn-KtT3v9uyuobt# zHkpORcI<&3sUu)m{4&S?7{&U zKmh^~P|g(>8Pq_-K^Vj#7(yL%%t96oFl1gMcH=M%OI<5Qa1ZQ}x=!S9FYLvAuus4$)o5fF%fbb|B#b@9d>CF*8#ba;`pM_`fId~4Ahv)G)9GBT#aRN`m zNqhlbkadfA5nqCrq@E{U##3+#8=(99D^~LwTZL% z2D~BlLh&nn6W+vga1O^|9N&Vsq~9)njc>!-QZEwJG0iz~aTber@Haq-Y2H~fTOxjs z@4~zI9=wO|!~1w1&f^F00bYO$co8njo_hqnJgqj5AHs+D5qyLn!^hHJD*l9@z$aLM z0)7gg;%D#~UV=+_87|`$xFUO&i9h4#@Ht+EtM~W<%*80Z(HT{}J365Vb|97!6Mup0arPb!UM0c8?GIxkJ*>_p)W@ibVx5q4N zSl&yw>@Ais%Lvs~oww>Nvy349v#LM*ILjv(_b`?+mN7aQ%PH?TSD}xwK5Jrq<1K2f z&PTn8-9~>iYN@oyHYa%5J=uwM>od5wgc43iOw+K($PK3TQqwEgb+gV+4&p*DqifYs z#Yz@u+*;1#Di}6ub;hhSSynRa=464&DwYn$#IL3&olTW4uH6iei9!txxgt*0NTcp$ z)5q|0GRsV&6meNC0rrB_;#yz{u?&-NwS^`~`Y5gvqU>3L3(h0ZUzB{EFJ%*l8 zg+803COYPBur{-t!t?KDtT$1JniIIoj6`p5Pqts}HE2myh{}B){{~JSNiAcT`!{(^b_%Ue z-$+Y+QR^n2;z4c-(2sqtl0i+V$(+8Kp71qq1@@hKdRR@5Ozr(dsc+%T{Xbc6Vp!A7 zlcI0s)R)L4M9xr?Q)3hIFW{$dBZog8*v@UPY%XgauF1uXX!<~}&_?LVs-{4}mQW{y zWPifY`^^!$%GgE6{bAbxOTj=Bbj$Cj9f1<7p{8|xknJJ*hd*lJ3tDKP2Lf(t4OCg= zRW+zP5V!8;reVei?G4m5?qQkZcDK-S31Q?WpRcET%}EnN-^(rg=t3Z*JVe(60q1@; z9%kejkC-qYW}X21pd6IWn5XdoyD@ex8O_|HgCv6W)ZWFDD!g0{-T>^;Uf z!a*$uj%>P*uYf-FYBB%w2SQGYgevHeBS!ay>L?#-nV}!&fhTAzR6|!y|GQAk`6^qd z8D|);F@8qfVQ*<-Q0*B==k%YORn+-92ftwak}>guqJ!b>Wq&to#r$T^(#^2H_)5{r N@#&GH%CzrH@4q9M)MWqw delta 2152 zcmb7^S#*@;8OQkw9Uzm8Gnvf3&zOB91OkW)Bw&qz7R&m8gdv$s2HyY7@XaI;Y7HQQ zo?g`OfKn}$B6g9Y!HC{?yb-19p?7r|tQ+D&d%Aelp4M|Z&l}A}Pj5Qs%>VqJ|NnWv z@7-n&tT@?Yd#kFdVn+FMreZQ zIK`2mISFmr)cD_P@7Cy0Eoh}y&DE+6?bNQ>spg@BIxwH+qmw$ZfEHjOEkqY}>AXfQ z!eUyi*`=1?CgPM)+k2~e;)c0@)-GN@})wN&UiB+@;t7$dX z&>HOp6kj%cV^~M)bRAObv4J*VBW=Vc+Jw!t8APPBu-byHv{iFN-HUCsO> zX}8W3%0z(*nr~D^4AZdYS~Y?_vudeIVDE84l&GqVsxR37B+@Nm$5$>n^biffj3aae4-v1O>6=mJ&MQZF`S?iI%`)y#pCoio}edmJx~1%C+Vc-4)t?9Nl&7V>TrrqX>Y#z z1y0jxJVj6844u(lr+ON{B)&E?wp7<$>KvY>XYm|8hY6a%c{;ECMe2D>(xm3a>IGb& z3!0ax7x5DD@>wn`UZ$6|w^VU*maiVK(yO>g7x5ashS%wJT%t=lzeW8HZ_pcflitKz z#81zvom&5`>JNCE-o`uh4&J4A@gBX05|wa;uHb!oUyr>_{ShC~2l$Xa#7FcIKBkZH z34NloW$G$ErB5|4SJ&_veTL8Jb9_Nx;7j@v*Xg=3BRylF`?NuS8trLZv2TAxPbyn9 zlf3@B7baUQS1m?h(q8qinZ{YJbZoFh%Kr{#l!qKYwk#Ly=6^ckmKs49$ErIly@Gf0 zrs_-9fS@T-;3nr#MO%uIzHu(S=USJ25AcNU;XYm`JBr1kD43#y1&iX#J)ga*=9*S zW@;pp+B3cFgUOMcSw3%*B=slD>mDs;`ls`x-eGyq7A^)7##TNN2=a^7Ey0p4nfZ1i z{X@y3IVe*|iwyC<0}=kJ+RL56FgFGLyen8`%db z_wejc$g)>(l$%0fV;}d1g1px0{7$KXqrR{sZ>9!QW-4304rArEQe0e1HdkK6e!d*4 zw;qte2PJAQM=_3ut@a1RJ0$X;1hq28R!F7g4w{F>Kf>v7y=D4G803j?9sl0BlpT>6 zSJcFLT_nz}HC~>GxaTJGnZ8}AQS(=PE)sM7TE_oIHxB!94 diff --git a/Backend/src/notifications/models/__pycache__/notification.cpython-312.pyc b/Backend/src/notifications/models/__pycache__/notification.cpython-312.pyc index 3e05570e2c39533f7a7f1a64b96e5cf2a0ccce3a..09bea8818b0c5dde6dc92bdaa96c09601f9d22a3 100644 GIT binary patch delta 772 zcmZvZ%TE(g7{$5mjFz?|t&izEz^25IOpGy7gDqg{b1Di&#f4(DHtkeKai-i(7r0Q@ zu4?W=To7Y0ChRctXK4HbChm3Rin#Wk%K{|K;y35M&-uQs()W_`O;M5q{BxH}Zs)6_ zN#s16ju%)}$|PFJ#q?^*O0$R3y0qlB1NKMC6NMd-FOsvYDi=tZxpFalFTaU%sSQpH zPxGs2nPW3fp`(QF^*XIa;LvjTY*39Q&tV5p2;ZjGi456%mpu_)RyN4%t2m>2e&Dv< zW+QNYuO2urI?Mcm?mb$>*+~wYmLR8q3qS?91YF^;NR)2-)N!AB_2JJIod%$nv;{1& zSGt-JHQi%~eb!IqMIldO(_KIY9sr`Q_t`If^vtePl$2J1eI{+p0V{06*x;@rqn{B5 mM2$rTo4qtgNT02lvqzIe?RVF%eVq9)vpv8E+Zy|8PW}UTR-5$z delta 754 zcmajcO-~a+7zc2syQ_WM&?5F_TVPA{P!9+~paK?cgrAAor9lu}e(yUUTs+v%@qs16C)Ln{dCs~|K?E>A=2G|5W*2ev}+CznvXd*J1pAqYM zNpPTWw1cqccDGtq$L1yfNhB4Dj$#Hd{r%W36MLA+tiPu3F;*egJMicnCY%>6@F_H> z!6YbyIdDl(WI=r0QmHtyQnZTVAvTYNcSyeeMx;t)@ZH_w19r~w72U=e77Ky|H6 zn@2>`(Q$1Ghk25FBrE+v<3nk739FIgkgHpOk4T@r6}< zGx<4mM%Iej7K$MmTX3xFI`_NwQPsZ{Ok@2i<6pNuX;9&uVft^l`jl)k*ZCb}DfdeQh_ z!t_3SYDh*nh*j4C66Y&GCVG<;V>tFJ(KtQJrDJ>^6TlYTF!sbTzZyFua(|hntVquJ pv(#_l*-fw2y=RyCQ+ktI>Wi?{PW$=gm$etQ!yeH&Oi^Ec`a3bfm_h&m diff --git a/Backend/src/notifications/models/notification.py b/Backend/src/notifications/models/notification.py index 6865a3fc..fc6d5e9d 100644 --- a/Backend/src/notifications/models/notification.py +++ b/Backend/src/notifications/models/notification.py @@ -26,6 +26,7 @@ class NotificationType(str, enum.Enum): check_out_reminder = 'check_out_reminder' marketing_campaign = 'marketing_campaign' loyalty_update = 'loyalty_update' + shift_update = 'shift_update' system_alert = 'system_alert' custom = 'custom' diff --git a/Backend/src/security/middleware/__pycache__/step_up_auth.cpython-312.pyc b/Backend/src/security/middleware/__pycache__/step_up_auth.cpython-312.pyc index b9bab30af062dc68b4bc348a691c9efb8d991e8b..01e2f374873e5294e4ff1ec10b4501eda839afec 100644 GIT binary patch delta 1530 zcmZ9MO>7%Q6o6;^Z^w@R5_@eY&gLICsgs7ZwP+HO5K2=Cs-{1xtRi=@*NPL{8)nzg zqDqNG4oFCdbR<+H1m)I%K$R@+aN@urNmG$(RSTgaggAf;L9I9--rK|k*uyvP&70Zx zGjHaXM6!<1>5Kwz&GvU0+tdkSji z;5WJhRs2nLgMMNPuUl9M;??aH*pVsZSQIoC;O|-#>b+r|8(Fur7MyBD;8J6S?ql++ zl>(F1jH<~?naYxGW3r?wW!SrFy+tFu*YQBc8+RlAJB%3)zGG_LCTdnZ1IDd8U;9U6e+?NtX!U|04~9pBv^7P4Td~-*TNS znD~mhpO?%|+f}N@DB;)5wPhB8WqPntKvgX$<&35*%4HS<3u{Nj5gmwbK*Gg*DEA@y z5lYOp8Q0ftdLz7@d-EGw_dsD zP23N5Y`Qz@HWCp(^1kog5eBw|fz843ZDHcBQ2et0>!GiPcBU_EO<&l&xVSxCe9N475EGap;s3w_Gl-Wx-ZJ(D^ zNz)iR!~Y6JAPpfLU&b_am#RrjQ(rG=^UMnq%!8OlJcpP;JP$}X4-#hRYwyp^##Kd6 znX*)p*%`Fp!5MX6r$JJk04xT6dyS?G`2znUe4g^EOUTmrC~m;EOPGu;>@x0a^ag)B zB3fUDnRob|$VocNA4kTi$d5)hj_W-}!uxkqO*Zb$-ka2Tc=qnX96B*_^zo)x*RmgU z-H|ck&SZ>qb+ZaZ!WY5lLWT##(DOQBMvQn%{&b8%fh$FMf-QoI2dXN7x|ve?$YiFL z#|}crw#kVa>8g=fjSSXEsz!Qh3~!O{qnDAZsj8J3+{yS2Mp@5}}v z+mw(#NDPT^K`?6M!T48{hrVe{)E8rTuoWYl37bg5gCRzXf)buQXO>#*&E&iHp84jU zbI6Tz#2fS16C^z`ITt~wJG>9=Rvr=O+7MzjX4ahBpsLPldAqDED}CT4AF zU`7~if_?qNeGv9>H8ev;6KdFEL}nbL$zyeL!|}eew2v3*xnLltE9p$8SSlFlf-$$* z_Ngr8-eR#we(tUb)Ry*L*BE&DV}%jm%U3@I7#sepV^O6**DBQ@U2WuH`ZIt z=L#$a2CK4zvQVpG(~^Z@?aVr@kS(%|reJCnE43_+lmvn=i6{ZcKK^}Ua(WZ)TM$|i z9zzHqv>~)3U@jTnlC1@h-78CE9bt)0M$$S23`NJpVohP3uykYz>-eT6F~N=a7T?oMP1Z`~WkGI+I(A?=OhL=1a}z78;#mHxO&_S`p$b&196FW| z%fLGLPPvv$Q(|OC5hV%o7ffEdMh05y&N*~XB|UIh40C;VcQB#eUPH2v>ka9699S_)%W1coPlLYU User: - from ...shared.utils.role_helpers import is_admin - if not is_admin(current_user, db): return current_user # Only admins are subject to this dependency - session_token = request.headers.get('X-Session-Token') or request.cookies.get('session_token') + # Try to get session token from header or cookie + session_token = request.headers.get('X-Session-Token') or request.cookies.get('admin_session_token') - requires_step_up, reason = accountant_security_service.require_step_up( + # Check if step-up is required using admin security service + requires_step_up, reason = admin_security_service.require_step_up( db=db, user_id=current_user.id, session_token=session_token, - action_description=action_description, - enforce_role_check=False, # allow admin + action_description=action_description ) if requires_step_up: diff --git a/Frontend/src/App.tsx b/Frontend/src/App.tsx index 399678ff..165f16be 100644 --- a/Frontend/src/App.tsx +++ b/Frontend/src/App.tsx @@ -119,7 +119,7 @@ const ServiceManagementPage = lazy(() => import('./pages/admin/ServiceManagement const InventoryManagementPage = lazy(() => import('./pages/admin/InventoryManagementPage')); const MaintenanceManagementPage = lazy(() => import('./pages/admin/MaintenanceManagementPage')); const InspectionManagementPage = lazy(() => import('./pages/admin/InspectionManagementPage')); -const StaffShiftManagementPage = lazy(() => import('./pages/admin/StaffShiftManagementPage')); +const StaffShiftDashboardPage = lazy(() => import('./pages/admin/StaffShiftDashboardPage')); const StaffDashboardPage = lazy(() => import('./pages/staff/DashboardPage')); const StaffInventoryViewPage = lazy(() => import('./pages/staff/InventoryViewPage')); @@ -751,7 +751,7 @@ function App() { /> } + element={} /> = ({ setError(null); const response = await (isAdmin - ? accountantSecurityService.verifyAdminStepUp({ + ? adminSecurityService.verifyStepUp({ mfa_token: data.mfaToken, }) : accountantSecurityService.verifyStepUp({ - mfa_token: data.mfaToken, + mfa_token: data.mfaToken, })); if (response.status === 'success' && response.data.step_up_completed) { @@ -132,11 +133,11 @@ const StepUpAuthModal: React.FC = ({ setError(null); const response = await (isAdmin - ? accountantSecurityService.verifyAdminStepUp({ + ? adminSecurityService.verifyStepUp({ password: data.password, }) : accountantSecurityService.verifyStepUp({ - password: data.password, + password: data.password, })); if (response.status === 'success' && response.data.step_up_completed) { diff --git a/Frontend/src/features/security/services/accountantSecurityService.ts b/Frontend/src/features/security/services/accountantSecurityService.ts index 5945e59d..06756052 100644 --- a/Frontend/src/features/security/services/accountantSecurityService.ts +++ b/Frontend/src/features/security/services/accountantSecurityService.ts @@ -45,15 +45,6 @@ class AccountantSecurityService { return response.data; } - async verifyAdminStepUp(data: { - mfa_token?: string; - password?: string; - session_token?: string; - }): Promise<{ status: string; data: { step_up_completed: boolean } }> { - const response = await apiClient.post('/auth/admin/step-up/verify', data); - return response.data; - } - async getSessions(): Promise<{ status: string; data: { sessions: AccountantSession[] } }> { const response = await apiClient.get('/accountant/security/sessions'); return response.data; diff --git a/Frontend/src/features/security/services/adminSecurityService.ts b/Frontend/src/features/security/services/adminSecurityService.ts new file mode 100644 index 00000000..ae4bf59d --- /dev/null +++ b/Frontend/src/features/security/services/adminSecurityService.ts @@ -0,0 +1,68 @@ +import apiClient from '../../../shared/services/apiClient'; + +export interface AdminSession { + id: number; + ip_address?: string; + user_agent?: string; + country?: string; + city?: string; + last_activity: string; + step_up_authenticated: boolean; + step_up_expires_at?: string; + created_at: string; + expires_at: string; +} + +export interface AdminActivityLog { + id: number; + user_id: number; + activity_type: string; + activity_description: string; + ip_address?: string; + country?: string; + city?: string; + risk_level: 'low' | 'medium' | 'high' | 'critical'; + is_unusual: boolean; + metadata?: any; + created_at: string; +} + +class AdminSecurityService { + async verifyStepUp(data: { + mfa_token?: string; + password?: string; + session_token?: string; + }): Promise<{ status: string; data: { step_up_completed: boolean } }> { + const response = await apiClient.post('/admin/security/step-up/verify', data); + return response.data; + } + + async getSessions(): Promise<{ status: string; data: { sessions: AdminSession[] } }> { + const response = await apiClient.get('/admin/security/sessions'); + return response.data; + } + + async revokeSession(sessionId: number): Promise<{ status: string; message: string }> { + const response = await apiClient.post(`/admin/security/sessions/${sessionId}/revoke`); + return response.data; + } + + async revokeAllSessions(): Promise<{ status: string; data: { revoked_count: number } }> { + const response = await apiClient.post('/admin/security/sessions/revoke-all'); + return response.data; + } + + async getActivityLogs(params?: { + page?: number; + limit?: number; + risk_level?: string; + is_unusual?: boolean; + }): Promise<{ status: string; data: { logs: AdminActivityLog[]; pagination: any } }> { + const response = await apiClient.get('/admin/security/activity-logs', { params }); + return response.data; + } +} + +const adminSecurityService = new AdminSecurityService(); +export default adminSecurityService; + diff --git a/Frontend/src/features/staffShifts/services/staffShiftService.ts b/Frontend/src/features/staffShifts/services/staffShiftService.ts index a73fa878..a1a6906a 100644 --- a/Frontend/src/features/staffShifts/services/staffShiftService.ts +++ b/Frontend/src/features/staffShifts/services/staffShiftService.ts @@ -144,6 +144,20 @@ const staffShiftService = { const response = await apiClient.get('/staff-shifts/workload', { params: { date } }); return response.data; }, + + // Status change methods - only cancellation is manual, start/complete are automatic + async cancelShift(shiftId: number, reason?: string, notes?: string) { + const response = await apiClient.post(`/staff-shifts/${shiftId}/cancel`, { + reason, + notes, + }); + return response.data; + }, + + async deleteShift(shiftId: number) { + const response = await apiClient.delete(`/staff-shifts/${shiftId}`); + return response.data; + }, }; export default staffShiftService; diff --git a/Frontend/src/pages/admin/StaffShiftDashboardPage.tsx b/Frontend/src/pages/admin/StaffShiftDashboardPage.tsx new file mode 100644 index 00000000..8dfb5ab3 --- /dev/null +++ b/Frontend/src/pages/admin/StaffShiftDashboardPage.tsx @@ -0,0 +1,1401 @@ +import React, { useEffect, useState } from 'react'; +import { + Calendar, Clock, TrendingUp, Trash2, ChevronLeft, ChevronRight, + Sparkles, Crown, Activity, X, AlertCircle, Plus, Edit, Search, + Users, XCircle, BarChart3, Settings +} from 'lucide-react'; +import staffShiftService, { StaffShift } from '../../features/staffShifts/services/staffShiftService'; +import userService, { User } from '../../features/auth/services/userService'; +import { toast } from 'react-toastify'; +import Loading from '../../shared/components/Loading'; +import Pagination from '../../shared/components/Pagination'; +import { formatDate } from '../../shared/utils/format'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; + +// Custom scrollbar styles - Luxury design +const customScrollbarStyles = ` + .custom-scrollbar::-webkit-scrollbar { + width: 4px; + } + @media (min-width: 640px) { + .custom-scrollbar::-webkit-scrollbar { + width: 6px; + } + } + .custom-scrollbar::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.05); + border-radius: 10px; + } + .custom-scrollbar::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, rgba(251, 191, 36, 0.6), rgba(245, 158, 11, 0.6)); + border-radius: 10px; + border: 1px solid rgba(251, 191, 36, 0.3); + } + .custom-scrollbar::-webkit-scrollbar-thumb:hover { + background: linear-gradient(180deg, rgba(251, 191, 36, 0.8), rgba(245, 158, 11, 0.8)); + } + .custom-scrollbar { + scrollbar-width: thin; + scrollbar-color: rgba(251, 191, 36, 0.6) rgba(255, 255, 255, 0.05); + } +`; + +interface MonthlyStats { + month: string; + monthKey: string; + total: number; + scheduled: number; + in_progress: number; + completed: number; + cancelled: number; + no_show: number; +} + +interface DailyStats { + date: string; + total: number; + scheduled: number; + in_progress: number; + completed: number; + cancelled: number; + no_show: number; + shifts: StaffShift[]; +} + +type TabType = 'dashboard' | 'management'; + +const StaffShiftDashboardPage: React.FC = () => { + // Tab state + const [activeTab, setActiveTab] = useState('dashboard'); + + // Dashboard states + const [allShifts, setAllShifts] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedMonth, setSelectedMonth] = useState(new Date()); + const [monthlyStats, setMonthlyStats] = useState([]); + const [dailyStats, setDailyStats] = useState([]); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [shiftToDelete, setShiftToDelete] = useState(null); + const [deleting, setDeleting] = useState(false); + const [expandedMonths, setExpandedMonths] = useState>(new Set()); + const [actionBlocked, setActionBlocked] = useState<{ title: string; message: string } | null>(null); + + // Management states + const [shifts, setShifts] = useState([]); + const [showModal, setShowModal] = useState(false); + const [editingShift, setEditingShift] = useState(null); + const [staffMembers, setStaffMembers] = useState([]); + const [showCancelModal, setShowCancelModal] = useState(false); + const [selectedShift, setSelectedShift] = useState(null); + const [cancelReason, setCancelReason] = useState(''); + const [cancelNotes, setCancelNotes] = useState(''); + const [processingStatus, setProcessingStatus] = useState(null); + const [filters, setFilters] = useState({ + search: '', + status: '', + staff_id: '', + shift_date: '', + department: '', + }); + const [currentPage, setCurrentPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [totalItems, setTotalItems] = useState(0); + const itemsPerPage = 20; + const [formData, setFormData] = useState({ + staff_id: 0, + shift_date: new Date(), + shift_type: 'full_day', + start_time: '08:00', + end_time: '20:00', + break_duration_minutes: 30, + department: '', + notes: '', + }); + + const shiftTypes = [ + { value: 'morning', label: 'Morning (6 AM - 2 PM)' }, + { value: 'afternoon', label: 'Afternoon (2 PM - 10 PM)' }, + { value: 'night', label: 'Night (10 PM - 6 AM)' }, + { value: 'full_day', label: 'Full Day (8 AM - 8 PM)' }, + { value: 'custom', label: 'Custom' }, + ]; + + const statuses = [ + { value: 'scheduled', label: 'Scheduled', color: 'bg-yellow-100 text-yellow-800' }, + { value: 'in_progress', label: 'In Progress', color: 'bg-blue-100 text-blue-800' }, + { value: 'completed', label: 'Completed', color: 'bg-green-100 text-green-800' }, + { value: 'cancelled', label: 'Cancelled', color: 'bg-gray-100 text-gray-800' }, + { value: 'no_show', label: 'No Show', color: 'bg-red-100 text-red-800' }, + ]; + + // Fetch all shifts for dashboard + const fetchAllShifts = async () => { + try { + setLoading(true); + const response = await staffShiftService.getShifts({ limit: 1000 }); + if (response.status === 'success' && response.data) { + setAllShifts(response.data.shifts || []); + } + } catch (error: any) { + toast.error(error.response?.data?.message || 'Unable to load shifts'); + } finally { + setLoading(false); + } + }; + + // Fetch shifts for management (paginated) + const fetchShifts = async () => { + try { + setLoading(true); + const params: any = { page: currentPage, limit: itemsPerPage }; + if (filters.status) params.status = filters.status; + if (filters.staff_id) params.staff_id = parseInt(filters.staff_id); + if (filters.shift_date) params.shift_date = filters.shift_date; + if (filters.department) params.department = filters.department; + + const response = await staffShiftService.getShifts(params); + if (response.status === 'success' && response.data) { + let shiftList = response.data.shifts || []; + if (filters.search) { + shiftList = shiftList.filter((shift: StaffShift) => + shift.staff_name?.toLowerCase().includes(filters.search.toLowerCase()) || + shift.department?.toLowerCase().includes(filters.search.toLowerCase()) + ); + } + setShifts(shiftList); + setTotalPages(response.data.pagination?.total_pages || 1); + setTotalItems(response.data.pagination?.total || 0); + } + } catch (error: any) { + toast.error(error.response?.data?.message || 'Unable to load shifts'); + } finally { + setLoading(false); + } + }; + + const fetchStaffMembers = async () => { + try { + const [staffResponse, housekeepingResponse] = await Promise.all([ + userService.getUsers({ role: 'staff', limit: 100 }), + userService.getUsers({ role: 'housekeeping', limit: 100 }) + ]); + const allUsers: User[] = []; + if (staffResponse.data?.users) allUsers.push(...staffResponse.data.users); + if (housekeepingResponse.data?.users) allUsers.push(...housekeepingResponse.data.users); + setStaffMembers(allUsers); + } catch (error) { + console.error('Error fetching staff members:', error); + } + }; + + useEffect(() => { + if (activeTab === 'dashboard') { + fetchAllShifts(); + } else { + fetchShifts(); + fetchStaffMembers(); + } + }, [activeTab]); + + useEffect(() => { + if (activeTab === 'management') { + setCurrentPage(1); + } + }, [filters, activeTab]); + + useEffect(() => { + if (activeTab === 'management') { + fetchShifts(); + } + }, [filters, currentPage, activeTab]); + + useEffect(() => { + if (allShifts.length > 0 && activeTab === 'dashboard') { + calculateStats(); + } + }, [allShifts, selectedMonth, activeTab]); + + // Auto-refresh for management tab + useEffect(() => { + if (activeTab === 'management') { + const interval = setInterval(() => fetchShifts(), 30000); + return () => clearInterval(interval); + } + }, [filters, currentPage, activeTab]); + + const calculateStats = () => { + const months: { [key: string]: MonthlyStats } = {}; + allShifts.forEach(shift => { + const shiftDate = new Date(shift.shift_date); + const monthKey = `${shiftDate.getFullYear()}-${String(shiftDate.getMonth() + 1).padStart(2, '0')}`; + const monthName = shiftDate.toLocaleString('default', { month: 'long', year: 'numeric' }); + if (!months[monthKey]) { + months[monthKey] = { + month: monthName, + monthKey, + total: 0, + scheduled: 0, + in_progress: 0, + completed: 0, + cancelled: 0, + no_show: 0, + }; + } + months[monthKey].total++; + months[monthKey][shift.status as keyof MonthlyStats]++; + }); + setMonthlyStats(Object.values(months).sort((a, b) => b.monthKey.localeCompare(a.monthKey))); + + const selectedMonthStart = new Date(selectedMonth.getFullYear(), selectedMonth.getMonth(), 1); + const selectedMonthEnd = new Date(selectedMonth.getFullYear(), selectedMonth.getMonth() + 1, 0); + const days: { [key: string]: DailyStats } = {}; + allShifts.forEach(shift => { + const shiftDate = new Date(shift.shift_date); + if (shiftDate >= selectedMonthStart && shiftDate <= selectedMonthEnd) { + const dayKey = shiftDate.toISOString().split('T')[0]; + if (!days[dayKey]) { + days[dayKey] = { + date: dayKey, + total: 0, + scheduled: 0, + in_progress: 0, + completed: 0, + cancelled: 0, + no_show: 0, + shifts: [], + }; + } + days[dayKey].total++; + days[dayKey][shift.status as keyof DailyStats]++; + days[dayKey].shifts.push(shift); + } + }); + setDailyStats(Object.values(days).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())); + }; + + const handleDelete = async () => { + if (!shiftToDelete) return; + try { + setDeleting(true); + await staffShiftService.deleteShift(shiftToDelete.id); + toast.success('Shift deleted successfully'); + setShowDeleteModal(false); + setShiftToDelete(null); + fetchAllShifts(); + } catch (error: any) { + toast.error(error.response?.data?.detail || 'Unable to delete shift'); + } finally { + setDeleting(false); + } + }; + + const canEditShift = (shift: StaffShift) => + !(shift.status === 'in_progress' || shift.status === 'completed'); + + const canDeleteShift = (shift: StaffShift) => + shift.status === 'scheduled' || shift.status === 'cancelled'; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!formData.staff_id) { + toast.error('Please select a staff member or housekeeping user'); + return; + } + try { + const dataToSubmit: any = { + staff_id: formData.staff_id, + shift_date: formData.shift_date.toISOString(), + shift_type: formData.shift_type, + start_time: formData.start_time, + end_time: formData.end_time, + break_duration_minutes: formData.break_duration_minutes, + }; + if (formData.department) dataToSubmit.department = formData.department; + if (formData.notes) dataToSubmit.notes = formData.notes; + + if (editingShift) { + await staffShiftService.updateShift(editingShift.id, dataToSubmit); + toast.success('Shift updated successfully'); + } else { + await staffShiftService.createShift(dataToSubmit); + toast.success('Shift created successfully'); + } + setShowModal(false); + resetForm(); + fetchShifts(); + fetchAllShifts(); + } catch (error: any) { + toast.error(error.response?.data?.message || 'Unable to save shift'); + } + }; + + const handleEdit = (shift: StaffShift) => { + if (!canEditShift(shift)) { + setActionBlocked({ + title: 'Edit not allowed', + message: 'Shifts that are in progress or completed cannot be modified. Please cancel and recreate if needed.', + }); + return; + } + setEditingShift(shift); + const shiftDate = shift.shift_date ? new Date(shift.shift_date) : new Date(); + setFormData({ + staff_id: shift.staff_id, + shift_date: shiftDate, + shift_type: shift.shift_type, + start_time: shift.start_time || '08:00', + end_time: shift.end_time || '20:00', + break_duration_minutes: shift.break_duration_minutes || 30, + department: shift.department || '', + notes: shift.notes || '', + }); + setShowModal(true); + }; + + const handleCancelShift = async () => { + if (!selectedShift) return; + try { + setProcessingStatus(selectedShift.id); + await staffShiftService.cancelShift(selectedShift.id, cancelReason || 'MANUAL_CANCEL', cancelNotes); + toast.success('Shift cancelled successfully'); + setShowCancelModal(false); + setSelectedShift(null); + setCancelReason(''); + setCancelNotes(''); + fetchShifts(); + fetchAllShifts(); + } catch (error: any) { + toast.error(error.response?.data?.detail || 'Unable to cancel shift'); + } finally { + setProcessingStatus(null); + } + }; + + const canCancelShift = (shift: StaffShift) => { + return shift.status === 'scheduled' || shift.status === 'in_progress'; + }; + + const resetForm = () => { + setEditingShift(null); + setFormData({ + staff_id: 0, + shift_date: new Date(), + shift_type: 'full_day', + start_time: '08:00', + end_time: '20:00', + break_duration_minutes: 30, + department: '', + notes: '', + }); + }; + + const getStatusColor = (status: string) => { + const colors: { [key: string]: string } = { + scheduled: 'bg-gradient-to-r from-amber-400 to-amber-600', + in_progress: 'bg-gradient-to-r from-blue-400 to-blue-600', + completed: 'bg-gradient-to-r from-emerald-400 to-emerald-600', + cancelled: 'bg-gradient-to-r from-slate-400 to-slate-600', + no_show: 'bg-gradient-to-r from-red-400 to-red-600', + }; + return colors[status] || 'bg-gradient-to-r from-gray-400 to-gray-600'; + }; + + const getStatusIcon = (status: string) => { + switch (status) { + case 'scheduled': return ; + case 'in_progress': return ; + case 'completed': return ; + case 'cancelled': return ; + case 'no_show': return ; + default: return ; + } + }; + + const getStatusBadge = (status: string) => { + return statuses.find((s) => s.value === status) || statuses[0]; + }; + + const navigateMonth = (direction: 'prev' | 'next') => { + const newDate = new Date(selectedMonth); + if (direction === 'prev') { + newDate.setMonth(newDate.getMonth() - 1); + } else { + newDate.setMonth(newDate.getMonth() + 1); + } + setSelectedMonth(newDate); + }; + + const toggleMonthExpansion = (monthKey: string) => { + const newExpanded = new Set(expandedMonths); + if (newExpanded.has(monthKey)) { + newExpanded.delete(monthKey); + } else { + newExpanded.add(monthKey); + } + setExpandedMonths(newExpanded); + }; + + if (loading && (activeTab === 'dashboard' ? allShifts.length === 0 : shifts.length === 0)) { + return ; + } + + return ( + <> + +
+ {/* Luxury Background Effects */} +
+
+
+
+ + {/* Luxury Header - Fixed */} +
+
+
+ +
+
+

+ + Staff Shift Management + +

+

+ + Analytics, insights and management +

+
+ {activeTab === 'management' && ( + + )} +
+ + {/* Tabs */} +
+ + +
+ + {/* Month Navigation for Dashboard */} + {activeTab === 'dashboard' && ( +
+ +

+ {selectedMonth.toLocaleString('default', { month: 'long', year: 'numeric' })} +

+ +
+ )} +
+ + {/* Scrollable Content Area */} +
+
+ {activeTab === 'dashboard' ? ( + // Dashboard View +
+ {monthlyStats.length === 0 ? ( +
+ +

No shifts found

+
+ ) : ( + monthlyStats.map((stat, index) => { + const isExpanded = expandedMonths.has(stat.monthKey); + const monthDate = new Date(stat.monthKey + '-01'); + const monthDailyStats = dailyStats.filter(day => { + const dayDate = new Date(day.date); + return dayDate.getMonth() === monthDate.getMonth() && + dayDate.getFullYear() === monthDate.getFullYear(); + }); + + return ( +
+ + + {/* Status Grid */} +
+
+
+
+ +
+ Scheduled +
+
{stat.scheduled}
+
+ {stat.total > 0 ? Math.round((stat.scheduled / stat.total) * 100) : 0}% +
+
+ +
+
+
+ +
+ In Progress +
+
{stat.in_progress}
+
+ {stat.total > 0 ? Math.round((stat.in_progress / stat.total) * 100) : 0}% +
+
+ +
+
+
+ +
+ Completed +
+
{stat.completed}
+
+ {stat.total > 0 ? Math.round((stat.completed / stat.total) * 100) : 0}% +
+
+ +
+
+
+ +
+ Cancelled +
+
{stat.cancelled}
+
+ {stat.total > 0 ? Math.round((stat.cancelled / stat.total) * 100) : 0}% +
+
+ + {stat.no_show > 0 && ( +
+
+
+ +
+ No Show +
+
{stat.no_show}
+
+ {stat.total > 0 ? Math.round((stat.no_show / stat.total) * 100) : 0}% +
+
+ )} +
+ + {/* Expanded Daily Breakdown */} + {isExpanded && ( +
+ {monthDailyStats.length > 0 ? ( + monthDailyStats.map((day, dayIndex) => ( +
+
+
+
+ +
+
+

+ {formatDate(day.date)} +

+

+ {new Date(day.date).toLocaleDateString('en-US', { weekday: 'long' })} +

+
+
+
+
+
{day.total}
+

Shifts

+
+
+ {day.scheduled > 0 && ( + + {day.scheduled} + + )} + {day.in_progress > 0 && ( + + {day.in_progress} + + )} + {day.completed > 0 && ( + + {day.completed} + + )} + {day.cancelled > 0 && ( + + {day.cancelled} + + )} +
+
+
+ +
+ {day.shifts + .sort((a, b) => { + const statusOrder = { 'in_progress': 0, 'scheduled': 1, 'completed': 2, 'cancelled': 3, 'no_show': 4 }; + return (statusOrder[a.status as keyof typeof statusOrder] || 99) - + (statusOrder[b.status as keyof typeof statusOrder] || 99); + }) + .map((shift) => ( +
+
+
+ {getStatusIcon(shift.status)} +
+
+
+ + {shift.staff_name || `Staff #${shift.staff_id}`} + + + {shift.status.replace('_', ' ').toUpperCase()} + +
+
+ + + {shift.start_time} - {shift.end_time} + + {shift.department && ( + {shift.department} + )} +
+
+
+
+ + {shift.status === 'completed' && ( +
+ + Archived +
+ )} +
+
+ ))} +
+
+ )) + ) : ( +
+

No daily shifts for this month

+
+ )} +
+ )} + + {/* Monthly Shifts Summary */} +
+

All Shifts in {stat.month}

+
+ {allShifts + .filter(shift => { + const shiftDate = new Date(shift.shift_date); + const monthKey = `${shiftDate.getFullYear()}-${String(shiftDate.getMonth() + 1).padStart(2, '0')}`; + return monthKey === stat.monthKey; + }) + .sort((a, b) => { + const dateA = new Date(a.shift_date); + const dateB = new Date(b.shift_date); + return dateB.getTime() - dateA.getTime(); + }) + .map((shift) => ( +
+
+
+ {getStatusIcon(shift.status)} +
+
+
+ + {shift.staff_name || `Staff #${shift.staff_id}`} + + + {shift.status.replace('_', ' ').toUpperCase()} + +
+
+ {formatDate(shift.shift_date)} + {shift.start_time} - {shift.end_time} + {shift.department && ( + {shift.department} + )} +
+
+
+
+ + {shift.status === 'completed' && ( +
+ + Archived +
+ )} +
+
+ ))} + {allShifts.filter(shift => { + const shiftDate = new Date(shift.shift_date); + const monthKey = `${shiftDate.getFullYear()}-${String(shiftDate.getMonth() + 1).padStart(2, '0')}`; + return monthKey === stat.monthKey; + }).length === 0 && ( +

No shifts to display

+ )} +
+
+
+ ); + }) + )} +
+ ) : ( + // Management View +
+ {/* Filters */} +
+
+
+ + setFilters({ ...filters, search: e.target.value })} + className="w-full pl-8 sm:pl-10 pr-3 sm:pr-4 py-2 sm:py-2.5 bg-white/10 border border-white/20 rounded-lg sm:rounded-xl focus:border-amber-400 focus:ring-2 focus:ring-amber-500/30 transition-all text-white placeholder-slate-400 text-xs sm:text-sm backdrop-blur-sm" + /> +
+ + + setFilters({ ...filters, shift_date: e.target.value })} + className="px-3 sm:px-4 py-2 sm:py-2.5 bg-white/10 border border-white/20 rounded-lg sm:rounded-xl focus:border-amber-400 focus:ring-2 focus:ring-amber-500/30 transition-all text-white text-xs sm:text-sm backdrop-blur-sm" + /> + setFilters({ ...filters, department: e.target.value })} + placeholder="Department" + className="px-3 sm:px-4 py-2 sm:py-2.5 bg-white/10 border border-white/20 rounded-lg sm:rounded-xl focus:border-amber-400 focus:ring-2 focus:ring-amber-500/30 transition-all text-white placeholder-slate-400 text-xs sm:text-sm backdrop-blur-sm" + /> +
+
+ + {/* Stats Cards */} +
+
+
+
+

Total

+

{totalItems}

+
+ +
+
+
+
+
+

Scheduled

+

+ {shifts.filter(s => s.status === 'scheduled').length} +

+
+ +
+
+
+
+
+

In Progress

+

+ {shifts.filter(s => s.status === 'in_progress').length} +

+
+ +
+
+
+
+
+

Completed

+

+ {shifts.filter(s => s.status === 'completed').length} +

+
+ +
+
+
+ + {/* Shifts Table */} +
+
+ + + + + + + + + + + + + + {shifts.map((shift) => { + const statusBadge = getStatusBadge(shift.status); + return ( + + + + + + + + + + ); + })} + +
StaffDateTypeTimeDeptStatusActions
+
{shift.staff_name || `Staff #${shift.staff_id}`}
+
+
{formatDate(shift.shift_date)}
+
+ + {shiftTypes.find((t) => t.value === shift.shift_type)?.label || shift.shift_type} + + +
{shift.start_time} - {shift.end_time}
+ {shift.break_duration_minutes && ( +
Break: {shift.break_duration_minutes} min
+ )} +
+ {shift.department || '—'} + +
+ + {statusBadge.label} + + {shift.actual_start_time && ( + + ✓ + + )} +
+
+
+ + {canCancelShift(shift) && ( + + )} +
+
+
+ {shifts.length === 0 && !loading && ( +
+ +

No shifts found

+
+ )} +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ +
+ )} +
+ )} +
+
+ + {/* Delete Confirmation Modal */} + {showDeleteModal && shiftToDelete && ( +
+
+
+
+
+ +
+

Delete Shift

+
+

+ Are you sure you want to delete the shift for{' '} + + {shiftToDelete.staff_name || `Staff #${shiftToDelete.staff_id}`} + {' '} + on {formatDate(shiftToDelete.shift_date)}? This action cannot be undone. +

+
+ + +
+
+
+
+ )} + + {/* Create/Edit Modal */} + {showModal && ( +
+
+
+

+ {editingShift ? 'Edit Shift' : 'Create Shift'} +

+ +
+
+
+
+ + +
+
+ + { + if (date) setFormData({ ...formData, shift_date: date }); + }} + dateFormat="MMMM d, yyyy" + className="w-full px-3 sm:px-4 py-2 sm:py-2.5 bg-white/10 border border-white/20 rounded-lg sm:rounded-xl focus:border-amber-400 focus:ring-2 focus:ring-amber-500/30 text-white text-xs sm:text-sm backdrop-blur-sm" + required + /> +
+
+ +
+
+ + +
+
+ + +

+ Shifts are automatically managed. Status will change based on time. +

+
+
+ +
+
+ + setFormData({ ...formData, start_time: e.target.value })} + className="w-full px-3 sm:px-4 py-2 sm:py-2.5 bg-white/10 border border-white/20 rounded-lg sm:rounded-xl focus:border-amber-400 focus:ring-2 focus:ring-amber-500/30 text-white text-xs sm:text-sm backdrop-blur-sm" + required + /> +
+
+ + setFormData({ ...formData, end_time: e.target.value })} + className="w-full px-3 sm:px-4 py-2 sm:py-2.5 bg-white/10 border border-white/20 rounded-lg sm:rounded-xl focus:border-amber-400 focus:ring-2 focus:ring-amber-500/30 text-white text-xs sm:text-sm backdrop-blur-sm" + required + /> +
+
+ + setFormData({ ...formData, break_duration_minutes: parseInt(e.target.value) || 30 })} + className="w-full px-3 sm:px-4 py-2 sm:py-2.5 bg-white/10 border border-white/20 rounded-lg sm:rounded-xl focus:border-amber-400 focus:ring-2 focus:ring-amber-500/30 text-white text-xs sm:text-sm backdrop-blur-sm" + min="0" + /> +
+
+ +
+ + setFormData({ ...formData, department: e.target.value })} + placeholder="e.g., reception, housekeeping" + className="w-full px-3 sm:px-4 py-2 sm:py-2.5 bg-white/10 border border-white/20 rounded-lg sm:rounded-xl focus:border-amber-400 focus:ring-2 focus:ring-amber-500/30 text-white placeholder-slate-400 text-xs sm:text-sm backdrop-blur-sm" + /> +
+ +
+ +